forked from wangziqi/gongxue-base
feat: add commerce refund workflow
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user