forked from wangziqi/gongxue-base
feat: improve commerce checkout flow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user