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