feat: improve commerce checkout flow

This commit is contained in:
Codex
2026-06-29 02:44:28 +08:00
parent 9122bb0829
commit c767b87c6f
15 changed files with 1214 additions and 72 deletions

View File

@@ -37,6 +37,8 @@ const ids = {
question: '00000000-0000-0000-0000-000000000401',
questionTwo: '00000000-0000-0000-0000-000000000403',
questionThree: '00000000-0000-0000-0000-000000000405',
plan: '00000000-0000-0000-0000-000000000201',
couponOnlyPlan: '00000000-0000-0000-0000-000000000202',
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812',
video: '00000000-0000-0000-0000-000000000821',
@@ -1142,12 +1144,49 @@ async function testCommerce() {
const orders = await request('/api/commerce/orders');
assert.ok(orders.items?.some(item => item.orderNo === 'SMOKE-ORDER-20260621'), 'orders should include smoke order');
const activationPrecheck = await request('/api/commerce/activation-codes/check', {
method: 'POST',
body: { code: ' smoke20260621 ', regionId: ids.region },
});
assert.equal(activationPrecheck.valid, true, 'activation code precheck should validate unused code');
assert.equal(activationPrecheck.days, 30, 'activation code precheck should expose granted days');
assert.equal(activationPrecheck.regionId, ids.region, 'activation code precheck should expose bound region');
const selfCodePrecheck = await request('/api/commerce/activation-codes/check', {
method: 'POST',
body: { code: 'SMOKESELF20260621', regionId: ids.region },
});
assert.equal(selfCodePrecheck.valid, false, 'self-issued activation code should be invalid for the issuing user');
assert.equal(
selfCodePrecheck.reasonCode,
'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN',
'self-issued activation precheck should expose a stable reason code',
);
const selfCodeRedeem = await request('/api/commerce/activation-codes/redeem', {
method: 'POST',
body: { code: 'SMOKESELF20260621', regionId: ids.region },
expectStatus: 409,
});
assert.equal(
selfCodeRedeem.code,
'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN',
'redeeming a self-issued activation code must be blocked',
);
const redeemed = await request('/api/commerce/activation-codes/redeem', {
method: 'POST',
body: { code: 'SMOKE20260621', regionId: ids.region },
});
assert.ok(redeemed.item?.entitlement?.id, 'activation code should grant an entitlement');
const usedCodePrecheck = await request('/api/commerce/activation-codes/check', {
method: 'POST',
body: { code: 'SMOKE20260621', regionId: ids.region },
});
assert.equal(usedCodePrecheck.valid, false, 'activation code precheck should reject used code');
assert.equal(usedCodePrecheck.reasonCode, 'ACTIVATION_CODE_USED', 'used activation code should return stable reason code');
const entitlements = await request('/api/commerce/entitlements');
assert.ok(Array.isArray(entitlements.items), 'entitlements should return a list');
assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary');
@@ -1161,6 +1200,151 @@ async function testCommerce() {
assert.equal(svipPlayback.access?.mode, 'svip', 'SVIP video playback should use svip mode');
assert.equal(svipPlayback.access?.consumedQuota, 0, 'SVIP video playback should not consume quota');
const couponOnlyDenied = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.couponOnlyPlan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
expectStatus: 403,
});
assert.equal(couponOnlyDenied.code, 'PLAN_REQUIRES_COUPON', 'coupon-only plan should require a coupon');
const claimedCoupon = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region },
});
assert.equal(claimedCoupon.valid, true, 'coupon claim should be valid');
assert.equal(claimedCoupon.idempotent, false, 'first coupon claim should create a redemption');
assert.equal(claimedCoupon.coupon?.discountCents, 500, 'coupon claim should calculate 50% discount');
const claimedCouponAgain = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region },
});
assert.equal(claimedCouponAgain.idempotent, true, 'second coupon claim should be idempotent');
assert.equal(
claimedCouponAgain.redemption?.id,
claimedCoupon.redemption?.id,
'idempotent coupon claim should return the original redemption',
);
const discountedOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.couponOnlyPlan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
couponRedemptionId: claimedCoupon.redemption.id,
},
});
assert.equal(discountedOrder.item?.originalAmountCents, 1000, 'discounted order should use server-side plan price');
assert.equal(discountedOrder.item?.discountCents, 500, 'discounted order should apply claimed coupon');
assert.equal(discountedOrder.item?.amountCents, 500, 'discounted order payable amount should be 500 cents');
assert.equal(discountedOrder.item?.status, 'pending', 'partially discounted order should remain pending');
const discountedDetail = await request('/api/commerce/orders/detail', {
query: { orderNo: discountedOrder.item.orderNo },
});
assert.equal(discountedDetail.item?.pricing?.discountCents, 500, 'order detail should expose pricing snapshot');
assert.ok(
discountedDetail.item?.items?.some(item => item.itemType === 'coupon_discount' && item.totalAmountCents === -500),
'order detail should include a negative coupon order item',
);
assert.ok(
discountedDetail.item?.couponRedemptions?.some(item => item.status === 'used' && item.discountAppliedCents === 500),
'order detail should include used coupon redemption',
);
const discountedStatus = await request('/api/commerce/orders/status', {
query: { orderNo: discountedOrder.item.orderNo },
});
assert.equal(discountedStatus.item?.status, 'pending', 'order status endpoint should return pending status');
assert.equal(discountedStatus.item?.payment?.status, 'pending', 'order status endpoint should include latest payment');
const studentManualConfirmDenied = await request('/api/commerce/payments/manual-confirm', {
method: 'POST',
body: { orderNo: discountedOrder.item.orderNo, amountCents: discountedOrder.item.amountCents },
expectStatus: 403,
});
assert.equal(
studentManualConfirmDenied.code,
'TENANT_ADMIN_REQUIRED',
'student must not manually confirm commerce payments',
);
const manualConfirmed = await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
amountCents: discountedOrder.item.amountCents,
providerTradeNo: `manual-${discountedOrder.item.orderNo}`,
},
});
assert.equal(manualConfirmed.item?.status, 'paid', 'tenant admin should manually confirm discounted order');
assert.equal(manualConfirmed.item?.confirmedBy, TENANT_ADMIN_USER_ID, 'manual confirmation should record operator');
assert.ok(manualConfirmed.item?.entitlement?.id, 'manual confirmation should grant entitlement');
const manualConfirmedAgain = await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
amountCents: discountedOrder.item.amountCents,
providerTradeNo: `manual-${discountedOrder.item.orderNo}`,
},
});
assert.equal(manualConfirmedAgain.item?.idempotent, true, 'duplicate manual confirmation should be idempotent');
const usedCouponClaim = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region },
expectStatus: 409,
});
assert.equal(usedCouponClaim.code, 'COUPON_ALREADY_USED', 'used coupon should not be claimable again by same user');
const freeOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.couponOnlyPlan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
couponCode: 'SMOKEFREE',
},
});
assert.equal(freeOrder.item?.originalAmountCents, 1000, 'free order should still keep original server price');
assert.equal(freeOrder.item?.discountCents, 1000, 'free coupon should discount the full amount');
assert.equal(freeOrder.item?.amountCents, 0, 'free order payable amount should be zero');
assert.equal(freeOrder.item?.status, 'paid', 'zero-amount order should be marked paid immediately');
assert.ok(freeOrder.item?.tradeNo?.startsWith('zero-'), 'zero-amount order should get an internal trade number');
assert.ok(freeOrder.item?.entitlement?.id, 'zero-amount order should grant entitlement immediately');
const freeStatus = await request('/api/commerce/orders/status', {
query: { orderNo: freeOrder.item.orderNo },
});
assert.equal(freeStatus.item?.status, 'paid', 'zero-amount order status should be paid');
assert.equal(freeStatus.item?.payment?.status, 'paid', 'zero-amount order payment row should be paid');
assert.equal(freeStatus.item?.payment?.amountCents, 0, 'zero-amount order payment row should have zero amount');
const freePaymentDenied = await request('/api/commerce/payments/create', {
method: 'POST',
body: { orderNo: freeOrder.item.orderNo, provider: 'manual' },
expectStatus: 409,
});
assert.equal(freePaymentDenied.code, 'ORDER_ALREADY_PAID', 'paid zero-amount order should not create another payment');
const crossTenantOrderDetail = await request('/api/commerce/orders/detail', {
tenantId: PARTNER_TENANT_ID,
query: { orderNo: freeOrder.item.orderNo },
expectStatus: 404,
});
assert.equal(crossTenantOrderDetail.code, 'ORDER_NOT_FOUND', 'order detail must be tenant isolated');
const fakeWechatPay = await startFakeWechatPayServer();
const wechatAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
@@ -1192,7 +1376,7 @@ async function testCommerce() {
const wechatOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: '00000000-0000-0000-0000-000000000201',
planId: ids.plan,
payProvider: 'wechat_pay',
payMethod: 'jsapi',
regionId: ids.region,
@@ -1286,7 +1470,7 @@ async function testCommerce() {
const alipayOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: '00000000-0000-0000-0000-000000000201',
planId: ids.plan,
payProvider: 'alipay',
payMethod: 'wap',
regionId: ids.region,