feat: add commerce refund workflow

This commit is contained in:
Codex
2026-06-29 04:53:12 +08:00
parent ead1296f80
commit 24fd788b48
13 changed files with 977 additions and 13 deletions

View File

@@ -1315,6 +1315,120 @@ async function testCommerce() {
});
assert.equal(manualConfirmedAgain.item?.idempotent, true, 'duplicate manual confirmation should be idempotent');
const studentRefundDenied = await request('/api/commerce/refunds', {
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
amountCents: 500,
reason: 'student should not be able to request admin refund',
},
expectStatus: 403,
});
assert.equal(studentRefundDenied.code, 'TENANT_ADMIN_REQUIRED', 'student must not create refund request');
const refund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
refundNo: 'RF-INTEGRATION-001',
amountCents: 500,
reason: 'integration refund',
},
});
assert.equal(refund.item?.refundNo, 'RF-INTEGRATION-001', 'tenant admin should create refund request');
assert.equal(refund.item?.status, 'requested', 'refund should start requested');
assert.equal(refund.item?.amountCents, 500, 'refund should use cents amount');
const refundAgain = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
refundNo: 'RF-INTEGRATION-001',
amountCents: 500,
reason: 'integration refund duplicate',
},
});
assert.equal(refundAgain.item?.idempotent, true, 'refundNo should be idempotent for same order and amount');
assert.equal(refundAgain.item?.id, refund.item.id, 'idempotent refund should return original request');
const excessiveRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
refundNo: 'RF-INTEGRATION-EXCESS',
amountCents: 1,
reason: 'should exceed because full amount is already reserved',
},
expectStatus: 409,
});
assert.equal(excessiveRefund.code, 'REFUND_AMOUNT_EXCEEDS_PAID', 'reserved refunds must prevent over-refund');
const listedRefunds = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
query: { orderNo: discountedOrder.item.orderNo },
});
assert.ok(listedRefunds.items?.some(item => item.id === refund.item.id), 'refund list should include created refund');
const approvedRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: refund.item.id,
action: 'approve',
note: 'approved by integration test',
},
});
assert.equal(approvedRefund.item?.status, 'approved', 'tenant admin should approve refund');
const succeededRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: refund.item.id,
action: 'mark_succeeded',
providerRefundNo: 'provider-refund-integration-001',
},
});
assert.equal(succeededRefund.item?.status, 'succeeded', 'tenant admin should mark refund succeeded');
assert.equal(succeededRefund.item?.providerRefundNo, 'provider-refund-integration-001', 'refund should record provider refund no');
const refundedStatus = await request('/api/commerce/orders/status', {
query: { orderNo: discountedOrder.item.orderNo },
});
assert.equal(refundedStatus.item?.status, 'refunded', 'full refund should mark order refunded');
assert.equal(refundedStatus.item?.payment?.status, 'refunded', 'full refund should mark payment refunded');
assert.equal(refundedStatus.item?.refundedAmountCents, 500, 'order status should expose refunded amount');
const entitlementAfterRefund = await request('/api/commerce/entitlements');
assert.ok(
entitlementAfterRefund.items?.some(
item => item.sourceType === 'order' && item.sourceId === discountedOrder.item.id && item.status === 'revoked',
),
'full order refund should revoke entitlement from that order',
);
const refundStatusAgain = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: refund.item.id,
action: 'mark_succeeded',
},
expectStatus: 409,
});
assert.equal(refundStatusAgain.code, 'REFUND_STATUS_INVALID', 'succeeded refund must not be processed twice');
const crossTenantRefundDenied = await request('/api/commerce/refunds', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { orderNo: discountedOrder.item.orderNo },
expectStatus: 403,
});
assert.equal(crossTenantRefundDenied.code, 'TENANT_ADMIN_REQUIRED', 'refund admin list must be tenant isolated');
const usedCouponClaim = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region },
@@ -1353,6 +1467,56 @@ async function testCommerce() {
});
assert.equal(freePaymentDenied.code, 'ORDER_ALREADY_PAID', 'paid zero-amount order should not create another payment');
const partialRefundOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: partialRefundOrder.item.orderNo,
amountCents: partialRefundOrder.item.amountCents,
providerTradeNo: `manual-${partialRefundOrder.item.orderNo}`,
},
});
const partialRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: partialRefundOrder.item.orderNo,
refundNo: 'RF-INTEGRATION-PARTIAL',
amountCents: 100,
entitlementAction: 'revoke_on_success',
reason: 'partial refund should keep entitlement active',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: partialRefund.item.id,
action: 'mark_succeeded',
providerRefundNo: 'provider-refund-integration-partial',
},
});
const partialRefundStatus = await request('/api/commerce/orders/status', {
query: { orderNo: partialRefundOrder.item.orderNo },
});
assert.equal(partialRefundStatus.item?.status, 'partially_refunded', 'partial refund should mark order partially_refunded');
const entitlementAfterPartialRefund = await request('/api/commerce/entitlements');
assert.ok(
entitlementAfterPartialRefund.items?.some(
item => item.sourceType === 'order' && item.sourceId === partialRefundOrder.item.id && item.status === 'active',
),
'partial refund should not revoke the order entitlement',
);
const crossTenantOrderDetail = await request('/api/commerce/orders/detail', {
tenantId: PARTNER_TENANT_ID,
query: { orderNo: freeOrder.item.orderNo },