forked from wangziqi/gongxue-base
feat: add commerce refund workflow
This commit is contained in:
@@ -19,13 +19,14 @@
|
||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志。
|
||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||
- 订单售后基础闭环:退款请求、审核、处理状态流、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
||||
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
|
||||
|
||||
还没有达到生产交付的部分:
|
||||
|
||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路已完成本地适配;微信网页登录、QQ 登录、手机号换绑、退款/对账、支付补偿和真实生产账号联调还没接完。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路已完成本地适配;微信网页登录、QQ 登录、手机号换绑、真实微信/支付宝退款 API、对账、支付补偿和真实生产账号联调还没接完。
|
||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。
|
||||
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
|
||||
- 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。
|
||||
@@ -192,4 +193,4 @@ npm run check:refactor
|
||||
2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。
|
||||
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
|
||||
5. 微信网页/QQ 登录、退款对账、支付补偿、CRM worker、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。
|
||||
5. 微信网页/QQ 登录、真实退款 provider、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
createOrderRoute,
|
||||
entitlementCheckRoute,
|
||||
entitlementsRoute,
|
||||
createRefundRequestRoute,
|
||||
orderDetailRoute,
|
||||
orderStatusRoute,
|
||||
ordersRoute,
|
||||
paymentNotifyRoute,
|
||||
redeemActivationCodeRoute,
|
||||
refundRequestsRoute,
|
||||
updateRefundStatusRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const commerceRoutes: RouteDefinition[] = [
|
||||
@@ -21,6 +24,9 @@ export const commerceRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/commerce/orders/status', orderStatusRoute],
|
||||
['GET', '/api/commerce/entitlements', entitlementsRoute],
|
||||
['GET', '/api/commerce/entitlements/check', entitlementCheckRoute],
|
||||
['GET', '/api/commerce/refunds', refundRequestsRoute],
|
||||
['POST', '/api/commerce/refunds', createRefundRequestRoute],
|
||||
['POST', '/api/commerce/refunds/status', updateRefundStatusRoute],
|
||||
['POST', '/api/commerce/payments/create', createPaymentRoute],
|
||||
['POST', '/api/commerce/payments/manual-confirm', confirmManualPaymentRoute],
|
||||
['POST', '/api/commerce/payments/notify/wechat_pay', paymentNotifyRoute],
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
userIdFrom,
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { createOrderNo, grantSvipEntitlement } from './service.js';
|
||||
import { createOrderNo, createRefundNo, grantSvipEntitlement } from './service.js';
|
||||
import {
|
||||
createPaymentProvider,
|
||||
normalizePaymentProvider,
|
||||
@@ -45,6 +45,7 @@ interface PaymentRow {
|
||||
id: string;
|
||||
status: string;
|
||||
amount_cents: number;
|
||||
refunded_amount_cents?: number;
|
||||
}
|
||||
|
||||
interface PaymentOrderRow {
|
||||
@@ -75,11 +76,41 @@ interface OrderDetailRow {
|
||||
userId: string | null;
|
||||
regionId: string | null;
|
||||
paidAt: string | null;
|
||||
refundedAmountCents?: number;
|
||||
rawPayload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface RefundRequestRow {
|
||||
id: string;
|
||||
refundNo: string;
|
||||
orderId: string;
|
||||
paymentId: string | null;
|
||||
orderNo: string;
|
||||
orderStatus: string;
|
||||
paymentProvider: string | null;
|
||||
provider: string | null;
|
||||
providerRefundNo: string | null;
|
||||
status: string;
|
||||
amountCents: number;
|
||||
reason: string | null;
|
||||
entitlementAction: string;
|
||||
requestedBy: string | null;
|
||||
reviewedBy: string | null;
|
||||
processedBy: string | null;
|
||||
requestedAt: string;
|
||||
reviewedAt: string | null;
|
||||
processedAt: string | null;
|
||||
succeededAt: string | null;
|
||||
failedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
failureReason: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CouponRow {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -331,6 +362,7 @@ function orderDetailPayload(order: OrderDetailRow, payments: unknown[], items: u
|
||||
productName: order.productName,
|
||||
amountCents: order.amountCents,
|
||||
amount: formatPrice(order.amountCents),
|
||||
refundedAmountCents: order.refundedAmountCents ?? 0,
|
||||
payMethod: order.payMethod,
|
||||
payProvider: order.payProvider,
|
||||
tradeNo: order.tradeNo,
|
||||
@@ -347,6 +379,270 @@ function orderDetailPayload(order: OrderDetailRow, payments: unknown[], items: u
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function optionalChoice<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
if (typeof value === 'string' && allowed.includes(value as T)) return value as T;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeRefundNo(value: string) {
|
||||
return value.trim().replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function refundPayload(row: RefundRequestRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
refundNo: row.refundNo,
|
||||
orderId: row.orderId,
|
||||
paymentId: row.paymentId,
|
||||
orderNo: row.orderNo,
|
||||
orderStatus: row.orderStatus,
|
||||
paymentProvider: row.paymentProvider,
|
||||
provider: row.provider,
|
||||
providerRefundNo: row.providerRefundNo,
|
||||
status: row.status,
|
||||
amountCents: row.amountCents,
|
||||
amount: formatPrice(row.amountCents),
|
||||
reason: row.reason,
|
||||
entitlementAction: row.entitlementAction,
|
||||
requestedBy: row.requestedBy,
|
||||
reviewedBy: row.reviewedBy,
|
||||
processedBy: row.processedBy,
|
||||
requestedAt: row.requestedAt,
|
||||
reviewedAt: row.reviewedAt,
|
||||
processedAt: row.processedAt,
|
||||
succeededAt: row.succeededAt,
|
||||
failedAt: row.failedAt,
|
||||
cancelledAt: row.cancelledAt,
|
||||
failureReason: row.failureReason,
|
||||
metadata: row.metadata || {},
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function recordCommerceAudit(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
actorUserId?: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.actorUserId || null,
|
||||
input.action,
|
||||
input.targetType,
|
||||
input.targetId,
|
||||
JSON.stringify(input.details || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function recordRefundEvent(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
refundRequestId: string;
|
||||
fromStatus?: string | null;
|
||||
toStatus: string;
|
||||
eventType: string;
|
||||
actorUserId?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.commerce_refund_events (
|
||||
tenant_id, refund_request_id, from_status, to_status, event_type, actor_user_id, details
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.refundRequestId,
|
||||
input.fromStatus || null,
|
||||
input.toStatus,
|
||||
input.eventType,
|
||||
input.actorUserId || null,
|
||||
JSON.stringify(input.details || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchRefundById(client: pg.PoolClient, tenantId: string, refundId: string, lock = false) {
|
||||
const result = await client.query<RefundRequestRow>(
|
||||
`
|
||||
select rr.id, rr.refund_no as "refundNo", rr.order_id as "orderId", rr.payment_id as "paymentId",
|
||||
o.order_no as "orderNo", o.status as "orderStatus", o.pay_provider as "paymentProvider",
|
||||
rr.provider, rr.provider_refund_no as "providerRefundNo", rr.status,
|
||||
rr.amount_cents as "amountCents", rr.reason, rr.entitlement_action as "entitlementAction",
|
||||
rr.requested_by as "requestedBy", rr.reviewed_by as "reviewedBy", rr.processed_by as "processedBy",
|
||||
rr.requested_at as "requestedAt", rr.reviewed_at as "reviewedAt", rr.processed_at as "processedAt",
|
||||
rr.succeeded_at as "succeededAt", rr.failed_at as "failedAt", rr.cancelled_at as "cancelledAt",
|
||||
rr.failure_reason as "failureReason", rr.metadata,
|
||||
rr.created_at as "createdAt", rr.updated_at as "updatedAt"
|
||||
from public.commerce_refund_requests rr
|
||||
join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id
|
||||
where rr.tenant_id = $1 and rr.id = $2
|
||||
limit 1
|
||||
${lock ? 'for update of rr' : ''}
|
||||
`,
|
||||
[tenantId, refundId],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function applySuccessfulRefund(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
refund: RefundRequestRow;
|
||||
actorUserId: string;
|
||||
providerRefundNo?: string | null;
|
||||
},
|
||||
) {
|
||||
const orderResult = await client.query<{
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
amountCents: number;
|
||||
refundedAmountCents: number;
|
||||
userId: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, order_no as "orderNo", status, amount_cents as "amountCents",
|
||||
refunded_amount_cents as "refundedAmountCents", user_id as "userId"
|
||||
from public.orders
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[input.tenantId, input.refund.orderId],
|
||||
);
|
||||
const order = orderResult.rows[0];
|
||||
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
|
||||
if (!['paid', 'partially_refunded'].includes(order.status)) {
|
||||
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_REFUNDABLE');
|
||||
}
|
||||
|
||||
const nextRefundedAmount = order.refundedAmountCents + input.refund.amountCents;
|
||||
if (nextRefundedAmount > order.amountCents) {
|
||||
throw new HttpError(409, 'Refund amount exceeds paid amount', 'REFUND_AMOUNT_EXCEEDS_PAID');
|
||||
}
|
||||
const finalStatus = nextRefundedAmount === order.amountCents ? 'refunded' : 'partially_refunded';
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.orders
|
||||
set status = $4, refunded_amount_cents = $3, updated_at = now(),
|
||||
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
order.id,
|
||||
nextRefundedAmount,
|
||||
finalStatus,
|
||||
JSON.stringify({
|
||||
lastRefund: {
|
||||
refundNo: input.refund.refundNo,
|
||||
amountCents: input.refund.amountCents,
|
||||
providerRefundNo: input.providerRefundNo || input.refund.providerRefundNo || null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
if (input.refund.paymentId) {
|
||||
const paymentResult = await client.query<PaymentRow>(
|
||||
`
|
||||
select id, status, amount_cents, refunded_amount_cents
|
||||
from public.payments
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[input.tenantId, input.refund.paymentId],
|
||||
);
|
||||
const payment = paymentResult.rows[0];
|
||||
if (!payment) throw new HttpError(404, 'Payment not found', 'PAYMENT_NOT_FOUND');
|
||||
const paymentRefundedAmount = (payment.refunded_amount_cents || 0) + input.refund.amountCents;
|
||||
if (paymentRefundedAmount > payment.amount_cents) {
|
||||
throw new HttpError(409, 'Refund amount exceeds payment amount', 'REFUND_AMOUNT_EXCEEDS_PAID');
|
||||
}
|
||||
await client.query(
|
||||
`
|
||||
update public.payments
|
||||
set status = $4, refunded_amount_cents = $3, updated_at = now(),
|
||||
raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
payment.id,
|
||||
paymentRefundedAmount,
|
||||
paymentRefundedAmount === payment.amount_cents ? 'refunded' : 'partially_refunded',
|
||||
JSON.stringify({
|
||||
lastRefund: {
|
||||
refundNo: input.refund.refundNo,
|
||||
amountCents: input.refund.amountCents,
|
||||
providerRefundNo: input.providerRefundNo || input.refund.providerRefundNo || null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
let revokedEntitlements = 0;
|
||||
if (input.refund.entitlementAction === 'revoke_on_success' && finalStatus === 'refunded') {
|
||||
const revoked = await client.query(
|
||||
`
|
||||
update public.entitlements
|
||||
set status = 'revoked',
|
||||
revoked_at = now(),
|
||||
revoked_by = $3,
|
||||
revoked_reason = $4,
|
||||
metadata = coalesce(metadata, '{}'::jsonb) || $5::jsonb
|
||||
where tenant_id = $1
|
||||
and source_type = 'order'
|
||||
and source_id = $2
|
||||
and status = 'active'
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
order.id,
|
||||
input.actorUserId,
|
||||
`refund:${input.refund.refundNo}`,
|
||||
JSON.stringify({
|
||||
revokedByRefundNo: input.refund.refundNo,
|
||||
refundAmountCents: input.refund.amountCents,
|
||||
}),
|
||||
],
|
||||
);
|
||||
revokedEntitlements = revoked.rowCount || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
orderNo: order.orderNo,
|
||||
orderStatus: finalStatus,
|
||||
refundedAmountCents: nextRefundedAmount,
|
||||
revokedEntitlements,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOrderRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
@@ -634,6 +930,7 @@ export async function ordersRoute(ctx: RequestContext) {
|
||||
pay_method as "payMethod", pay_provider as "payProvider",
|
||||
trade_no as "tradeNo", plan_id as "planId", days,
|
||||
region_id as "regionId", paid_at as "paidAt",
|
||||
refunded_amount_cents as "refundedAmountCents",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.orders
|
||||
where tenant_id = $1 and user_id = $2
|
||||
@@ -653,7 +950,8 @@ async function loadOrderDetail(tenantId: string, userId: string, orderNo: string
|
||||
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",
|
||||
region_id as "regionId", paid_at as "paidAt",
|
||||
refunded_amount_cents as "refundedAmountCents", 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
|
||||
@@ -721,7 +1019,8 @@ export async function orderStatusRoute(ctx: RequestContext) {
|
||||
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",
|
||||
region_id as "regionId", paid_at as "paidAt",
|
||||
refunded_amount_cents as "refundedAmountCents", 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
|
||||
@@ -758,6 +1057,7 @@ export async function orderStatusRoute(ctx: RequestContext) {
|
||||
status: order.status,
|
||||
amountCents: order.amountCents,
|
||||
amount: formatPrice(order.amountCents),
|
||||
refundedAmountCents: order.refundedAmountCents ?? 0,
|
||||
payProvider: order.payProvider,
|
||||
payMethod: order.payMethod,
|
||||
tradeNo: order.tradeNo,
|
||||
@@ -865,6 +1165,319 @@ export async function entitlementCheckRoute(ctx: RequestContext) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function refundRequestsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'tenant:refund:read');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const orderNo = stringParam(ctx, 'orderNo');
|
||||
|
||||
const params: unknown[] = [auth.tenantId, limit];
|
||||
const where: string[] = ['rr.tenant_id = $1'];
|
||||
if (status) {
|
||||
params.push(status);
|
||||
where.push(`rr.status = $${params.length}`);
|
||||
}
|
||||
if (orderNo) {
|
||||
params.push(orderNo);
|
||||
where.push(`o.order_no = $${params.length}`);
|
||||
}
|
||||
|
||||
const items = await query<RefundRequestRow>(
|
||||
`
|
||||
select rr.id, rr.refund_no as "refundNo", rr.order_id as "orderId", rr.payment_id as "paymentId",
|
||||
o.order_no as "orderNo", o.status as "orderStatus", o.pay_provider as "paymentProvider",
|
||||
rr.provider, rr.provider_refund_no as "providerRefundNo", rr.status,
|
||||
rr.amount_cents as "amountCents", rr.reason, rr.entitlement_action as "entitlementAction",
|
||||
rr.requested_by as "requestedBy", rr.reviewed_by as "reviewedBy", rr.processed_by as "processedBy",
|
||||
rr.requested_at as "requestedAt", rr.reviewed_at as "reviewedAt", rr.processed_at as "processedAt",
|
||||
rr.succeeded_at as "succeededAt", rr.failed_at as "failedAt", rr.cancelled_at as "cancelledAt",
|
||||
rr.failure_reason as "failureReason", rr.metadata,
|
||||
rr.created_at as "createdAt", rr.updated_at as "updatedAt"
|
||||
from public.commerce_refund_requests rr
|
||||
join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id
|
||||
where ${where.join(' and ')}
|
||||
order by rr.created_at desc
|
||||
limit $2
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items: items.map(refundPayload) };
|
||||
}
|
||||
|
||||
export async function createRefundRequestRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'tenant:refund:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const orderNo = requiredString(body, 'orderNo');
|
||||
const requestedAmountCents = Math.max(0, optionalInteger(body, 'amountCents', 0));
|
||||
const reason = optionalString(body, 'reason') || null;
|
||||
const refundNo = normalizeRefundNo(optionalString(body, 'refundNo') || createRefundNo());
|
||||
const entitlementAction = optionalChoice(body.entitlementAction, ['none', 'revoke_on_success'] as const, 'revoke_on_success');
|
||||
const metadata = objectValue(body.metadata);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const orderResult = await client.query<{
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
amountCents: number;
|
||||
refundedAmountCents: number;
|
||||
userId: string | null;
|
||||
payProvider: string | null;
|
||||
payMethod: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, order_no as "orderNo", status, amount_cents as "amountCents",
|
||||
refunded_amount_cents as "refundedAmountCents", user_id as "userId",
|
||||
pay_provider as "payProvider", pay_method as "payMethod"
|
||||
from public.orders
|
||||
where tenant_id = $1 and order_no = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, orderNo],
|
||||
);
|
||||
const order = orderResult.rows[0];
|
||||
if (!order) throw new HttpError(404, 'Order not found', 'ORDER_NOT_FOUND');
|
||||
if (!order.userId) throw new HttpError(409, 'Order has no user', 'ORDER_USER_MISSING');
|
||||
if (!['paid', 'partially_refunded'].includes(order.status)) {
|
||||
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_REFUNDABLE');
|
||||
}
|
||||
|
||||
const existingByRefundNo = await client.query<{ id: string; order_id: string; amount_cents: number }>(
|
||||
`
|
||||
select id, order_id, amount_cents
|
||||
from public.commerce_refund_requests
|
||||
where tenant_id = $1 and refund_no = $2
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, refundNo],
|
||||
);
|
||||
const existingRefund = existingByRefundNo.rows[0];
|
||||
if (existingRefund) {
|
||||
if (existingRefund.order_id !== order.id || (requestedAmountCents > 0 && requestedAmountCents !== existingRefund.amount_cents)) {
|
||||
throw new HttpError(409, 'Refund number already belongs to another refund request', 'REFUND_NO_CONFLICT');
|
||||
}
|
||||
const refund = await fetchRefundById(client, auth.tenantId, existingRefund.id, true);
|
||||
if (!refund) throw new HttpError(500, 'Refund request lookup failed', 'REFUND_LOOKUP_FAILED');
|
||||
return { ...refundPayload(refund), idempotent: true };
|
||||
}
|
||||
|
||||
const alreadySucceeded = await client.query<{ total: number }>(
|
||||
`
|
||||
select coalesce(sum(amount_cents), 0)::integer as total
|
||||
from public.commerce_refund_requests
|
||||
where tenant_id = $1
|
||||
and order_id = $2
|
||||
and status in ('requested', 'approved', 'processing', 'succeeded')
|
||||
`,
|
||||
[auth.tenantId, order.id],
|
||||
);
|
||||
const reservedAmount = Number(alreadySucceeded.rows[0]?.total || 0);
|
||||
const remainingAmount = Math.max(0, order.amountCents - reservedAmount);
|
||||
const amountCents = requestedAmountCents || remainingAmount;
|
||||
if (amountCents <= 0) throw new HttpError(409, 'Order has no refundable amount', 'ORDER_NOT_REFUNDABLE');
|
||||
if (amountCents > remainingAmount) {
|
||||
throw new HttpError(409, 'Refund amount exceeds paid amount', 'REFUND_AMOUNT_EXCEEDS_PAID');
|
||||
}
|
||||
|
||||
const paymentResult = await client.query<PaymentRow & { provider: string | null }>(
|
||||
`
|
||||
select id, provider, status, amount_cents, refunded_amount_cents
|
||||
from public.payments
|
||||
where tenant_id = $1
|
||||
and order_id = $2
|
||||
and status in ('paid', 'partially_refunded', 'refunded')
|
||||
order by paid_at desc nulls last, updated_at desc
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, order.id],
|
||||
);
|
||||
const payment = paymentResult.rows[0];
|
||||
if (!payment && amountCents > 0) throw new HttpError(404, 'Paid payment not found', 'PAYMENT_NOT_FOUND');
|
||||
if (payment && amountCents > payment.amount_cents - (payment.refunded_amount_cents || 0)) {
|
||||
throw new HttpError(409, 'Refund amount exceeds payment amount', 'REFUND_AMOUNT_EXCEEDS_PAID');
|
||||
}
|
||||
|
||||
const inserted = await client.query<RefundRequestRow>(
|
||||
`
|
||||
insert into public.commerce_refund_requests (
|
||||
tenant_id, order_id, payment_id, refund_no, provider, status, amount_cents,
|
||||
reason, entitlement_action, requested_by, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'requested', $6, $7, $8, $9, $10::jsonb)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
order.id,
|
||||
payment?.id || null,
|
||||
refundNo,
|
||||
payment?.provider || order.payProvider || null,
|
||||
amountCents,
|
||||
reason,
|
||||
entitlementAction,
|
||||
auth.userId,
|
||||
JSON.stringify(metadata),
|
||||
],
|
||||
);
|
||||
|
||||
const refund = await fetchRefundById(client, auth.tenantId, inserted.rows[0].id, true);
|
||||
if (!refund) throw new HttpError(500, 'Refund request was not created', 'REFUND_CREATE_FAILED');
|
||||
await recordRefundEvent(client, {
|
||||
tenantId: auth.tenantId,
|
||||
refundRequestId: refund.id,
|
||||
toStatus: refund.status,
|
||||
eventType: 'created',
|
||||
actorUserId: auth.userId,
|
||||
details: { orderNo, amountCents, reason, entitlementAction },
|
||||
});
|
||||
await recordCommerceAudit(client, {
|
||||
tenantId: auth.tenantId,
|
||||
actorUserId: auth.userId,
|
||||
action: 'commerce.refund.requested',
|
||||
targetType: 'commerce_refund_request',
|
||||
targetId: refund.id,
|
||||
details: { refundNo: refund.refundNo, orderNo, amountCents, reason, entitlementAction },
|
||||
});
|
||||
return refundPayload(refund);
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function updateRefundStatusRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const refundId = requiredString(body, 'refundId');
|
||||
const action = requiredString(body, 'action');
|
||||
const note = optionalString(body, 'note') || null;
|
||||
const providerRefundNo = optionalString(body, 'providerRefundNo') || null;
|
||||
const failureReason = optionalString(body, 'failureReason') || note;
|
||||
const metadataPatch = objectValue(body.metadata);
|
||||
|
||||
const reviewActions = new Set(['approve', 'reject', 'cancel']);
|
||||
requireTenantPermission(auth, reviewActions.has(action) ? 'tenant:refund:review' : 'tenant:refund:write');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const refund = await fetchRefundById(client, auth.tenantId, refundId, true);
|
||||
if (!refund) throw new HttpError(404, 'Refund request not found', 'REFUND_NOT_FOUND');
|
||||
|
||||
const fromStatus = refund.status;
|
||||
let toStatus = fromStatus;
|
||||
let eventType = action;
|
||||
let processResult: Record<string, unknown> = {};
|
||||
const details: Record<string, unknown> = { note, providerRefundNo, failureReason, metadata: metadataPatch };
|
||||
|
||||
if (action === 'approve') {
|
||||
if (fromStatus !== 'requested') throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||
toStatus = 'approved';
|
||||
} else if (action === 'reject') {
|
||||
if (!['requested', 'approved'].includes(fromStatus)) {
|
||||
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||
}
|
||||
toStatus = 'rejected';
|
||||
} else if (action === 'mark_processing') {
|
||||
if (!['requested', 'approved'].includes(fromStatus)) {
|
||||
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||
}
|
||||
toStatus = 'processing';
|
||||
eventType = 'processing';
|
||||
} else if (action === 'mark_succeeded') {
|
||||
if (!['requested', 'approved', 'processing'].includes(fromStatus)) {
|
||||
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||
}
|
||||
processResult = await applySuccessfulRefund(client, {
|
||||
tenantId: auth.tenantId,
|
||||
refund,
|
||||
actorUserId: auth.userId,
|
||||
providerRefundNo,
|
||||
});
|
||||
toStatus = 'succeeded';
|
||||
eventType = 'succeeded';
|
||||
details.processResult = processResult;
|
||||
} else if (action === 'mark_failed') {
|
||||
if (!['approved', 'processing'].includes(fromStatus)) {
|
||||
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||
}
|
||||
toStatus = 'failed';
|
||||
eventType = 'failed';
|
||||
} else if (action === 'cancel') {
|
||||
if (!['requested', 'approved'].includes(fromStatus)) {
|
||||
throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID');
|
||||
}
|
||||
toStatus = 'cancelled';
|
||||
} else {
|
||||
throw new HttpError(400, 'Unsupported refund action', 'REFUND_ACTION_INVALID');
|
||||
}
|
||||
|
||||
const updated = await client.query<RefundRequestRow>(
|
||||
`
|
||||
update public.commerce_refund_requests
|
||||
set status = $3,
|
||||
provider_refund_no = coalesce($4, provider_refund_no),
|
||||
reviewed_by = case when $3 in ('approved', 'rejected') then $5 else reviewed_by end,
|
||||
processed_by = case when $3 in ('processing', 'succeeded', 'failed') then $5 else processed_by end,
|
||||
reviewed_at = case when $3 in ('approved', 'rejected') then now() else reviewed_at end,
|
||||
processed_at = case when $3 in ('processing', 'succeeded', 'failed') then now() else processed_at end,
|
||||
succeeded_at = case when $3 = 'succeeded' then now() else succeeded_at end,
|
||||
failed_at = case when $3 = 'failed' then now() else failed_at end,
|
||||
cancelled_at = case when $3 = 'cancelled' then now() else cancelled_at end,
|
||||
failure_reason = case when $3 = 'failed' then $6 else failure_reason end,
|
||||
metadata = coalesce(metadata, '{}'::jsonb) || $7::jsonb,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
refund.id,
|
||||
toStatus,
|
||||
providerRefundNo,
|
||||
auth.userId,
|
||||
failureReason,
|
||||
JSON.stringify({ lastAction: action, note, processResult, ...metadataPatch }),
|
||||
],
|
||||
);
|
||||
const updatedRefund = await fetchRefundById(client, auth.tenantId, updated.rows[0].id, true);
|
||||
if (!updatedRefund) throw new HttpError(500, 'Refund request update failed', 'REFUND_UPDATE_FAILED');
|
||||
|
||||
await recordRefundEvent(client, {
|
||||
tenantId: auth.tenantId,
|
||||
refundRequestId: refund.id,
|
||||
fromStatus,
|
||||
toStatus,
|
||||
eventType,
|
||||
actorUserId: auth.userId,
|
||||
details,
|
||||
});
|
||||
await recordCommerceAudit(client, {
|
||||
tenantId: auth.tenantId,
|
||||
actorUserId: auth.userId,
|
||||
action: `commerce.refund.${eventType}`,
|
||||
targetType: 'commerce_refund_request',
|
||||
targetId: refund.id,
|
||||
details: {
|
||||
refundNo: refund.refundNo,
|
||||
orderNo: refund.orderNo,
|
||||
fromStatus,
|
||||
toStatus,
|
||||
amountCents: refund.amountCents,
|
||||
providerRefundNo,
|
||||
processResult,
|
||||
},
|
||||
});
|
||||
|
||||
return refundPayload(updatedRefund);
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function confirmManualPaymentRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
@@ -900,6 +1513,12 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) {
|
||||
if (order.status === 'paid') {
|
||||
return { orderNo, status: 'paid', idempotent: true };
|
||||
}
|
||||
if (['partially_refunded', 'refunded'].includes(order.status)) {
|
||||
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
|
||||
}
|
||||
if (order.status !== 'pending') {
|
||||
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -1216,6 +1835,12 @@ export async function paymentNotifyRoute(ctx: RequestContext) {
|
||||
orderNo: order.order_no,
|
||||
};
|
||||
}
|
||||
if (['partially_refunded', 'refunded'].includes(order.status)) {
|
||||
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
|
||||
}
|
||||
if (order.status !== 'pending') {
|
||||
throw new HttpError(409, `Order status is ${order.status}`, 'ORDER_NOT_PAYABLE');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
|
||||
@@ -80,3 +80,7 @@ export function createOrderNo(prefix = 'SVIP') {
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `${prefix}${stamp}${random}`;
|
||||
}
|
||||
|
||||
export function createRefundNo(prefix = 'RF') {
|
||||
return createOrderNo(prefix);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ export function tenantPermissionCatalog() {
|
||||
{ key: 'tenant:domains:write', label: '域名管理' },
|
||||
{ key: 'tenant:payment:read', label: '商户配置查看' },
|
||||
{ key: 'tenant:payment:write', label: '商户配置管理' },
|
||||
{ key: 'tenant:refund:read', label: '退款查看' },
|
||||
{ key: 'tenant:refund:write', label: '退款申请/处理' },
|
||||
{ key: 'tenant:refund:review', label: '退款审核' },
|
||||
{ key: 'tenant:auth:read', label: '登录配置查看' },
|
||||
{ key: 'tenant:auth:write', label: '登录配置管理' },
|
||||
{ key: 'tenant:secrets:read', label: '密钥掩码查看' },
|
||||
|
||||
@@ -18,7 +18,7 @@ apps/api/src/
|
||||
tenant/ 租户解析、品牌配置、域名识别
|
||||
catalog/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口
|
||||
learning/ 组卷 session、答题、错题、收藏、练习进度、排行榜
|
||||
commerce/ 订单、支付确认、激活码、优惠券、权益
|
||||
commerce/ 订单、支付确认、退款、激活码、优惠券、权益
|
||||
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
|
||||
storage/ 对象存储签名 provider
|
||||
video/ 题目视频列表、搜索、SVIP/次数校验和签名播放
|
||||
|
||||
@@ -108,7 +108,8 @@
|
||||
| 激活码预检查/兑换 | 可联调 | `/api/commerce/activation-codes/check`、`redeem`;支持地区校验、自用码拒绝、已用码稳定 reasonCode |
|
||||
| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` |
|
||||
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
||||
| 退款/补偿/对账 | 待补齐 | 需退款接口、支付补偿任务、对账、异常订单处理 |
|
||||
| 退款状态机 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`;支持退款申请、审核、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
||||
| 补偿/对账/真实退款 provider | 待补齐 | 真实微信/支付宝退款 API、支付补偿任务、对账、异常订单自动处理和退款 worker |
|
||||
|
||||
## 租户后台与平台后台
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ GET /api/tenant-admin/audit-logs
|
||||
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
||||
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
|
||||
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
|
||||
- `/api/commerce/refunds` 和 `/api/commerce/refunds/status` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,全额退款成功会撤销订单来源权益,真实微信/支付宝退款 API 和对账 worker 后续接入。
|
||||
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
||||
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
||||
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
||||
@@ -245,7 +246,7 @@ GET /api/tenant-admin/audit-logs
|
||||
1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||
2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||
3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。
|
||||
4. 补退款、支付补偿任务、对账、异常订单处理和优惠券核销报表。
|
||||
4. 补真实微信/支付宝退款 provider、支付补偿任务、对账、异常订单自动处理和优惠券核销报表。
|
||||
5. 扩展 `apps/worker`:支付补偿、日报统计、导入后检查、CRM 死信告警和公共题库同步。
|
||||
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
|
||||
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路已有;缺退款/对账/补偿任务和前端收银台体验 |
|
||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机和全额退款权益撤销已有;缺真实退款 provider、对账/补偿任务和前端收银台/售后体验 |
|
||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 |
|
||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
|
||||
1. 支付
|
||||
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
|
||||
- 继续补退款、支付补偿任务、对账、异常订单处理。
|
||||
- 已完成内部退款状态机、退款申请/审核/处理接口、部分/全额退款状态、全额退款权益撤销和审计事件。
|
||||
- 继续补真实微信/支付宝退款 provider、支付补偿任务、对账和异常订单自动处理。
|
||||
- 租户自有商户收款和平台代收/服务商模式。
|
||||
|
||||
2. 国内登录和短信
|
||||
@@ -91,8 +92,8 @@
|
||||
- 继续补断点续练和复盘体验。
|
||||
|
||||
7. 订单和营销体验
|
||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取和下单抵扣计算。
|
||||
- 继续补退款、支付补偿任务、对账、异常订单处理、优惠券核销报表和复杂活动规则。
|
||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
||||
- 继续补真实 provider 退款、支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。
|
||||
|
||||
8. 积分和反馈增强
|
||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||
@@ -200,5 +201,5 @@
|
||||
2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
|
||||
5. 并行补对象存储、真实登录、退款对账、CRM worker 和公共题库版本同步 worker。
|
||||
5. 并行补对象存储、真实登录、真实退款 provider、支付对账和公共题库版本同步 worker。
|
||||
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -872,6 +872,60 @@ GET /api/commerce/entitlements
|
||||
|
||||
订单详情会返回 `pricing`、`payments`、`items`、`couponRedemptions`,可用于收银台、订单详情页和售后排查。订单状态轮询页只需消费 `status/payment`,避免频繁拉取全量明细。
|
||||
|
||||
### 退款和售后
|
||||
|
||||
学生端不直接发起后台退款命令。普通用户订单页只展示 `GET /api/commerce/orders/status` 和 `GET /api/commerce/orders/detail` 返回的订单状态、支付状态、`refundedAmountCents`,并提供客服/工单入口。租户后台或运营后台才接退款接口。
|
||||
|
||||
租户后台退款列表:
|
||||
|
||||
```text
|
||||
GET /api/commerce/refunds?status=requested&orderNo=<orderNo>
|
||||
权限:tenant:refund:read
|
||||
```
|
||||
|
||||
创建退款申请:
|
||||
|
||||
```text
|
||||
POST /api/commerce/refunds
|
||||
权限:tenant:refund:write
|
||||
body: {
|
||||
"orderNo": "<orderNo>",
|
||||
"refundNo": "<可选,前端幂等键>",
|
||||
"amountCents": 500,
|
||||
"reason": "用户协商退款",
|
||||
"entitlementAction": "revoke_on_success | none"
|
||||
}
|
||||
```
|
||||
|
||||
退款状态流转:
|
||||
|
||||
```text
|
||||
POST /api/commerce/refunds/status
|
||||
body: {
|
||||
"refundId": "<refundId>",
|
||||
"action": "approve | reject | mark_processing | mark_succeeded | mark_failed | cancel",
|
||||
"providerRefundNo": "<支付平台退款单号,可选>",
|
||||
"note": "<处理备注>"
|
||||
}
|
||||
```
|
||||
|
||||
状态说明:
|
||||
|
||||
```text
|
||||
requested -> approved -> processing -> succeeded
|
||||
requested/approved -> rejected
|
||||
requested/approved -> cancelled
|
||||
approved/processing -> failed
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- 金额单位一律是分,前端不要传元。
|
||||
- `refundNo` 是幂等键;同一订单同一金额重复提交会返回原退款申请。
|
||||
- 后端会限制累计退款金额不能超过实付金额。
|
||||
- 全额退款成功后订单和支付会进入 `refunded`,相关订单权益会被置为 `revoked`;部分退款进入 `partially_refunded`,默认不撤销权益。
|
||||
- 当前接口完成内部退款状态机和人工成功登记;真实微信/支付宝退款 API、自动对账和补偿 worker 后续接入。前端不要假设点击退款后已经实时调用支付平台。
|
||||
|
||||
### 激活码预检查与兑换
|
||||
|
||||
兑换前建议先调用:
|
||||
|
||||
@@ -1315,6 +1315,120 @@ async function testCommerce() {
|
||||
});
|
||||
assert.equal(manualConfirmedAgain.item?.idempotent, true, 'duplicate manual confirmation should be idempotent');
|
||||
|
||||
const studentRefundDenied = await request('/api/commerce/refunds', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: discountedOrder.item.orderNo,
|
||||
amountCents: 500,
|
||||
reason: 'student should not be able to request admin refund',
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(studentRefundDenied.code, 'TENANT_ADMIN_REQUIRED', 'student must not create refund request');
|
||||
|
||||
const refund = await request('/api/commerce/refunds', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: discountedOrder.item.orderNo,
|
||||
refundNo: 'RF-INTEGRATION-001',
|
||||
amountCents: 500,
|
||||
reason: 'integration refund',
|
||||
},
|
||||
});
|
||||
assert.equal(refund.item?.refundNo, 'RF-INTEGRATION-001', 'tenant admin should create refund request');
|
||||
assert.equal(refund.item?.status, 'requested', 'refund should start requested');
|
||||
assert.equal(refund.item?.amountCents, 500, 'refund should use cents amount');
|
||||
|
||||
const refundAgain = await request('/api/commerce/refunds', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: discountedOrder.item.orderNo,
|
||||
refundNo: 'RF-INTEGRATION-001',
|
||||
amountCents: 500,
|
||||
reason: 'integration refund duplicate',
|
||||
},
|
||||
});
|
||||
assert.equal(refundAgain.item?.idempotent, true, 'refundNo should be idempotent for same order and amount');
|
||||
assert.equal(refundAgain.item?.id, refund.item.id, 'idempotent refund should return original request');
|
||||
|
||||
const excessiveRefund = await request('/api/commerce/refunds', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: discountedOrder.item.orderNo,
|
||||
refundNo: 'RF-INTEGRATION-EXCESS',
|
||||
amountCents: 1,
|
||||
reason: 'should exceed because full amount is already reserved',
|
||||
},
|
||||
expectStatus: 409,
|
||||
});
|
||||
assert.equal(excessiveRefund.code, 'REFUND_AMOUNT_EXCEEDS_PAID', 'reserved refunds must prevent over-refund');
|
||||
|
||||
const listedRefunds = await request('/api/commerce/refunds', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { orderNo: discountedOrder.item.orderNo },
|
||||
});
|
||||
assert.ok(listedRefunds.items?.some(item => item.id === refund.item.id), 'refund list should include created refund');
|
||||
|
||||
const approvedRefund = await request('/api/commerce/refunds/status', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
refundId: refund.item.id,
|
||||
action: 'approve',
|
||||
note: 'approved by integration test',
|
||||
},
|
||||
});
|
||||
assert.equal(approvedRefund.item?.status, 'approved', 'tenant admin should approve refund');
|
||||
|
||||
const succeededRefund = await request('/api/commerce/refunds/status', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
refundId: refund.item.id,
|
||||
action: 'mark_succeeded',
|
||||
providerRefundNo: 'provider-refund-integration-001',
|
||||
},
|
||||
});
|
||||
assert.equal(succeededRefund.item?.status, 'succeeded', 'tenant admin should mark refund succeeded');
|
||||
assert.equal(succeededRefund.item?.providerRefundNo, 'provider-refund-integration-001', 'refund should record provider refund no');
|
||||
|
||||
const refundedStatus = await request('/api/commerce/orders/status', {
|
||||
query: { orderNo: discountedOrder.item.orderNo },
|
||||
});
|
||||
assert.equal(refundedStatus.item?.status, 'refunded', 'full refund should mark order refunded');
|
||||
assert.equal(refundedStatus.item?.payment?.status, 'refunded', 'full refund should mark payment refunded');
|
||||
assert.equal(refundedStatus.item?.refundedAmountCents, 500, 'order status should expose refunded amount');
|
||||
|
||||
const entitlementAfterRefund = await request('/api/commerce/entitlements');
|
||||
assert.ok(
|
||||
entitlementAfterRefund.items?.some(
|
||||
item => item.sourceType === 'order' && item.sourceId === discountedOrder.item.id && item.status === 'revoked',
|
||||
),
|
||||
'full order refund should revoke entitlement from that order',
|
||||
);
|
||||
|
||||
const refundStatusAgain = await request('/api/commerce/refunds/status', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
refundId: refund.item.id,
|
||||
action: 'mark_succeeded',
|
||||
},
|
||||
expectStatus: 409,
|
||||
});
|
||||
assert.equal(refundStatusAgain.code, 'REFUND_STATUS_INVALID', 'succeeded refund must not be processed twice');
|
||||
|
||||
const crossTenantRefundDenied = await request('/api/commerce/refunds', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { orderNo: discountedOrder.item.orderNo },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(crossTenantRefundDenied.code, 'TENANT_ADMIN_REQUIRED', 'refund admin list must be tenant isolated');
|
||||
|
||||
const usedCouponClaim = await request('/api/commerce/coupons/claim', {
|
||||
method: 'POST',
|
||||
body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region },
|
||||
@@ -1353,6 +1467,56 @@ async function testCommerce() {
|
||||
});
|
||||
assert.equal(freePaymentDenied.code, 'ORDER_ALREADY_PAID', 'paid zero-amount order should not create another payment');
|
||||
|
||||
const partialRefundOrder = await request('/api/commerce/orders', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
planId: ids.plan,
|
||||
payProvider: 'manual',
|
||||
payMethod: 'manual',
|
||||
regionId: ids.region,
|
||||
},
|
||||
});
|
||||
await request('/api/commerce/payments/manual-confirm', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: partialRefundOrder.item.orderNo,
|
||||
amountCents: partialRefundOrder.item.amountCents,
|
||||
providerTradeNo: `manual-${partialRefundOrder.item.orderNo}`,
|
||||
},
|
||||
});
|
||||
const partialRefund = await request('/api/commerce/refunds', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: partialRefundOrder.item.orderNo,
|
||||
refundNo: 'RF-INTEGRATION-PARTIAL',
|
||||
amountCents: 100,
|
||||
entitlementAction: 'revoke_on_success',
|
||||
reason: 'partial refund should keep entitlement active',
|
||||
},
|
||||
});
|
||||
await request('/api/commerce/refunds/status', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
refundId: partialRefund.item.id,
|
||||
action: 'mark_succeeded',
|
||||
providerRefundNo: 'provider-refund-integration-partial',
|
||||
},
|
||||
});
|
||||
const partialRefundStatus = await request('/api/commerce/orders/status', {
|
||||
query: { orderNo: partialRefundOrder.item.orderNo },
|
||||
});
|
||||
assert.equal(partialRefundStatus.item?.status, 'partially_refunded', 'partial refund should mark order partially_refunded');
|
||||
const entitlementAfterPartialRefund = await request('/api/commerce/entitlements');
|
||||
assert.ok(
|
||||
entitlementAfterPartialRefund.items?.some(
|
||||
item => item.sourceType === 'order' && item.sourceId === partialRefundOrder.item.id && item.status === 'active',
|
||||
),
|
||||
'partial refund should not revoke the order entitlement',
|
||||
);
|
||||
|
||||
const crossTenantOrderDetail = await request('/api/commerce/orders/detail', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
query: { orderNo: freeOrder.item.orderNo },
|
||||
|
||||
104
supabase/migrations/202606290011_commerce_refunds.sql
Normal file
104
supabase/migrations/202606290011_commerce_refunds.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
alter table public.orders
|
||||
add column if not exists refunded_amount_cents integer not null default 0;
|
||||
|
||||
alter table public.payments
|
||||
add column if not exists refunded_amount_cents integer not null default 0;
|
||||
|
||||
alter table public.orders drop constraint if exists orders_status_check;
|
||||
alter table public.orders
|
||||
add constraint orders_status_check
|
||||
check (status in ('pending', 'paid', 'failed', 'closed', 'partially_refunded', 'refunded'));
|
||||
|
||||
alter table public.payments drop constraint if exists payments_status_check;
|
||||
alter table public.payments
|
||||
add constraint payments_status_check
|
||||
check (status in ('pending', 'paid', 'failed', 'cancelled', 'partially_refunded', 'refunded'));
|
||||
|
||||
alter table public.entitlements
|
||||
add column if not exists revoked_at timestamptz,
|
||||
add column if not exists revoked_by uuid references public.platform_users(id) on delete set null,
|
||||
add column if not exists revoked_reason text;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (select 1 from pg_constraint where conname = 'orders_refunded_amount_cents_check') then
|
||||
alter table public.orders
|
||||
add constraint orders_refunded_amount_cents_check check (refunded_amount_cents >= 0);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from pg_constraint where conname = 'payments_refunded_amount_cents_check') then
|
||||
alter table public.payments
|
||||
add constraint payments_refunded_amount_cents_check check (refunded_amount_cents >= 0);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create table if not exists public.commerce_refund_requests (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
order_id uuid not null references public.orders(id) on delete cascade,
|
||||
payment_id uuid references public.payments(id) on delete set null,
|
||||
refund_no text not null,
|
||||
provider text,
|
||||
provider_refund_no text,
|
||||
status text not null default 'requested'
|
||||
check (status in ('requested', 'approved', 'processing', 'succeeded', 'failed', 'rejected', 'cancelled')),
|
||||
amount_cents integer not null check (amount_cents > 0),
|
||||
reason text,
|
||||
entitlement_action text not null default 'revoke_on_success'
|
||||
check (entitlement_action in ('none', 'revoke_on_success')),
|
||||
requested_by uuid references public.platform_users(id) on delete set null,
|
||||
reviewed_by uuid references public.platform_users(id) on delete set null,
|
||||
processed_by uuid references public.platform_users(id) on delete set null,
|
||||
requested_at timestamptz not null default now(),
|
||||
reviewed_at timestamptz,
|
||||
processed_at timestamptz,
|
||||
succeeded_at timestamptz,
|
||||
failed_at timestamptz,
|
||||
cancelled_at timestamptz,
|
||||
failure_reason text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (tenant_id, refund_no)
|
||||
);
|
||||
|
||||
create table if not exists public.commerce_refund_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
refund_request_id uuid not null references public.commerce_refund_requests(id) on delete cascade,
|
||||
from_status text,
|
||||
to_status text not null,
|
||||
event_type text not null,
|
||||
actor_user_id uuid references public.platform_users(id) on delete set null,
|
||||
details jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_commerce_refunds_order
|
||||
on public.commerce_refund_requests(tenant_id, order_id, created_at desc);
|
||||
|
||||
create index if not exists idx_commerce_refunds_status
|
||||
on public.commerce_refund_requests(tenant_id, status, created_at desc);
|
||||
|
||||
create index if not exists idx_commerce_refund_events_request
|
||||
on public.commerce_refund_events(tenant_id, refund_request_id, created_at desc);
|
||||
|
||||
alter table public.commerce_refund_requests enable row level security;
|
||||
alter table public.commerce_refund_events enable row level security;
|
||||
|
||||
drop policy if exists tenant_isolation on public.commerce_refund_requests;
|
||||
create policy tenant_isolation on public.commerce_refund_requests
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop policy if exists tenant_isolation on public.commerce_refund_events;
|
||||
create policy tenant_isolation on public.commerce_refund_events
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop trigger if exists set_updated_at on public.commerce_refund_requests;
|
||||
create trigger set_updated_at
|
||||
before update on public.commerce_refund_requests
|
||||
for each row execute function app.touch_updated_at();
|
||||
Reference in New Issue
Block a user