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,

View File

@@ -45,9 +45,13 @@ const ids = {
questionThree: '00000000-0000-0000-0000-000000000405',
questionThreeVersion: '00000000-0000-0000-0000-000000000406',
plan: '00000000-0000-0000-0000-000000000201',
couponOnlyPlan: '00000000-0000-0000-0000-000000000202',
checkoutCoupon: '00000000-0000-0000-0000-000000000203',
freeCheckoutCoupon: '00000000-0000-0000-0000-000000000204',
order: '00000000-0000-0000-0000-000000000701',
payment: '00000000-0000-0000-0000-000000000702',
activationCode: '00000000-0000-0000-0000-000000000801',
selfActivationCode: '00000000-0000-0000-0000-000000000802',
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812',
vocabularyWordTwo: '00000000-0000-0000-0000-000000000813',
@@ -163,6 +167,93 @@ async function main() {
[tenantId, ids.leaderboardSessionUser, ids.leaderboardSessionSecond],
);
const checkoutCouponCodes = ['SMOKE50', 'SMOKEFREE'];
await client.query(
`
delete from public.coupon_redemptions
where tenant_id = $1
and (
coupon_id = any($2::uuid[])
or coupon_code = any($3::text[])
or order_id in (
select id from public.orders
where tenant_id = $1
and (raw_payload->'pricing'->>'couponCode') = any($3::text[])
)
)
`,
[tenantId, [ids.checkoutCoupon, ids.freeCheckoutCoupon], checkoutCouponCodes],
);
await client.query(
`
delete from public.order_items
where tenant_id = $1
and order_id in (
select id from public.orders
where tenant_id = $1
and (raw_payload->'pricing'->>'couponCode') = any($2::text[])
)
`,
[tenantId, checkoutCouponCodes],
);
await client.query(
`
delete from public.entitlements
where tenant_id = $1
and user_id = $2::uuid
and source_type in ('order', 'activation_code')
and (
legacy_source_id in ('SMOKE20260621', 'SMOKESELF20260621')
or source_id in (
select id
from public.orders
where tenant_id = $1
and (
raw_payload->'pricing'->>'couponCode' = any($3::text[])
or order_no like 'SVIP%'
or order_no like 'XP%'
)
)
or metadata->>'orderNo' like 'SVIP%'
or metadata->>'orderNo' like 'XP%'
)
`,
[tenantId, ids.user, checkoutCouponCodes],
);
await client.query(
`
delete from public.payments
where tenant_id = $1
and order_id in (
select id from public.orders
where tenant_id = $1
and (
(raw_payload->'pricing'->>'couponCode') = any($2::text[])
or order_no like 'SVIP%'
or order_no like 'XP%'
)
)
`,
[tenantId, checkoutCouponCodes],
);
await client.query(
`
delete from public.orders
where tenant_id = $1
and (
(raw_payload->'pricing'->>'couponCode') = any($2::text[])
or order_no like 'SVIP%'
or order_no like 'XP%'
)
`,
[tenantId, checkoutCouponCodes],
);
await client.query(
`
delete from public.crm_webhook_queue
@@ -1193,6 +1284,62 @@ async function main() {
[ids.plan, tenantId, ids.region],
);
await client.query(
`
insert into public.svip_plans (
id, tenant_id, region_id, legacy_id, name, price_cents, original_price_cents,
days, description, per_day_label, badge, recommended, coupon_only, sort_order, is_active
)
values (
$1, $2, $3, 'smoke-coupon-plan', '烟测优惠券专享月卡', 1000, 2000,
30, '本地 smoke 优惠券专享套餐', '0.33/天', 'COUPON', false, true, 2, true
)
on conflict (id)
do update set name = excluded.name,
price_cents = excluded.price_cents,
original_price_cents = excluded.original_price_cents,
days = excluded.days,
region_id = excluded.region_id,
coupon_only = excluded.coupon_only,
is_active = true,
updated_at = now()
`,
[ids.couponOnlyPlan, tenantId, ids.region],
);
await client.query(
`
insert into public.coupons (
id, tenant_id, legacy_id, code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, used_count, source, remark
)
values
(
$1, $2, 'smoke-coupon', 'SMOKE50', $3, 'percent', 50,
now() - interval '1 day', now() + interval '30 days', 100, 0,
'smoke', '本地 smoke 优惠券'
),
(
$4, $2, 'smoke-free-coupon', 'SMOKEFREE', $3, 'percent', 100,
now() - interval '1 day', now() + interval '30 days', 100, 0,
'smoke', '本地 smoke 全额优惠券'
)
on conflict (id)
do update set code = excluded.code,
plan_id = excluded.plan_id,
discount_type = excluded.discount_type,
discount_value = excluded.discount_value,
valid_from = excluded.valid_from,
valid_to = excluded.valid_to,
max_uses = excluded.max_uses,
used_count = 0,
source = excluded.source,
remark = excluded.remark,
updated_at = now()
`,
[ids.checkoutCoupon, tenantId, ids.couponOnlyPlan, ids.freeCheckoutCoupon],
);
await client.query(
`
insert into public.orders (
@@ -1238,6 +1385,24 @@ async function main() {
[ids.activationCode, tenantId, ids.region],
);
await client.query(
`
insert into public.activation_codes (
id, tenant_id, legacy_id, code, days, is_used, agent_user_id,
sale_type, unit_price_cents, used_region_id, remark
)
values ($1, $2, 'smoke-self-code', 'SMOKESELF20260621', 30, false, $3, 'sales', 0, $4, '不可自用的 smoke 激活码')
on conflict (id)
do update set is_used = false,
used_by = null,
used_at = null,
agent_user_id = excluded.agent_user_id,
used_region_id = excluded.used_region_id,
updated_at = now()
`,
[ids.selfActivationCode, tenantId, ids.user, ids.region],
);
await client.query(
`
insert into public.vocabulary_units (