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

@@ -1,10 +1,14 @@
import type { RouteDefinition } from '../../core/router.js';
import {
checkActivationCodeRoute,
claimCouponRoute,
confirmManualPaymentRoute,
createPaymentRoute,
createOrderRoute,
entitlementCheckRoute,
entitlementsRoute,
orderDetailRoute,
orderStatusRoute,
ordersRoute,
paymentNotifyRoute,
redeemActivationCodeRoute,
@@ -13,6 +17,8 @@ import {
export const commerceRoutes: RouteDefinition[] = [
['POST', '/api/commerce/orders', createOrderRoute],
['GET', '/api/commerce/orders', ordersRoute],
['GET', '/api/commerce/orders/detail', orderDetailRoute],
['GET', '/api/commerce/orders/status', orderStatusRoute],
['GET', '/api/commerce/entitlements', entitlementsRoute],
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
['POST', '/api/commerce/payments/create', createPaymentRoute],
@@ -20,5 +26,7 @@ export const commerceRoutes: RouteDefinition[] = [
['POST', '/api/commerce/payments/notify/wechat_pay', paymentNotifyRoute],
['POST', '/api/commerce/payments/notify/wechat-pay', paymentNotifyRoute],
['POST', '/api/commerce/payments/notify/alipay', paymentNotifyRoute],
['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute],
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
['POST', '/api/commerce/coupons/claim', claimCouponRoute],
];

View File

@@ -1,3 +1,4 @@
import type pg from 'pg';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
@@ -5,6 +6,7 @@ import {
optionalString,
readJsonBody,
requiredString,
stringParam,
tenantIdFrom,
userIdFrom,
} from '../../core/request.js';
@@ -17,13 +19,16 @@ import {
type PaymentProviderName,
} from './providers.js';
import { loadTenantPaymentProviderConfig } from '../../core/tenant-provider-config.js';
import { requireTenantAdmin, requireTenantPermission } from '../tenant-admin/auth.js';
interface PlanRow {
id: string;
name: string;
price_cents: number;
original_price_cents: number | null;
days: number;
region_id: string | null;
coupon_only: boolean;
}
interface OrderRow {
@@ -55,6 +60,56 @@ interface PaymentOrderRow {
region_id: string | null;
}
interface OrderDetailRow {
id: string;
orderNo: string;
status: string;
productType: string | null;
productName: string | null;
amountCents: number;
payMethod: string | null;
payProvider: string | null;
tradeNo: string | null;
planId: string | null;
days: number | null;
userId: string | null;
regionId: string | null;
paidAt: string | null;
rawPayload: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
interface CouponRow {
id: string;
code: string;
plan_id: string | null;
discount_type: 'percent' | 'fixed' | null;
discount_value: string | number | null;
valid_from: string | null;
valid_to: string | null;
max_uses: number | null;
used_count: number;
source: string | null;
remark: string | null;
}
interface CouponRedemptionRow {
id: string;
coupon_id: string | null;
coupon_code: string | null;
user_id: string | null;
plan_id: string | null;
order_id: string | null;
status: string;
discount_applied_cents: number | null;
region_id: string | null;
source: string | null;
remark: string | null;
claimed_at: string | null;
used_at: string | null;
}
interface EntitlementRow {
id: string;
entitlementType: string;
@@ -69,6 +124,229 @@ interface EntitlementRow {
createdAt: string;
}
function normalizeCode(value: string) {
return value.trim().replace(/\s+/g, '').toUpperCase();
}
function formatPrice(amountCents: number) {
return Number((Math.max(0, amountCents) / 100).toFixed(2));
}
function safeDiscountValue(value: string | number | null) {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
function calculateCouponDiscount(coupon: CouponRow, baseAmountCents: number) {
const amount = Math.max(0, Math.trunc(baseAmountCents));
if (!coupon.discount_type || coupon.discount_value === null || coupon.discount_value === undefined) return 0;
if (coupon.discount_type === 'fixed') {
return Math.min(amount, Math.max(0, Math.round(safeDiscountValue(coupon.discount_value))));
}
const percent = Math.max(0, Math.min(100, safeDiscountValue(coupon.discount_value)));
return Math.min(amount, Math.floor((amount * percent) / 100));
}
function calculatePlanDays(planDays: number, quantity: number) {
const days = Math.trunc(planDays || 0);
if (days < 0) return -1;
return Math.max(0, days * quantity);
}
function couponValidity(coupon: CouponRow, now = new Date()) {
if (coupon.valid_from && now < new Date(coupon.valid_from)) {
return { valid: false, code: 'COUPON_NOT_STARTED', message: 'Coupon is not active yet' };
}
if (coupon.valid_to && now > new Date(coupon.valid_to)) {
return { valid: false, code: 'COUPON_EXPIRED', message: 'Coupon has expired' };
}
if (coupon.max_uses !== null && coupon.max_uses !== undefined && coupon.max_uses > 0 && coupon.used_count >= coupon.max_uses) {
return { valid: false, code: 'COUPON_QUOTA_EXHAUSTED', message: 'Coupon quota has been exhausted' };
}
return { valid: true, code: '', message: '' };
}
function couponPayload(coupon: CouponRow, plan: PlanRow, redemption: CouponRedemptionRow | null, discountCents: number) {
return {
code: coupon.code,
discountType: coupon.discount_type,
discountValue: coupon.discount_value === null || coupon.discount_value === undefined ? null : Number(coupon.discount_value),
discountCents,
source: coupon.source,
remark: coupon.remark,
redemptionId: redemption?.id || null,
redemptionStatus: redemption?.status || null,
plan: {
id: plan.id,
name: plan.name,
priceCents: plan.price_cents,
price: formatPrice(plan.price_cents),
originalPriceCents: plan.original_price_cents,
originalPrice: plan.original_price_cents === null ? null : formatPrice(plan.original_price_cents),
days: plan.days,
regionId: plan.region_id,
couponOnly: plan.coupon_only,
},
};
}
async function loadPlan(client: pg.PoolClient | null, tenantId: string, planId: string, lock = false) {
const sql = `
select id, name, price_cents, original_price_cents, days, region_id, coupon_only
from public.svip_plans
where tenant_id = $1 and id = $2 and is_active = true
limit 1
${lock ? 'for update' : ''}
`;
if (client) {
const result = await client.query<PlanRow>(sql, [tenantId, planId]);
return result.rows[0] || null;
}
return queryOne<PlanRow>(sql, [tenantId, planId]);
}
async function findCouponByCode(client: pg.PoolClient, tenantId: string, code: string, lock = false) {
const result = await client.query<CouponRow>(
`
select id, code::text as code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, used_count, source, remark
from public.coupons
where tenant_id = $1 and lower(code::text) = lower($2)
limit 1
${lock ? 'for update' : ''}
`,
[tenantId, normalizeCode(code)],
);
return result.rows[0] || null;
}
async function findCouponById(client: pg.PoolClient, tenantId: string, couponId: string, lock = false) {
const result = await client.query<CouponRow>(
`
select id, code::text as code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, used_count, source, remark
from public.coupons
where tenant_id = $1 and id = $2
limit 1
${lock ? 'for update' : ''}
`,
[tenantId, couponId],
);
return result.rows[0] || null;
}
async function findCouponRedemption(client: pg.PoolClient, tenantId: string, userId: string, couponId: string) {
const result = await client.query<CouponRedemptionRow>(
`
select id, coupon_id, coupon_code, user_id, plan_id, order_id, status,
discount_applied_cents, region_id, source, remark, claimed_at, used_at
from public.coupon_redemptions
where tenant_id = $1 and user_id = $2 and coupon_id = $3
order by created_at desc
limit 1
for update
`,
[tenantId, userId, couponId],
);
return result.rows[0] || null;
}
async function claimCoupon(
client: pg.PoolClient,
input: {
tenantId: string;
userId: string;
code: string;
planId?: string | null;
regionId?: string | null;
baseAmountCents?: number;
},
) {
const coupon = await findCouponByCode(client, input.tenantId, input.code, true);
if (!coupon) throw new HttpError(404, 'Coupon not found', 'COUPON_NOT_FOUND');
const validity = couponValidity(coupon);
if (!validity.valid) throw new HttpError(409, validity.message, validity.code);
const planId = input.planId || coupon.plan_id;
if (!planId) throw new HttpError(409, 'Coupon is not bound to a plan', 'COUPON_PLAN_MISSING');
if (coupon.plan_id && input.planId && coupon.plan_id !== input.planId) {
throw new HttpError(409, 'Coupon does not apply to this plan', 'COUPON_PLAN_MISMATCH');
}
const plan = await loadPlan(client, input.tenantId, planId);
if (!plan) throw new HttpError(404, 'Coupon plan not found', 'COUPON_PLAN_NOT_FOUND');
const finalRegionId = input.regionId || plan.region_id;
if (plan.region_id && finalRegionId && plan.region_id !== finalRegionId) {
throw new HttpError(409, 'Coupon does not apply to this region', 'COUPON_REGION_MISMATCH');
}
const existing = await findCouponRedemption(client, input.tenantId, input.userId, coupon.id);
if (existing?.status === 'used') throw new HttpError(409, 'Coupon already used', 'COUPON_ALREADY_USED');
if (existing && !['claimed', 'pending'].includes(existing.status)) {
throw new HttpError(409, `Coupon redemption is ${existing.status}`, 'COUPON_REDEMPTION_UNAVAILABLE');
}
const discountCents = calculateCouponDiscount(coupon, input.baseAmountCents ?? plan.price_cents);
if (existing) {
return { coupon, plan, redemption: existing, discountCents, idempotent: true };
}
const inserted = await client.query<CouponRedemptionRow>(
`
insert into public.coupon_redemptions (
tenant_id, coupon_id, coupon_code, user_id, plan_id, status,
discount_applied_cents, region_id, source, remark, claimed_at
)
values ($1, $2, $3, $4, $5, 'claimed', $6, $7::uuid, $8, $9, now())
returning id, coupon_id, coupon_code, user_id, plan_id, order_id, status,
discount_applied_cents, region_id, source, remark, claimed_at, used_at
`,
[
input.tenantId,
coupon.id,
coupon.code,
input.userId,
plan.id,
discountCents,
finalRegionId || null,
coupon.source,
coupon.remark,
],
);
return { coupon, plan, redemption: inserted.rows[0], discountCents, idempotent: false };
}
function orderDetailPayload(order: OrderDetailRow, payments: unknown[], items: unknown[], redemptions: unknown[]) {
const snapshot =
order.rawPayload && typeof order.rawPayload === 'object' && 'pricing' in order.rawPayload
? (order.rawPayload as Record<string, unknown>).pricing
: null;
return {
id: order.id,
orderNo: order.orderNo,
status: order.status,
productType: order.productType,
productName: order.productName,
amountCents: order.amountCents,
amount: formatPrice(order.amountCents),
payMethod: order.payMethod,
payProvider: order.payProvider,
tradeNo: order.tradeNo,
planId: order.planId,
days: order.days,
regionId: order.regionId,
paidAt: order.paidAt,
createdAt: order.createdAt,
updatedAt: order.updatedAt,
pricing: snapshot,
payments,
items,
couponRedemptions: redemptions,
};
}
export async function createOrderRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
@@ -78,27 +356,98 @@ export async function createOrderRoute(ctx: RequestContext) {
const payMethod = optionalString(body, 'payMethod') || 'manual';
const payProvider = optionalString(body, 'payProvider') || 'manual';
const regionId = optionalString(body, 'regionId') || null;
const couponCode = optionalString(body, 'couponCode');
const couponRedemptionId = optionalString(body, 'couponRedemptionId');
const plan = await queryOne<PlanRow>(
`
select id, name, price_cents, days, region_id
from public.svip_plans
where tenant_id = $1 and id = $2 and is_active = true
limit 1
`,
[tenantId, planId],
);
const plan = await loadPlan(null, tenantId, planId);
if (!plan) {
throw new HttpError(404, 'SVIP plan not found', 'PLAN_NOT_FOUND');
}
if (plan.coupon_only && !couponCode && !couponRedemptionId) {
throw new HttpError(403, 'This plan requires a coupon', 'PLAN_REQUIRES_COUPON');
}
const finalRegionId = regionId || plan.region_id;
const amountCents = Math.max(0, plan.price_cents * quantity);
const days = Math.max(0, plan.days * quantity);
const originalAmountCents = Math.max(0, plan.price_cents * quantity);
const days = calculatePlanDays(plan.days, quantity);
const orderNo = createOrderNo(payProvider === 'xpay' ? 'XP' : 'SVIP');
const item = await transaction(async client => {
let couponResult:
| {
coupon: CouponRow;
plan: PlanRow;
redemption: CouponRedemptionRow;
discountCents: number;
idempotent: boolean;
}
| null = null;
if (couponRedemptionId) {
const redemptionResult = await client.query<CouponRedemptionRow>(
`
select id, coupon_id, coupon_code, user_id, plan_id, order_id, status,
discount_applied_cents, region_id, source, remark, claimed_at, used_at
from public.coupon_redemptions
where tenant_id = $1 and id = $2 and user_id = $3
limit 1
for update
`,
[tenantId, couponRedemptionId, userId],
);
const redemption = redemptionResult.rows[0];
if (!redemption) throw new HttpError(404, 'Coupon redemption not found', 'COUPON_REDEMPTION_NOT_FOUND');
if (redemption.status === 'used') throw new HttpError(409, 'Coupon already used', 'COUPON_ALREADY_USED');
if (redemption.order_id) throw new HttpError(409, 'Coupon redemption is already bound to an order', 'COUPON_REDEMPTION_BOUND');
if (redemption.plan_id && redemption.plan_id !== planId) {
throw new HttpError(409, 'Coupon does not apply to this plan', 'COUPON_PLAN_MISMATCH');
}
const coupon = redemption.coupon_id
? await findCouponById(client, tenantId, redemption.coupon_id, true)
: await findCouponByCode(client, tenantId, redemption.coupon_code || '', true);
if (!coupon) throw new HttpError(404, 'Coupon not found', 'COUPON_NOT_FOUND');
const validity = couponValidity(coupon);
if (!validity.valid) throw new HttpError(409, validity.message, validity.code);
const discountCents = calculateCouponDiscount(coupon, originalAmountCents);
couponResult = { coupon, plan, redemption, discountCents, idempotent: true };
} else if (couponCode) {
const claimed = await claimCoupon(client, {
tenantId,
userId,
code: couponCode,
planId,
regionId: finalRegionId,
baseAmountCents: originalAmountCents,
});
couponResult = {
coupon: claimed.coupon,
plan: claimed.plan,
redemption: claimed.redemption,
discountCents: claimed.discountCents,
idempotent: claimed.idempotent,
};
}
if (couponResult?.plan.id !== undefined && couponResult.plan.id !== plan.id) {
throw new HttpError(409, 'Coupon plan does not match the order plan', 'COUPON_PLAN_MISMATCH');
}
if (couponResult?.redemption.order_id) {
throw new HttpError(409, 'Coupon redemption is already bound to an order', 'COUPON_REDEMPTION_BOUND');
}
const discountCents = couponResult ? Math.min(originalAmountCents, Math.max(0, couponResult.discountCents)) : 0;
const amountCents = Math.max(0, originalAmountCents - discountCents);
const pricingSnapshot = {
quantity,
unitAmountCents: plan.price_cents,
originalAmountCents,
discountCents,
amountCents,
couponCode: couponResult?.coupon.code || null,
couponRedemptionId: couponResult?.redemption.id || null,
};
const orderResult = await client.query(
`
insert into public.orders (
@@ -123,9 +472,10 @@ export async function createOrderRoute(ctx: RequestContext) {
planId,
days,
finalRegionId,
JSON.stringify({ quantity, request: body }),
JSON.stringify({ quantity, pricing: pricingSnapshot, request: body }),
],
);
const order = orderResult.rows[0];
await client.query(
`
@@ -134,25 +484,139 @@ export async function createOrderRoute(ctx: RequestContext) {
`,
[
tenantId,
orderResult.rows[0].id,
order.id,
planId,
plan.name,
quantity,
plan.price_cents,
amountCents,
JSON.stringify({ days: plan.days, totalDays: days }),
originalAmountCents,
JSON.stringify({ days: plan.days, totalDays: days, discountCents, payableAmountCents: amountCents }),
],
);
if (couponResult) {
if (discountCents > 0) {
await client.query(
`
insert into public.order_items (
tenant_id, order_id, item_type, item_id, name, quantity,
unit_amount_cents, total_amount_cents, metadata
)
values ($1, $2, 'coupon_discount', $3, $4, 1, -($5::integer), -($5::integer), $6::jsonb)
`,
[
tenantId,
order.id,
couponResult.coupon.id,
`优惠券 ${couponResult.coupon.code}`,
discountCents,
JSON.stringify({
couponCode: couponResult.coupon.code,
redemptionId: couponResult.redemption.id,
discountType: couponResult.coupon.discount_type,
discountValue: couponResult.coupon.discount_value,
}),
],
);
}
const redemptionUpdate = await client.query(
`
update public.coupon_redemptions
set status = 'used',
order_id = $3,
plan_id = $4,
discount_applied_cents = $5,
region_id = coalesce($6::uuid, region_id),
used_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2 and status in ('claimed', 'pending')
`,
[tenantId, couponResult.redemption.id, order.id, plan.id, discountCents, finalRegionId],
);
if (redemptionUpdate.rowCount !== 1) {
throw new HttpError(409, 'Coupon redemption is no longer available', 'COUPON_REDEMPTION_UNAVAILABLE');
}
await client.query(
`
update public.coupons
set used_count = used_count + 1, updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, couponResult.coupon.id],
);
}
const zeroAmountTradeNo = amountCents === 0 ? `zero-${orderNo}` : null;
if (zeroAmountTradeNo) {
await client.query(
`
update public.orders
set status = 'paid',
trade_no = $3,
paid_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, order.id, zeroAmountTradeNo],
);
order.status = 'paid';
}
await client.query(
`
insert into public.payments (tenant_id, order_id, provider, method, status, amount_cents, raw_payload)
values ($1, $2, $3, $4, 'pending', $5, $6::jsonb)
insert into public.payments (
tenant_id, order_id, provider, method, status, amount_cents,
provider_trade_no, paid_at, raw_payload
)
values (
$1, $2, $3, $4, $5, $6,
$7, case when $7::text is null then null else now() end, $8::jsonb
)
`,
[tenantId, orderResult.rows[0].id, payProvider, payMethod, amountCents, JSON.stringify({ request: body })],
[
tenantId,
order.id,
payProvider,
payMethod,
zeroAmountTradeNo ? 'paid' : 'pending',
amountCents,
zeroAmountTradeNo,
JSON.stringify({ pricing: pricingSnapshot, request: body }),
],
);
return orderResult.rows[0];
const entitlement =
amountCents === 0
? await grantSvipEntitlement(client, {
tenantId,
userId,
days,
regionId: finalRegionId,
sourceType: 'order',
sourceId: order.id,
metadata: {
orderNo,
paymentProvider: payProvider,
zeroAmountCheckout: true,
couponCode: couponResult?.coupon.code || null,
},
})
: null;
return {
...order,
status: amountCents === 0 ? 'paid' : order.status,
tradeNo: zeroAmountTradeNo,
originalAmountCents,
discountCents,
amount: formatPrice(amountCents),
entitlement,
coupon: couponResult
? couponPayload(couponResult.coupon, couponResult.plan, couponResult.redemption, discountCents)
: null,
};
});
return { item };
@@ -182,6 +646,128 @@ export async function ordersRoute(ctx: RequestContext) {
return { items };
}
async function loadOrderDetail(tenantId: string, userId: string, orderNo: string) {
const order = await queryOne<OrderDetailRow>(
`
select id, order_no as "orderNo", status, product_type as "productType",
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days, user_id as "userId",
region_id as "regionId", paid_at as "paidAt", raw_payload as "rawPayload",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2 and order_no = $3
limit 1
`,
[tenantId, userId, orderNo],
);
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
const [payments, items, redemptions] = await Promise.all([
query(
`
select id, provider, method, status, amount_cents as "amountCents",
provider_trade_no as "providerTradeNo", paid_at as "paidAt",
created_at as "createdAt", updated_at as "updatedAt"
from public.payments
where tenant_id = $1 and order_id = $2
order by created_at desc
`,
[tenantId, order.id],
),
query(
`
select id, item_type as "itemType", item_id as "itemId", name, quantity,
unit_amount_cents as "unitAmountCents", total_amount_cents as "totalAmountCents",
metadata
from public.order_items
where tenant_id = $1 and order_id = $2
order by case item_type when 'svip_plan' then 0 when 'coupon_discount' then 1 else 2 end, id
`,
[tenantId, order.id],
),
query(
`
select id, coupon_code as "couponCode", status, discount_applied_cents as "discountAppliedCents",
region_id as "regionId", claimed_at as "claimedAt", used_at as "usedAt"
from public.coupon_redemptions
where tenant_id = $1 and order_id = $2 and user_id = $3
order by created_at desc
`,
[tenantId, order.id, userId],
),
]);
return orderDetailPayload(order, payments, items, redemptions);
}
export async function orderDetailRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const orderNo = stringParam(ctx, 'orderNo');
if (!orderNo) throw new HttpError(400, 'orderNo is required', 'ORDER_NO_REQUIRED');
return { item: await loadOrderDetail(tenantId, userId, orderNo) };
}
export async function orderStatusRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const orderNo = stringParam(ctx, 'orderNo');
if (!orderNo) throw new HttpError(400, 'orderNo is required', 'ORDER_NO_REQUIRED');
const order = await queryOne<OrderDetailRow>(
`
select id, order_no as "orderNo", status, product_type as "productType",
product_name as "productName", amount_cents as "amountCents",
pay_method as "payMethod", pay_provider as "payProvider",
trade_no as "tradeNo", plan_id as "planId", days, user_id as "userId",
region_id as "regionId", paid_at as "paidAt", raw_payload as "rawPayload",
created_at as "createdAt", updated_at as "updatedAt"
from public.orders
where tenant_id = $1 and user_id = $2 and order_no = $3
limit 1
`,
[tenantId, userId, orderNo],
);
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
const latestPayment = await queryOne<{
provider: string;
method: string | null;
status: string;
amountCents: number;
providerTradeNo: string | null;
paidAt: string | null;
updatedAt: string;
}>(
`
select provider, method, status, amount_cents as "amountCents",
provider_trade_no as "providerTradeNo", paid_at as "paidAt",
updated_at as "updatedAt"
from public.payments
where tenant_id = $1 and order_id = $2
order by updated_at desc
limit 1
`,
[tenantId, order.id],
);
return {
item: {
orderNo: order.orderNo,
status: order.status,
amountCents: order.amountCents,
amount: formatPrice(order.amountCents),
payProvider: order.payProvider,
payMethod: order.payMethod,
tradeNo: order.tradeNo,
paidAt: order.paidAt,
updatedAt: order.updatedAt,
payment: latestPayment,
},
};
}
export async function entitlementsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
@@ -281,7 +867,9 @@ export async function entitlementCheckRoute(ctx: RequestContext) {
export async function confirmManualPaymentRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:payment:write');
const tenantId = auth.tenantId;
const orderNo = requiredString(body, 'orderNo');
const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`;
const amountCents = optionalInteger(body, 'amountCents', -1);
@@ -342,7 +930,7 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) {
metadata: { orderNo },
});
return { orderNo, status: 'paid', entitlement };
return { orderNo, status: 'paid', confirmedBy: auth.userId, entitlement };
});
return { item };
@@ -708,11 +1296,95 @@ export async function paymentNotifyRoute(ctx: RequestContext) {
return { item };
}
export async function checkActivationCodeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const code = normalizeCode(requiredString(body, 'code'));
const regionId = optionalString(body, 'regionId') || null;
const activationCode = await queryOne<{
id: string;
code: string;
days: number;
isUsed: boolean;
usedRegionId: string | null;
agentUserId: string | null;
saleType: string | null;
unitPriceCents: number | null;
soldTo: string | null;
remark: string | null;
regionName: string | null;
}>(
`
select ac.id, ac.code::text as code, ac.days, ac.is_used as "isUsed",
ac.used_region_id as "usedRegionId", ac.agent_user_id as "agentUserId",
ac.sale_type as "saleType", ac.unit_price_cents as "unitPriceCents",
ac.sold_to as "soldTo", ac.remark, r.name as "regionName"
from public.activation_codes ac
left join public.regions r on r.tenant_id = ac.tenant_id and r.id = ac.used_region_id
where ac.tenant_id = $1 and lower(ac.code::text) = lower($2)
limit 1
`,
[tenantId, code],
);
if (!activationCode) {
return {
valid: false,
code,
reasonCode: 'ACTIVATION_CODE_NOT_FOUND',
message: 'Activation code not found',
};
}
if (activationCode.isUsed) {
return {
valid: false,
code: activationCode.code,
reasonCode: 'ACTIVATION_CODE_USED',
message: 'Activation code already used',
days: activationCode.days,
};
}
if (activationCode.agentUserId && activationCode.agentUserId === userId) {
return {
valid: false,
code: activationCode.code,
reasonCode: 'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN',
message: 'Cannot redeem your own activation code',
days: activationCode.days,
};
}
if (regionId && activationCode.usedRegionId && regionId !== activationCode.usedRegionId) {
return {
valid: false,
code: activationCode.code,
reasonCode: 'ACTIVATION_CODE_REGION_MISMATCH',
message: 'Activation code does not apply to this region',
days: activationCode.days,
regionId: activationCode.usedRegionId,
regionName: activationCode.regionName,
};
}
return {
valid: true,
code: activationCode.code,
days: activationCode.days,
regionId: activationCode.usedRegionId,
regionName: activationCode.regionName,
saleType: activationCode.saleType,
unitPriceCents: activationCode.unitPriceCents,
soldTo: activationCode.soldTo,
remark: activationCode.remark,
};
}
export async function redeemActivationCodeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const code = requiredString(body, 'code');
const code = normalizeCode(requiredString(body, 'code'));
const regionId = optionalString(body, 'regionId') || null;
const item = await transaction(async client => {
@@ -722,9 +1394,10 @@ export async function redeemActivationCodeRoute(ctx: RequestContext) {
days: number;
is_used: boolean;
used_region_id: string | null;
agent_user_id: string | null;
}>(
`
select id, code, days, is_used, used_region_id
select id, code::text as code, days, is_used, used_region_id, agent_user_id
from public.activation_codes
where tenant_id = $1 and lower(code::text) = lower($2)
limit 1
@@ -740,6 +1413,12 @@ export async function redeemActivationCodeRoute(ctx: RequestContext) {
if (activationCode.is_used) {
throw new HttpError(409, 'Activation code already used', 'ACTIVATION_CODE_USED');
}
if (activationCode.agent_user_id && activationCode.agent_user_id === userId) {
throw new HttpError(409, 'Cannot redeem your own activation code', 'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN');
}
if (regionId && activationCode.used_region_id && regionId !== activationCode.used_region_id) {
throw new HttpError(409, 'Activation code does not apply to this region', 'ACTIVATION_CODE_REGION_MISMATCH');
}
const finalRegionId = regionId || activationCode.used_region_id;
await client.query(
@@ -769,3 +1448,35 @@ export async function redeemActivationCodeRoute(ctx: RequestContext) {
return { item };
}
export async function claimCouponRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const code = normalizeCode(requiredString(body, 'code'));
const planId = optionalString(body, 'planId') || null;
const regionId = optionalString(body, 'regionId') || null;
const result = await transaction(async client => {
const claimed = await claimCoupon(client, {
tenantId,
userId,
code,
planId,
regionId,
});
return {
valid: true,
idempotent: claimed.idempotent,
coupon: couponPayload(claimed.coupon, claimed.plan, claimed.redemption, claimed.discountCents),
redemption: {
id: claimed.redemption.id,
status: claimed.redemption.status,
claimedAt: claimed.redemption.claimed_at,
usedAt: claimed.redemption.used_at,
},
};
});
return result;
}

View File

@@ -12,7 +12,9 @@ interface EntitlementInput {
}
export async function grantSvipEntitlement(client: pg.PoolClient, input: EntitlementInput) {
const days = Math.max(0, Math.trunc(input.days || 0));
const days = Math.trunc(input.days || 0);
const expiresAtSql =
days < 0 ? `'2099-12-31T23:59:59Z'::timestamptz` : `$8::timestamptz + ($9::text || ' days')::interval`;
const scopeType = input.regionId ? 'region' : 'tenant';
const startsAtResult = await client.query<{ starts_at: string }>(
`
@@ -41,7 +43,7 @@ export async function grantSvipEntitlement(client: pg.PoolClient, input: Entitle
values (
$1, $2, 'svip', $3, $4,
$5, $6, $7, $8::timestamptz,
$8::timestamptz + ($9::text || ' days')::interval,
${expiresAtSql},
'active', $10::jsonb
)
returning id, entitlement_type as "entitlementType", scope_type as "scopeType",