feat: add commerce refund workflow

This commit is contained in:
Codex
2026-06-29 04:53:12 +08:00
parent ead1296f80
commit 24fd788b48
13 changed files with 977 additions and 13 deletions

View File

@@ -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、积分活动深化以及排行榜防刷/预聚合。

View File

@@ -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],

View File

@@ -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(
`

View File

@@ -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);
}

View File

@@ -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: '密钥掩码查看' },

View File

@@ -18,7 +18,7 @@ apps/api/src/
tenant/ 租户解析、品牌配置、域名识别
catalog/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口
learning/ 组卷 session、答题、错题、收藏、练习进度、排行榜
commerce/ 订单、支付确认、激活码、优惠券、权益
commerce/ 订单、支付确认、退款、激活码、优惠券、权益
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
storage/ 对象存储签名 provider
video/ 题目视频列表、搜索、SVIP/次数校验和签名播放

View File

@@ -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 |
## 租户后台与平台后台

View File

@@ -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/VaultAPI 只返回 `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` 抽到跨端包或适配层。

View File

@@ -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 报告 |

View File

@@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -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 后续接入。前端不要假设点击退款后已经实时调用支付平台。
### 激活码预检查与兑换
兑换前建议先调用:

View File

@@ -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 },

View 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();