From 24fd788b482ef1303055e17c5468fff18d3a105f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 04:53:12 +0800 Subject: [PATCH] feat: add commerce refund workflow --- README.md | 5 +- apps/api/src/features/commerce/index.ts | 6 + apps/api/src/features/commerce/routes.ts | 631 +++++++++++++++++- apps/api/src/features/commerce/service.ts | 4 + apps/api/src/features/tenant-admin/auth.ts | 3 + docs/refactor/api-structure.md | 2 +- docs/refactor/backend-capability-status.md | 3 +- docs/refactor/backend-progress.md | 3 +- docs/refactor/legacy-feature-gap-matrix.md | 2 +- docs/refactor/next-development-todo.md | 9 +- docs/refactor/taro-frontend-integration.md | 54 ++ scripts/api-integration-test.js | 164 +++++ .../202606290011_commerce_refunds.sql | 104 +++ 13 files changed, 977 insertions(+), 13 deletions(-) create mode 100644 supabase/migrations/202606290011_commerce_refunds.sql diff --git a/README.md b/README.md index fb177fae..efda0412 100644 --- a/README.md +++ b/README.md @@ -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、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/commerce/index.ts b/apps/api/src/features/commerce/index.ts index a544c301..d9166854 100644 --- a/apps/api/src/features/commerce/index.ts +++ b/apps/api/src/features/commerce/index.ts @@ -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], diff --git a/apps/api/src/features/commerce/routes.ts b/apps/api/src/features/commerce/routes.ts index 140ed60c..e007d4d2 100644 --- a/apps/api/src/features/commerce/routes.ts +++ b/apps/api/src/features/commerce/routes.ts @@ -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; 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; + 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 { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} + +function optionalChoice(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; + }, +) { + 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; + }, +) { + 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( + ` + 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( + ` + 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( + ` + 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( + ` + 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( + ` + 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 = {}; + const details: Record = { 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( + ` + 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( ` diff --git a/apps/api/src/features/commerce/service.ts b/apps/api/src/features/commerce/service.ts index b5a1ba77..eaf2f9f4 100644 --- a/apps/api/src/features/commerce/service.ts +++ b/apps/api/src/features/commerce/service.ts @@ -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); +} diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 46aade46..84c249c4 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -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: '密钥掩码查看' }, diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md index a5827e9f..25d696e6 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -18,7 +18,7 @@ apps/api/src/ tenant/ 租户解析、品牌配置、域名识别 catalog/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口 learning/ 组卷 session、答题、错题、收藏、练习进度、排行榜 - commerce/ 订单、支付确认、激活码、优惠券、权益 + commerce/ 订单、支付确认、退款、激活码、优惠券、权益 referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列 storage/ 对象存储签名 provider video/ 题目视频列表、搜索、SVIP/次数校验和签名播放 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 17519454..4c3548b3 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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 | ## 租户后台与平台后台 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index c1d04214..a03e6415 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -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` 抽到跨端包或适配层。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 96633c93..ce0ec1c8 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -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 报告 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 356ee800..ed9e8c8b 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index eb0f89e2..bba04057 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -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= +权限:tenant:refund:read +``` + +创建退款申请: + +```text +POST /api/commerce/refunds +权限:tenant:refund:write +body: { + "orderNo": "", + "refundNo": "<可选,前端幂等键>", + "amountCents": 500, + "reason": "用户协商退款", + "entitlementAction": "revoke_on_success | none" +} +``` + +退款状态流转: + +```text +POST /api/commerce/refunds/status +body: { + "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 后续接入。前端不要假设点击退款后已经实时调用支付平台。 + ### 激活码预检查与兑换 兑换前建议先调用: diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 678565ca..8aea0c2a 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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 }, diff --git a/supabase/migrations/202606290011_commerce_refunds.sql b/supabase/migrations/202606290011_commerce_refunds.sql new file mode 100644 index 00000000..eaed8aab --- /dev/null +++ b/supabase/migrations/202606290011_commerce_refunds.sql @@ -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();