From 7258e4a7d56b2c4ad54f1084726ab422e7ee3f47 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 18:46:38 +0800 Subject: [PATCH] feat: add commerce reconciliation ledger --- README.md | 9 +- apps/api/src/features/commerce/index.ts | 12 + .../src/features/commerce/reconciliation.ts | 1165 +++++++++++++++++ apps/api/src/features/tenant-admin/auth.ts | 2 + docs/refactor/auth-payment-provider-plan.md | 17 + docs/refactor/backend-capability-status.md | 3 +- docs/refactor/backend-handoff-roadmap.md | 4 +- docs/refactor/backend-progress.md | 4 +- docs/refactor/blueprint-coverage.md | 2 +- docs/refactor/legacy-feature-gap-matrix.md | 2 +- docs/refactor/next-development-todo.md | 7 +- docs/refactor/taro-frontend-integration.md | 72 +- scripts/api-integration-test.js | 171 +++ .../202606290022_commerce_reconciliation.sql | 100 ++ 14 files changed, 1555 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/features/commerce/reconciliation.ts create mode 100644 supabase/migrations/202606290022_commerce_reconciliation.sql diff --git a/README.md b/README.md index 94028ee6..000d7e8b 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,14 @@ - `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 +- 资金对账基础闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志。 - PocketBase schema/数据导入器雏形和导入后校验脚本。 - 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。 还没有达到生产交付的部分: - Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 -- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;完整资金流水对账和真实生产账号联调还没接完。 +- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对,微信/支付宝官方账单自动下载、差错处理工单和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界和资源复检 worker 已完成,杀毒扫描、视频动态水印、CDN 刷新和对象生命周期策略还没完成。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 @@ -277,7 +278,7 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失 apps/api/src/features/ auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录 catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口 - commerce/ 订单、支付确认、激活码、优惠券、权益 + commerce/ 订单、支付确认、退款、激活码、优惠券、权益、资金对账 health/ 健康检查 learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜 platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量 @@ -308,7 +309,7 @@ API 身份上下文: - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。 - 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。 -- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。 +- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定期通过资金对账接口导入供应商账单核对本地订单。 ## 最近一次验证 @@ -337,4 +338,4 @@ git diff --check 2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。 3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 -5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 +5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载与差错处理台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/commerce/index.ts b/apps/api/src/features/commerce/index.ts index ebf2478e..195fda6c 100644 --- a/apps/api/src/features/commerce/index.ts +++ b/apps/api/src/features/commerce/index.ts @@ -17,6 +17,13 @@ import { refundNotifyRoute, updateRefundStatusRoute, } from './routes.js'; +import { + importReconciliationRoute, + previewReconciliationRoute, + reconciliationAnomaliesRoute, + reconciliationBatchesRoute, + reconciliationItemsRoute, +} from './reconciliation.js'; export const commerceRoutes: RouteDefinition[] = [ ['POST', '/api/commerce/orders', createOrderRoute], @@ -36,6 +43,11 @@ export const commerceRoutes: RouteDefinition[] = [ ['POST', '/api/commerce/refunds/notify/wechat_pay', refundNotifyRoute], ['POST', '/api/commerce/refunds/notify/wechat-pay', refundNotifyRoute], ['POST', '/api/commerce/refunds/notify/alipay', refundNotifyRoute], + ['POST', '/api/commerce/reconciliation/preview', previewReconciliationRoute], + ['POST', '/api/commerce/reconciliation/import', importReconciliationRoute], + ['GET', '/api/commerce/reconciliation/batches', reconciliationBatchesRoute], + ['GET', '/api/commerce/reconciliation/items', reconciliationItemsRoute], + ['GET', '/api/commerce/reconciliation/anomalies', reconciliationAnomaliesRoute], ['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute], ['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute], ['POST', '/api/commerce/coupons/claim', claimCouponRoute], diff --git a/apps/api/src/features/commerce/reconciliation.ts b/apps/api/src/features/commerce/reconciliation.ts new file mode 100644 index 00000000..adc3f3b2 --- /dev/null +++ b/apps/api/src/features/commerce/reconciliation.ts @@ -0,0 +1,1165 @@ +import crypto from 'node:crypto'; +import type pg from 'pg'; +import { config } from '../../core/config.js'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, optionalInteger, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js'; +import { query, transaction } from '../../core/db.js'; +import { requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js'; + +type ReconciliationProvider = 'wechat_pay' | 'alipay' | 'manual'; +type BillType = 'payment' | 'refund' | 'combined'; +type ReconciliationSource = 'manual_upload' | 'provider_download' | 'api' | 'worker'; +type TransactionType = 'payment' | 'refund'; +type MatchStatus = + | 'matched' + | 'amount_mismatch' + | 'status_mismatch' + | 'missing_local' + | 'missing_provider' + | 'duplicate' + | 'ignored'; +type Severity = 'info' | 'warning' | 'error' | 'critical'; + +const BILL_TYPES = new Set(['payment', 'refund', 'combined']); +const SOURCES = new Set(['manual_upload', 'provider_download', 'api', 'worker']); +const PROVIDER_SUCCESS_STATUSES = new Set([ + 'success', + 'succeeded', + 'paid', + 'trade_success', + 'trade_finished', + 'refund_success', + 'finished', + 'completed', +]); +const LOCAL_PAID_PAYMENT_STATUSES = new Set(['paid', 'partially_refunded', 'refunded']); +const LOCAL_SUCCEEDED_REFUND_STATUSES = new Set(['succeeded']); + +interface RawBillRow { + [key: string]: unknown; +} + +interface NormalizedBillRow { + rowNo: number; + transactionType: TransactionType; + provider: ReconciliationProvider; + providerTradeNo: string | null; + providerRefundNo: string | null; + orderNo: string | null; + refundNo: string | null; + amountCents: number; + refundAmountCents: number; + feeCents: number; + paidAt: string | null; + refundedAt: string | null; + providerStatus: string | null; + raw: RawBillRow; + invalidIssue?: { + code: string; + message: string; + }; +} + +interface LocalPaymentMatch { + orderId: string; + orderNo: string; + orderStatus: string; + orderAmountCents: number; + orderRefundedAmountCents: number; + paymentId: string; + paymentStatus: string; + paymentAmountCents: number; + paymentRefundedAmountCents: number; + paymentProvider: string; + providerTradeNo: string | null; + paidAt: string | null; +} + +interface LocalRefundMatch { + orderId: string; + orderNo: string; + orderStatus: string; + refundRequestId: string; + refundNo: string; + refundStatus: string; + amountCents: number; + provider: string | null; + providerRefundNo: string | null; + paymentId: string | null; + succeededAt: string | null; +} + +interface ReconciliationItemDraft { + rowNo: number; + provider: ReconciliationProvider; + transactionType: TransactionType; + providerTradeNo: string | null; + providerRefundNo: string | null; + orderNo: string | null; + refundNo: string | null; + amountCents: number; + refundAmountCents: number; + feeCents: number; + paidAt: string | null; + refundedAt: string | null; + providerStatus: string | null; + localStatus: string | null; + orderId: string | null; + paymentId: string | null; + refundRequestId: string | null; + matchStatus: MatchStatus; + severity: Severity; + issueCode: string | null; + details: Record; +} + +interface ReconciliationBuildResult { + batch: { + provider: ReconciliationProvider; + billDate: string; + billType: BillType; + source: ReconciliationSource; + sourceName: string | null; + sourceHash: string; + status: 'preview' | 'completed' | 'completed_with_issues'; + totalCount: number; + matchedCount: number; + mismatchCount: number; + missingLocalCount: number; + missingProviderCount: number; + duplicateCount: number; + ignoredCount: number; + amountCents: number; + refundAmountCents: number; + feeCents: number; + }; + items: ReconciliationItemDraft[]; +} + +function normalizeProvider(value: string): ReconciliationProvider { + const normalized = value.trim().toLowerCase().replace(/[-\s]/g, '_'); + if (['wechat', 'wechatpay', 'wxpay', 'wx_pay', 'wechat_pay'].includes(normalized)) return 'wechat_pay'; + if (['alipay', 'ali_pay'].includes(normalized)) return 'alipay'; + if (normalized === 'manual') return 'manual'; + throw new HttpError(400, 'Payment provider is not supported for reconciliation', 'RECONCILIATION_PROVIDER_UNSUPPORTED'); +} + +function providerAliases(provider: ReconciliationProvider) { + if (provider === 'wechat_pay') return ['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay']; + if (provider === 'alipay') return ['alipay', 'ali_pay']; + return ['manual']; +} + +function normalizeBillType(value: unknown): BillType { + if (typeof value !== 'string' || !value.trim()) return 'combined'; + const normalized = value.trim().toLowerCase(); + if (BILL_TYPES.has(normalized as BillType)) return normalized as BillType; + throw new HttpError(400, 'billType is invalid', 'RECONCILIATION_BILL_TYPE_INVALID'); +} + +function normalizeSource(value: unknown): ReconciliationSource { + if (typeof value !== 'string' || !value.trim()) return 'manual_upload'; + const normalized = value.trim().toLowerCase(); + if (SOURCES.has(normalized as ReconciliationSource)) return normalized as ReconciliationSource; + throw new HttpError(400, 'source is invalid', 'RECONCILIATION_SOURCE_INVALID'); +} + +function normalizeBillDate(value: string) { + const trimmed = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + throw new HttpError(400, 'billDate must be YYYY-MM-DD', 'RECONCILIATION_BILL_DATE_INVALID'); + } + const date = new Date(`${trimmed}T00:00:00.000Z`); + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== trimmed) { + throw new HttpError(400, 'billDate is invalid', 'RECONCILIATION_BILL_DATE_INVALID'); + } + return trimmed; +} + +function objectValue(value: unknown): RawBillRow { + return value && typeof value === 'object' && !Array.isArray(value) ? value as RawBillRow : {}; +} + +function optionalRowString(row: RawBillRow, keys: string[]) { + for (const key of keys) { + const value = row[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return null; +} + +function dateString(row: RawBillRow, keys: string[]) { + const value = optionalRowString(row, keys); + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString(); +} + +function centsValue(row: RawBillRow, centKeys: string[], yuanKeys: string[], fallback = 0) { + for (const key of centKeys) { + const value = row[key]; + const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN; + if (Number.isFinite(parsed)) return Math.max(0, Math.round(parsed)); + } + for (const key of yuanKeys) { + const value = row[key]; + const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN; + if (Number.isFinite(parsed)) return Math.max(0, Math.round(parsed * 100)); + } + return fallback; +} + +function feeValue(row: RawBillRow) { + const cents = centsValue(row, ['feeCents', 'fee_cents', 'serviceFeeCents', 'service_fee_cents'], [], Number.NaN); + if (Number.isFinite(cents)) return cents; + for (const key of ['feeYuan', 'fee_yuan', 'serviceFee', 'service_fee']) { + const value = row[key]; + const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN; + if (Number.isFinite(parsed)) return Math.round(parsed * 100); + } + return 0; +} + +function normalizeTransactionType(value: unknown, billType: BillType): TransactionType | null { + if (typeof value !== 'string' || !value.trim()) { + return billType === 'refund' ? 'refund' : 'payment'; + } + const normalized = value.trim().toLowerCase().replace(/[-\s]/g, '_'); + if (['payment', 'pay', 'trade', 'transaction', 'income'].includes(normalized)) return 'payment'; + if (['refund', 'refundment', 'return'].includes(normalized)) return 'refund'; + return null; +} + +function normalizeProviderStatus(value: string | null) { + return value ? value.trim().toLowerCase().replace(/[-\s]/g, '_') : null; +} + +function providerStatusSucceeded(status: string | null) { + if (!status) return true; + return PROVIDER_SUCCESS_STATUSES.has(status); +} + +function emptyItemFromInvalidRow(rowNo: number, provider: ReconciliationProvider, billType: BillType, raw: RawBillRow, code: string, message: string): NormalizedBillRow { + return { + rowNo, + transactionType: billType === 'refund' ? 'refund' : 'payment', + provider, + providerTradeNo: null, + providerRefundNo: null, + orderNo: null, + refundNo: null, + amountCents: 0, + refundAmountCents: 0, + feeCents: 0, + paidAt: null, + refundedAt: null, + providerStatus: null, + raw, + invalidIssue: { code, message }, + }; +} + +function normalizeRows(provider: ReconciliationProvider, billType: BillType, rows: unknown[]): NormalizedBillRow[] { + return rows.map((value, index) => { + const rowNo = index + 1; + const row = objectValue(value); + if (Object.keys(row).length === 0) { + return emptyItemFromInvalidRow(rowNo, provider, billType, row, 'INVALID_ROW', 'row must be an object'); + } + + const transactionType = normalizeTransactionType( + row.transactionType ?? row.transaction_type ?? row.type ?? row.billType ?? row.bill_type, + billType, + ); + if (!transactionType) { + return emptyItemFromInvalidRow(rowNo, provider, billType, row, 'TRANSACTION_TYPE_INVALID', 'transaction type is invalid'); + } + if (billType !== 'combined' && billType !== transactionType) { + return emptyItemFromInvalidRow(rowNo, provider, billType, row, 'TRANSACTION_TYPE_MISMATCH', 'row type does not match billType'); + } + + const providerTradeNo = optionalRowString(row, ['providerTradeNo', 'provider_trade_no', 'transactionId', 'transaction_id', 'tradeNo', 'trade_no']); + const providerRefundNo = optionalRowString(row, ['providerRefundNo', 'provider_refund_no', 'refundId', 'refund_id']); + const orderNo = optionalRowString(row, ['orderNo', 'order_no', 'outTradeNo', 'out_trade_no', 'merchantOrderNo', 'merchant_order_no']); + const refundNo = optionalRowString(row, ['refundNo', 'refund_no', 'outRefundNo', 'out_refund_no', 'outRequestNo', 'out_request_no']); + const providerStatus = normalizeProviderStatus(optionalRowString(row, ['providerStatus', 'provider_status', 'status', 'tradeStatus', 'trade_status', 'refundStatus', 'refund_status'])); + + if (transactionType === 'payment' && !providerTradeNo && !orderNo) { + return emptyItemFromInvalidRow(rowNo, provider, billType, row, 'PAYMENT_IDENTITY_MISSING', 'payment row requires providerTradeNo or orderNo'); + } + if (transactionType === 'refund' && !providerRefundNo && !refundNo && !orderNo) { + return emptyItemFromInvalidRow(rowNo, provider, billType, row, 'REFUND_IDENTITY_MISSING', 'refund row requires providerRefundNo, refundNo, or orderNo'); + } + + const paymentAmountCents = centsValue( + row, + ['amountCents', 'amount_cents', 'totalAmountCents', 'total_amount_cents', 'paidAmountCents', 'paid_amount_cents'], + ['amountYuan', 'amount_yuan', 'totalAmount', 'total_amount', 'paidAmount', 'paid_amount'], + ); + const refundAmountCents = centsValue( + row, + ['refundAmountCents', 'refund_amount_cents'], + ['refundAmount', 'refund_amount', 'refundFee', 'refund_fee'], + transactionType === 'refund' ? paymentAmountCents : 0, + ); + + return { + rowNo, + transactionType, + provider, + providerTradeNo, + providerRefundNo, + orderNo, + refundNo, + amountCents: transactionType === 'payment' ? paymentAmountCents : 0, + refundAmountCents: transactionType === 'refund' ? refundAmountCents : 0, + feeCents: feeValue(row), + paidAt: dateString(row, ['paidAt', 'paid_at', 'successTime', 'success_time', 'payTime', 'pay_time']), + refundedAt: dateString(row, ['refundedAt', 'refunded_at', 'refundSuccessTime', 'refund_success_time', 'refundTime', 'refund_time']), + providerStatus, + raw: row, + }; + }); +} + +function reconciliationHash(input: { + provider: ReconciliationProvider; + billDate: string; + billType: BillType; + sourceName: string | null; + rows: NormalizedBillRow[]; +}) { + return crypto + .createHash('sha256') + .update(JSON.stringify({ + provider: input.provider, + billDate: input.billDate, + billType: input.billType, + sourceName: input.sourceName, + rows: input.rows.map(row => ({ + rowNo: row.rowNo, + transactionType: row.transactionType, + providerTradeNo: row.providerTradeNo, + providerRefundNo: row.providerRefundNo, + orderNo: row.orderNo, + refundNo: row.refundNo, + amountCents: row.amountCents, + refundAmountCents: row.refundAmountCents, + feeCents: row.feeCents, + paidAt: row.paidAt, + refundedAt: row.refundedAt, + providerStatus: row.providerStatus, + invalidIssue: row.invalidIssue || null, + })), + })) + .digest('hex'); +} + +function rowIdentity(row: NormalizedBillRow) { + if (row.invalidIssue) return `invalid:${row.rowNo}`; + if (row.transactionType === 'payment') { + return `payment:${row.providerTradeNo || ''}:${row.orderNo || ''}`; + } + return `refund:${row.providerRefundNo || ''}:${row.refundNo || ''}:${row.orderNo || ''}`; +} + +async function findPaymentMatch(client: pg.PoolClient, tenantId: string, provider: ReconciliationProvider, row: NormalizedBillRow) { + const aliases = providerAliases(provider); + const result = await client.query( + ` + select o.id as "orderId", o.order_no as "orderNo", o.status as "orderStatus", + o.amount_cents as "orderAmountCents", o.refunded_amount_cents as "orderRefundedAmountCents", + p.id as "paymentId", p.status as "paymentStatus", p.amount_cents as "paymentAmountCents", + p.refunded_amount_cents as "paymentRefundedAmountCents", p.provider as "paymentProvider", + p.provider_trade_no as "providerTradeNo", p.paid_at as "paidAt" + from public.payments p + join public.orders o on o.tenant_id = p.tenant_id and o.id = p.order_id + where p.tenant_id = $1 + and p.provider = any($2::text[]) + and ( + ($3::text is not null and p.provider_trade_no = $3) + or ($4::text is not null and o.order_no = $4) + ) + order by + case when $3::text is not null and p.provider_trade_no = $3 then 0 else 1 end, + p.updated_at desc + limit 1 + `, + [tenantId, aliases, row.providerTradeNo, row.orderNo], + ); + return result.rows[0] || null; +} + +async function findRefundMatch(client: pg.PoolClient, tenantId: string, provider: ReconciliationProvider, row: NormalizedBillRow) { + const aliases = providerAliases(provider); + const result = await client.query( + ` + select o.id as "orderId", o.order_no as "orderNo", o.status as "orderStatus", + rr.id as "refundRequestId", rr.refund_no as "refundNo", rr.status as "refundStatus", + rr.amount_cents as "amountCents", rr.provider, rr.provider_refund_no as "providerRefundNo", + rr.payment_id as "paymentId", rr.succeeded_at as "succeededAt" + 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.provider is null or rr.provider = any($2::text[])) + and ( + ($3::text is not null and rr.provider_refund_no = $3) + or ($4::text is not null and rr.refund_no = $4) + or ($5::text is not null and o.order_no = $5) + ) + order by + case when $3::text is not null and rr.provider_refund_no = $3 then 0 + when $4::text is not null and rr.refund_no = $4 then 1 + else 2 end, + rr.updated_at desc + limit 1 + `, + [tenantId, aliases, row.providerRefundNo, row.refundNo, row.orderNo], + ); + return result.rows[0] || null; +} + +function paymentDraft(row: NormalizedBillRow, local: LocalPaymentMatch | null): ReconciliationItemDraft { + if (!local) { + return { + ...baseDraft(row), + localStatus: null, + orderId: null, + paymentId: null, + refundRequestId: null, + matchStatus: 'missing_local', + severity: 'critical', + issueCode: 'PAYMENT_MISSING_LOCAL', + details: { rawRow: row.raw }, + }; + } + + const localStatus = local.paymentStatus; + const expectedAmountCents = row.amountCents; + const amountMatches = expectedAmountCents === local.paymentAmountCents; + const statusMatches = providerStatusSucceeded(row.providerStatus) + ? LOCAL_PAID_PAYMENT_STATUSES.has(local.paymentStatus) + : !LOCAL_PAID_PAYMENT_STATUSES.has(local.paymentStatus); + const matchStatus: MatchStatus = !amountMatches ? 'amount_mismatch' : !statusMatches ? 'status_mismatch' : 'matched'; + return { + ...baseDraft(row), + localStatus, + orderId: local.orderId, + paymentId: local.paymentId, + refundRequestId: null, + matchStatus, + severity: matchStatus === 'matched' ? 'info' : 'error', + issueCode: matchStatus === 'matched' ? null : matchStatus === 'amount_mismatch' ? 'PAYMENT_AMOUNT_MISMATCH' : 'PAYMENT_STATUS_MISMATCH', + details: { + rawRow: row.raw, + local: { + orderNo: local.orderNo, + orderStatus: local.orderStatus, + orderAmountCents: local.orderAmountCents, + paymentStatus: local.paymentStatus, + paymentAmountCents: local.paymentAmountCents, + providerTradeNo: local.providerTradeNo, + paidAt: local.paidAt, + }, + }, + }; +} + +function refundDraft(row: NormalizedBillRow, local: LocalRefundMatch | null): ReconciliationItemDraft { + if (!local) { + return { + ...baseDraft(row), + localStatus: null, + orderId: null, + paymentId: null, + refundRequestId: null, + matchStatus: 'missing_local', + severity: 'critical', + issueCode: 'REFUND_MISSING_LOCAL', + details: { rawRow: row.raw }, + }; + } + + const amountMatches = row.refundAmountCents === local.amountCents; + const statusMatches = providerStatusSucceeded(row.providerStatus) + ? LOCAL_SUCCEEDED_REFUND_STATUSES.has(local.refundStatus) + : !LOCAL_SUCCEEDED_REFUND_STATUSES.has(local.refundStatus); + const matchStatus: MatchStatus = !amountMatches ? 'amount_mismatch' : !statusMatches ? 'status_mismatch' : 'matched'; + return { + ...baseDraft(row), + localStatus: local.refundStatus, + orderId: local.orderId, + paymentId: local.paymentId, + refundRequestId: local.refundRequestId, + matchStatus, + severity: matchStatus === 'matched' ? 'info' : 'error', + issueCode: matchStatus === 'matched' ? null : matchStatus === 'amount_mismatch' ? 'REFUND_AMOUNT_MISMATCH' : 'REFUND_STATUS_MISMATCH', + details: { + rawRow: row.raw, + local: { + orderNo: local.orderNo, + orderStatus: local.orderStatus, + refundNo: local.refundNo, + providerRefundNo: local.providerRefundNo, + refundStatus: local.refundStatus, + amountCents: local.amountCents, + succeededAt: local.succeededAt, + }, + }, + }; +} + +function baseDraft(row: NormalizedBillRow): Omit { + return { + rowNo: row.rowNo, + provider: row.provider, + transactionType: row.transactionType, + providerTradeNo: row.providerTradeNo, + providerRefundNo: row.providerRefundNo, + orderNo: row.orderNo, + refundNo: row.refundNo, + amountCents: row.amountCents, + refundAmountCents: row.refundAmountCents, + feeCents: row.feeCents, + paidAt: row.paidAt, + refundedAt: row.refundedAt, + providerStatus: row.providerStatus, + }; +} + +function invalidDraft(row: NormalizedBillRow): ReconciliationItemDraft { + return { + ...baseDraft(row), + localStatus: null, + orderId: null, + paymentId: null, + refundRequestId: null, + matchStatus: 'ignored', + severity: 'error', + issueCode: row.invalidIssue?.code || 'INVALID_ROW', + details: { message: row.invalidIssue?.message || 'row is invalid', rawRow: row.raw }, + }; +} + +async function missingProviderPaymentDrafts( + client: pg.PoolClient, + tenantId: string, + provider: ReconciliationProvider, + billDate: string, + seenPaymentIds: Set, + startRowNo: number, +) { + const rows = await client.query( + ` + select o.id as "orderId", o.order_no as "orderNo", o.status as "orderStatus", + o.amount_cents as "orderAmountCents", o.refunded_amount_cents as "orderRefundedAmountCents", + p.id as "paymentId", p.status as "paymentStatus", p.amount_cents as "paymentAmountCents", + p.refunded_amount_cents as "paymentRefundedAmountCents", p.provider as "paymentProvider", + p.provider_trade_no as "providerTradeNo", p.paid_at as "paidAt" + from public.payments p + join public.orders o on o.tenant_id = p.tenant_id and o.id = p.order_id + where p.tenant_id = $1 + and p.provider = any($2::text[]) + and p.status in ('paid', 'partially_refunded', 'refunded') + and (p.paid_at at time zone 'Asia/Shanghai')::date = $3::date + order by p.paid_at asc nulls last, p.created_at asc + limit 1000 + `, + [tenantId, providerAliases(provider), billDate], + ); + let rowNo = startRowNo; + return rows.rows + .filter(row => !seenPaymentIds.has(row.paymentId)) + .map(row => ({ + rowNo: rowNo++, + provider, + transactionType: 'payment' as const, + providerTradeNo: row.providerTradeNo, + providerRefundNo: null, + orderNo: row.orderNo, + refundNo: null, + amountCents: row.paymentAmountCents, + refundAmountCents: 0, + feeCents: 0, + paidAt: row.paidAt, + refundedAt: null, + providerStatus: null, + localStatus: row.paymentStatus, + orderId: row.orderId, + paymentId: row.paymentId, + refundRequestId: null, + matchStatus: 'missing_provider' as const, + severity: 'error' as const, + issueCode: 'PAYMENT_MISSING_PROVIDER', + details: { + local: { + orderNo: row.orderNo, + orderStatus: row.orderStatus, + paymentStatus: row.paymentStatus, + paymentAmountCents: row.paymentAmountCents, + providerTradeNo: row.providerTradeNo, + paidAt: row.paidAt, + }, + }, + })); +} + +async function missingProviderRefundDrafts( + client: pg.PoolClient, + tenantId: string, + provider: ReconciliationProvider, + billDate: string, + seenRefundRequestIds: Set, + startRowNo: number, +) { + const rows = await client.query( + ` + select o.id as "orderId", o.order_no as "orderNo", o.status as "orderStatus", + rr.id as "refundRequestId", rr.refund_no as "refundNo", rr.status as "refundStatus", + rr.amount_cents as "amountCents", rr.provider, rr.provider_refund_no as "providerRefundNo", + rr.payment_id as "paymentId", rr.succeeded_at as "succeededAt" + 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.provider is null or rr.provider = any($2::text[])) + and rr.status = 'succeeded' + and (rr.succeeded_at at time zone 'Asia/Shanghai')::date = $3::date + order by rr.succeeded_at asc nulls last, rr.created_at asc + limit 1000 + `, + [tenantId, providerAliases(provider), billDate], + ); + let rowNo = startRowNo; + return rows.rows + .filter(row => !seenRefundRequestIds.has(row.refundRequestId)) + .map(row => ({ + rowNo: rowNo++, + provider, + transactionType: 'refund' as const, + providerTradeNo: null, + providerRefundNo: row.providerRefundNo, + orderNo: row.orderNo, + refundNo: row.refundNo, + amountCents: 0, + refundAmountCents: row.amountCents, + feeCents: 0, + paidAt: null, + refundedAt: row.succeededAt, + providerStatus: null, + localStatus: row.refundStatus, + orderId: row.orderId, + paymentId: row.paymentId, + refundRequestId: row.refundRequestId, + matchStatus: 'missing_provider' as const, + severity: 'error' as const, + issueCode: 'REFUND_MISSING_PROVIDER', + details: { + local: { + orderNo: row.orderNo, + orderStatus: row.orderStatus, + refundNo: row.refundNo, + providerRefundNo: row.providerRefundNo, + refundStatus: row.refundStatus, + amountCents: row.amountCents, + succeededAt: row.succeededAt, + }, + }, + })); +} + +async function buildReconciliation( + client: pg.PoolClient, + input: { + tenantId: string; + provider: ReconciliationProvider; + billDate: string; + billType: BillType; + source: ReconciliationSource; + sourceName: string | null; + rows: NormalizedBillRow[]; + }, +): Promise { + const items: ReconciliationItemDraft[] = []; + const seenKeys = new Set(); + const seenPaymentIds = new Set(); + const seenRefundRequestIds = new Set(); + + for (const row of input.rows) { + if (row.invalidIssue) { + items.push(invalidDraft(row)); + continue; + } + + const identity = rowIdentity(row); + if (seenKeys.has(identity)) { + items.push({ + ...baseDraft(row), + localStatus: null, + orderId: null, + paymentId: null, + refundRequestId: null, + matchStatus: 'duplicate', + severity: 'warning', + issueCode: 'DUPLICATE_PROVIDER_ROW', + details: { rawRow: row.raw, identity }, + }); + continue; + } + seenKeys.add(identity); + + if (row.transactionType === 'payment') { + const local = await findPaymentMatch(client, input.tenantId, input.provider, row); + if (local) seenPaymentIds.add(local.paymentId); + items.push(paymentDraft(row, local)); + } else { + const local = await findRefundMatch(client, input.tenantId, input.provider, row); + if (local) seenRefundRequestIds.add(local.refundRequestId); + items.push(refundDraft(row, local)); + } + } + + if (input.billType !== 'refund') { + const missingPayments = await missingProviderPaymentDrafts(client, input.tenantId, input.provider, input.billDate, seenPaymentIds, items.length + 1); + items.push(...missingPayments); + } + if (input.billType !== 'payment') { + const missingRefunds = await missingProviderRefundDrafts(client, input.tenantId, input.provider, input.billDate, seenRefundRequestIds, items.length + 1); + items.push(...missingRefunds); + } + + const matchedCount = items.filter(item => item.matchStatus === 'matched').length; + const mismatchCount = items.filter(item => item.matchStatus === 'amount_mismatch' || item.matchStatus === 'status_mismatch').length; + const missingLocalCount = items.filter(item => item.matchStatus === 'missing_local').length; + const missingProviderCount = items.filter(item => item.matchStatus === 'missing_provider').length; + const duplicateCount = items.filter(item => item.matchStatus === 'duplicate').length; + const ignoredCount = items.filter(item => item.matchStatus === 'ignored').length; + const hasIssues = mismatchCount + missingLocalCount + missingProviderCount + duplicateCount + ignoredCount > 0; + const sourceHash = reconciliationHash({ + provider: input.provider, + billDate: input.billDate, + billType: input.billType, + sourceName: input.sourceName, + rows: input.rows, + }); + + return { + batch: { + provider: input.provider, + billDate: input.billDate, + billType: input.billType, + source: input.source, + sourceName: input.sourceName, + sourceHash, + status: hasIssues ? 'completed_with_issues' : 'completed', + totalCount: items.length, + matchedCount, + mismatchCount, + missingLocalCount, + missingProviderCount, + duplicateCount, + ignoredCount, + amountCents: items.reduce((sum, item) => sum + item.amountCents, 0), + refundAmountCents: items.reduce((sum, item) => sum + item.refundAmountCents, 0), + feeCents: items.reduce((sum, item) => sum + item.feeCents, 0), + }, + items, + }; +} + +function readReconciliationInput(body: Record) { + const provider = normalizeProvider(requiredString(body, 'provider')); + const billDate = normalizeBillDate(requiredString(body, 'billDate')); + const billType = normalizeBillType(body.billType ?? body.bill_type); + const source = normalizeSource(body.source); + const sourceName = optionalString(body, 'sourceName') || optionalString(body, 'source_name') || null; + const rawRows = body.rows; + if (!Array.isArray(rawRows)) { + throw new HttpError(400, 'rows must be an array', 'RECONCILIATION_ROWS_REQUIRED'); + } + if (rawRows.length > 5000) { + throw new HttpError(413, 'Too many reconciliation rows. Max 5000.', 'RECONCILIATION_ROWS_TOO_MANY'); + } + const rows = normalizeRows(provider, billType, rawRows); + return { provider, billDate, billType, source, sourceName, rows }; +} + +function batchPayload(row: Record) { + return { + id: row.id, + provider: row.provider, + billDate: row.billDate, + billType: row.billType, + source: row.source, + sourceName: row.sourceName, + sourceHash: row.sourceHash, + status: row.status, + totalCount: row.totalCount, + matchedCount: row.matchedCount, + mismatchCount: row.mismatchCount, + missingLocalCount: row.missingLocalCount, + missingProviderCount: row.missingProviderCount, + duplicateCount: row.duplicateCount, + ignoredCount: row.ignoredCount, + amountCents: row.amountCents, + refundAmountCents: row.refundAmountCents, + feeCents: row.feeCents, + createdBy: row.createdBy, + completedAt: row.completedAt, + error: row.error, + metadata: row.metadata || {}, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function itemPayload(row: Record) { + return { + id: row.id, + batchId: row.batchId, + rowNo: row.rowNo, + provider: row.provider, + transactionType: row.transactionType, + providerTradeNo: row.providerTradeNo, + providerRefundNo: row.providerRefundNo, + orderNo: row.orderNo, + refundNo: row.refundNo, + amountCents: row.amountCents, + refundAmountCents: row.refundAmountCents, + feeCents: row.feeCents, + paidAt: row.paidAt, + refundedAt: row.refundedAt, + providerStatus: row.providerStatus, + localStatus: row.localStatus, + orderId: row.orderId, + paymentId: row.paymentId, + refundRequestId: row.refundRequestId, + matchStatus: row.matchStatus, + severity: row.severity, + issueCode: row.issueCode, + details: row.details || {}, + createdAt: row.createdAt, + }; +} + +async function recordReconciliationAudit(client: pg.PoolClient, auth: TenantAdminAuth, action: 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, 'commerce_reconciliation_batch', $4, $5::jsonb) + `, + [auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)], + ); +} + +async function authorizeRead(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:reconciliation:read'); + return auth; +} + +async function authorizeWrite(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'tenant:reconciliation:write'); + return auth; +} + +export async function previewReconciliationRoute(ctx: RequestContext) { + const auth = await authorizeRead(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); + const input = readReconciliationInput(body); + const result = await transaction(client => buildReconciliation(client, { tenantId: auth.tenantId, ...input })); + return { + item: { + ...result.batch, + status: 'preview', + }, + items: result.items.slice(0, optionalInteger(body, 'previewLimit', 200)).map(item => ({ + ...item, + id: null, + batchId: null, + })), + }; +} + +export async function importReconciliationRoute(ctx: RequestContext) { + const auth = await authorizeWrite(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); + const input = readReconciliationInput(body); + const metadata = objectValue(body.metadata); + + const result = await transaction(async client => { + const built = await buildReconciliation(client, { tenantId: auth.tenantId, ...input }); + const insertedBatch = await client.query>( + ` + insert into public.commerce_reconciliation_batches ( + tenant_id, provider, bill_date, bill_type, source, source_name, source_hash, + status, total_count, matched_count, mismatch_count, missing_local_count, + missing_provider_count, duplicate_count, ignored_count, amount_cents, + refund_amount_cents, fee_cents, created_by, completed_at, metadata + ) + values ( + $1, $2, $3::date, $4, $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, $16, + $17, $18, $19, now(), $20::jsonb + ) + returning id, provider, bill_date as "billDate", bill_type as "billType", + source, source_name as "sourceName", source_hash as "sourceHash", + status, total_count as "totalCount", matched_count as "matchedCount", + mismatch_count as "mismatchCount", missing_local_count as "missingLocalCount", + missing_provider_count as "missingProviderCount", duplicate_count as "duplicateCount", + ignored_count as "ignoredCount", amount_cents as "amountCents", + refund_amount_cents as "refundAmountCents", fee_cents as "feeCents", + created_by as "createdBy", completed_at as "completedAt", error, + metadata, created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + built.batch.provider, + built.batch.billDate, + built.batch.billType, + built.batch.source, + built.batch.sourceName, + built.batch.sourceHash, + built.batch.status, + built.batch.totalCount, + built.batch.matchedCount, + built.batch.mismatchCount, + built.batch.missingLocalCount, + built.batch.missingProviderCount, + built.batch.duplicateCount, + built.batch.ignoredCount, + built.batch.amountCents, + built.batch.refundAmountCents, + built.batch.feeCents, + auth.userId, + JSON.stringify(metadata), + ], + ); + const batch = insertedBatch.rows[0]; + const batchId = String(batch.id); + + for (const item of built.items) { + await client.query( + ` + insert into public.commerce_reconciliation_items ( + tenant_id, batch_id, row_no, provider, transaction_type, + provider_trade_no, provider_refund_no, order_no, refund_no, + amount_cents, refund_amount_cents, fee_cents, paid_at, refunded_at, + provider_status, local_status, order_id, payment_id, refund_request_id, + match_status, severity, issue_code, details + ) + values ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, $13::timestamptz, $14::timestamptz, + $15, $16, $17::uuid, $18::uuid, $19::uuid, + $20, $21, $22, $23::jsonb + ) + `, + [ + auth.tenantId, + batchId, + item.rowNo, + item.provider, + item.transactionType, + item.providerTradeNo, + item.providerRefundNo, + item.orderNo, + item.refundNo, + item.amountCents, + item.refundAmountCents, + item.feeCents, + item.paidAt, + item.refundedAt, + item.providerStatus, + item.localStatus, + item.orderId, + item.paymentId, + item.refundRequestId, + item.matchStatus, + item.severity, + item.issueCode, + JSON.stringify(item.details), + ], + ); + } + + await recordReconciliationAudit(client, auth, 'commerce.reconciliation.imported', batchId, { + provider: built.batch.provider, + billDate: built.batch.billDate, + billType: built.batch.billType, + source: built.batch.source, + sourceName: built.batch.sourceName, + sourceHash: built.batch.sourceHash, + summary: { + totalCount: built.batch.totalCount, + matchedCount: built.batch.matchedCount, + mismatchCount: built.batch.mismatchCount, + missingLocalCount: built.batch.missingLocalCount, + missingProviderCount: built.batch.missingProviderCount, + duplicateCount: built.batch.duplicateCount, + ignoredCount: built.batch.ignoredCount, + }, + }); + + return { batch, previewItems: built.items.slice(0, optionalInteger(body, 'previewLimit', 50)) }; + }); + + return { + item: batchPayload(result.batch), + previewItems: result.previewItems.map(item => ({ ...item, id: null, batchId: String(result.batch.id) })), + }; +} + +export async function reconciliationBatchesRoute(ctx: RequestContext) { + const auth = await authorizeRead(ctx); + const limit = intParam(ctx, 'limit', 50, 200); + const provider = stringParam(ctx, 'provider'); + const status = stringParam(ctx, 'status'); + const billDate = stringParam(ctx, 'billDate'); + + const params: unknown[] = [auth.tenantId, limit]; + const where = ['tenant_id = $1']; + if (provider) { + params.push(normalizeProvider(provider)); + where.push(`provider = $${params.length}`); + } + if (status) { + params.push(status); + where.push(`status = $${params.length}`); + } + if (billDate) { + params.push(normalizeBillDate(billDate)); + where.push(`bill_date = $${params.length}::date`); + } + + const items = await query>( + ` + select id, provider, bill_date as "billDate", bill_type as "billType", + source, source_name as "sourceName", source_hash as "sourceHash", + status, total_count as "totalCount", matched_count as "matchedCount", + mismatch_count as "mismatchCount", missing_local_count as "missingLocalCount", + missing_provider_count as "missingProviderCount", duplicate_count as "duplicateCount", + ignored_count as "ignoredCount", amount_cents as "amountCents", + refund_amount_cents as "refundAmountCents", fee_cents as "feeCents", + created_by as "createdBy", completed_at as "completedAt", error, + metadata, created_at as "createdAt", updated_at as "updatedAt" + from public.commerce_reconciliation_batches + where ${where.join(' and ')} + order by created_at desc + limit $2 + `, + params, + ); + return { items: items.map(batchPayload) }; +} + +export async function reconciliationItemsRoute(ctx: RequestContext) { + const auth = await authorizeRead(ctx); + const limit = intParam(ctx, 'limit', 100, 500); + const batchId = stringParam(ctx, 'batchId'); + const matchStatus = stringParam(ctx, 'matchStatus'); + const severity = stringParam(ctx, 'severity'); + const orderNo = stringParam(ctx, 'orderNo'); + + const params: unknown[] = [auth.tenantId, limit]; + const where = ['tenant_id = $1']; + if (batchId) { + params.push(batchId); + where.push(`batch_id = $${params.length}::uuid`); + } + if (matchStatus) { + params.push(matchStatus); + where.push(`match_status = $${params.length}`); + } + if (severity) { + params.push(severity); + where.push(`severity = $${params.length}`); + } + if (orderNo) { + params.push(orderNo); + where.push(`order_no = $${params.length}`); + } + + const items = await query>( + ` + select id, batch_id as "batchId", row_no as "rowNo", provider, + transaction_type as "transactionType", provider_trade_no as "providerTradeNo", + provider_refund_no as "providerRefundNo", order_no as "orderNo", refund_no as "refundNo", + amount_cents as "amountCents", refund_amount_cents as "refundAmountCents", + fee_cents as "feeCents", paid_at as "paidAt", refunded_at as "refundedAt", + provider_status as "providerStatus", local_status as "localStatus", + order_id as "orderId", payment_id as "paymentId", + refund_request_id as "refundRequestId", match_status as "matchStatus", + severity, issue_code as "issueCode", details, created_at as "createdAt" + from public.commerce_reconciliation_items + where ${where.join(' and ')} + order by created_at desc, row_no desc + limit $2 + `, + params, + ); + return { items: items.map(itemPayload) }; +} + +export async function reconciliationAnomaliesRoute(ctx: RequestContext) { + const auth = await authorizeRead(ctx); + const limit = intParam(ctx, 'limit', 50, 200); + const providerParam = stringParam(ctx, 'provider'); + const provider = providerParam ? normalizeProvider(providerParam) : ''; + + const issueParams: unknown[] = [auth.tenantId, limit]; + const issueWhere = [`i.tenant_id = $1`, `i.match_status not in ('matched', 'ignored')`]; + if (provider) { + issueParams.push(provider); + issueWhere.push(`i.provider = $${issueParams.length}`); + } + const reconciliationIssues = await query>( + ` + select i.id, i.batch_id as "batchId", b.bill_date as "billDate", b.source_name as "sourceName", + i.row_no as "rowNo", i.provider, i.transaction_type as "transactionType", + i.provider_trade_no as "providerTradeNo", i.provider_refund_no as "providerRefundNo", + i.order_no as "orderNo", i.refund_no as "refundNo", + i.amount_cents as "amountCents", i.refund_amount_cents as "refundAmountCents", + i.match_status as "matchStatus", i.severity, i.issue_code as "issueCode", + i.details, i.created_at as "createdAt" + from public.commerce_reconciliation_items i + join public.commerce_reconciliation_batches b on b.tenant_id = i.tenant_id and b.id = i.batch_id + where ${issueWhere.join(' and ')} + order by i.created_at desc + limit $2 + `, + issueParams, + ); + + const eventParams: unknown[] = [auth.tenantId, limit]; + const eventWhere = [`tenant_id = $1`, `error is not null`]; + if (provider) { + eventParams.push(provider); + eventWhere.push(`provider = $${eventParams.length}`); + } + const paymentEventErrors = await query>( + ` + select id, payment_id as "paymentId", provider, event_type as "eventType", + event_id as "eventId", error, created_at as "createdAt", processed_at as "processedAt" + from public.payment_events + where ${eventWhere.join(' and ')} + order by created_at desc + limit $2 + `, + eventParams, + ); + + return { + items: reconciliationIssues.map(item => ({ + source: 'reconciliation', + ...itemPayload(item), + billDate: item.billDate, + sourceName: item.sourceName, + })), + paymentEventErrors, + }; +} diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 0034ccab..991d8b91 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -90,6 +90,8 @@ export function tenantPermissionCatalog() { { key: 'tenant:domains:write', label: '域名管理' }, { key: 'tenant:payment:read', label: '商户配置查看' }, { key: 'tenant:payment:write', label: '商户配置管理' }, + { key: 'tenant:reconciliation:read', label: '资金对账查看' }, + { key: 'tenant:reconciliation:write', label: '资金对账导入' }, { key: 'tenant:refund:read', label: '退款查看' }, { key: 'tenant:refund:write', label: '退款申请/处理' }, { key: 'tenant:refund:review', label: '退款审核' }, diff --git a/docs/refactor/auth-payment-provider-plan.md b/docs/refactor/auth-payment-provider-plan.md index 205160b3..5063d149 100644 --- a/docs/refactor/auth-payment-provider-plan.md +++ b/docs/refactor/auth-payment-provider-plan.md @@ -272,6 +272,23 @@ body: { "code": "", "redirectUri": "https://h5.example.com/auth/q - 支付成功事务内更新 `orders`、`payments`、`payment_events`、`entitlements`。 - 支付密钥只允许放在 `app_private.tenant_secrets` 或生产 KMS/Vault。 +### 资金对账 + +当前后端已提供租户级资金对账基础能力: + +- `POST /api/commerce/reconciliation/preview`:预览供应商账单行和本地订单/支付/退款的匹配结果。 +- `POST /api/commerce/reconciliation/import`:确认导入对账批次和明细,写入审计。 +- `GET /api/commerce/reconciliation/batches`:查询对账批次。 +- `GET /api/commerce/reconciliation/items`:查询逐行结果。 +- `GET /api/commerce/reconciliation/anomalies`:查询金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行等异常。 + +权限点: + +- `tenant:reconciliation:read`:查看/预览对账。 +- `tenant:reconciliation:write`:导入对账批次。 + +对账只生成差异台账,不自动修改订单、支付、退款和权益。真实生产中,微信/支付宝官方账单下载 adapter 应复用同一套 `commerce_reconciliation_batches/items`,下载后的 CSV/JSON 先规范化为 `rows`,再调用同一套匹配逻辑。差异处理、人工调整凭证、财务复核和异常订单运营台仍需要继续补。 + B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,而是走平台账务: - `platform_saas_plans`:平台售卖给合作商的 SaaS 套餐。 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 6568a3fd..288676f5 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -117,7 +117,8 @@ | 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 | | 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`、`/api/commerce/refunds/notify/{provider}`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 | | 支付/退款补偿 worker | 可联调 | `apps/worker --job commerce` 查询微信/支付宝订单和处理中退款,补偿漏通知支付、补发权益、确认退款、全额退款撤销权益;`npm run test:worker:commerce` 覆盖幂等和密钥不泄露 | -| 完整资金流水对账 | 待补齐 | 后续补微信/支付宝账单下载、平台账单比对、差错处理、异常订单运营台 | +| 资金流水对账 | 可联调 | `commerce_reconciliation_batches/items` + `/api/commerce/reconciliation/preview/import/batches/items/anomalies`;租户后台需 `tenant:reconciliation:read/write`,支持支付/退款账单行手工或 API 导入、来源 hash、批次统计、逐行匹配、金额/状态差异、本地缺失、供应商缺失、重复行、无效行和审计;本阶段只生成差异,不自动改订单/权益 | +| 官方账单下载和差错处理台 | 待补齐 | 后续补微信/支付宝账单下载、差错单处理流、人工调整凭证、财务复核和异常订单运营台 | ## 租户后台与平台后台 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index e0b19488..a9d2f0d4 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -29,7 +29,7 @@ | 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 | | 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 | | 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架、题库导出 PDF/Word/每日一练 ZIP 可生成 `content_assets` 并走签名下载/预览 | CDN 防盗链、杀毒扫描、视频水印、资料前端操作体验 | -| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 | +| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API | 微信/支付宝官方账单自动下载、差错处理工单、财务复核和异常订单运营台 | | 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 | | 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 | | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 | @@ -83,7 +83,7 @@ ### P1:商用收费和运营能力 -- 完整资金流水对账、账单下载比对和异常订单运营台。 +- 资金对账已支持手工/API 账单导入比对和异常查询;继续补微信/支付宝官方账单下载、差错处理工单、财务复核和异常订单运营台。 - XPay 或其它实际支付网关 adapter。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。 - 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;继续补生产定时调度、失败告警,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 202ba42a..17229fbf 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -242,7 +242,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` 和 `/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook,全额退款成功会撤销订单来源权益;`apps/worker --job commerce` 会定时补偿查询支付和退款状态,完整资金流水对账后续接入。 +- `/api/commerce/refunds`、`/api/commerce/refunds/status` 和 `/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook,全额退款成功会撤销订单来源权益;`apps/worker --job commerce` 会定时补偿查询支付和退款状态;`/api/commerce/reconciliation/*` 已支持租户资金对账导入、预览、批次/明细/异常查询和审计。 - 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。 - `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。 - 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容、活动和勋章,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。 @@ -260,7 +260,7 @@ GET /api/tenant-admin/audit-logs 1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、杀毒扫描和视频水印。 2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。 3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。 -4. 补完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认和退款通知主链路已完成。 +4. 补微信/支付宝官方账单自动下载、差错处理工单、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知和资金对账导入比对主链路已完成。 5. 扩展 `apps/worker`:日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。 6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 0e1227ef..40370d19 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -30,7 +30,7 @@ | CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 | -| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知和补偿 worker | 完整资金流水对账、异常订单运营台、服务商/平台代收模式 | +| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知、补偿 worker、资金对账导入比对和异常查询 | 官方账单自动下载、差错处理工单、异常订单运营台、服务商/平台代收模式 | | AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 | | Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 | diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index f81ca295..26b5c920 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` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 | -| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker 和全额退款权益撤销已有;缺完整资金流水对账、异常订单运营台和前端收银台/售后体验 | +| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对和异常查询已有;缺微信/支付宝官方账单自动下载、差错处理工单、异常订单运营台和前端收银台/售后体验 | | 个人中心 | `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 fd7d32d7..1ff78614 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -27,6 +27,7 @@ - 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。 - 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。 - 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。 +- 资金对账基础闭环已完成:`commerce_reconciliation_batches/items` 和 `/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志。 - 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。 - 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。 - 本地验证:`npm run check:refactor` 已通过。 @@ -70,7 +71,7 @@ - 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。 - 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。 - 已完成支付/退款补偿 worker,可兜底供应商漏通知、处理中退款和重复执行幂等。 - - 继续补完整资金流水对账、账单下载比对和异常订单运营台。 + - 已完成资金对账手工/API 导入比对、批次/明细/异常查询和审计;继续补微信/支付宝官方账单自动下载、差错处理工单、人工调整凭证、财务复核和异常订单运营台。 - 租户自有商户收款和平台代收/服务商模式。 2. 国内登录和短信 @@ -109,7 +110,7 @@ 8. 订单和营销体验 - 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。 - - 已完成支付/退款补偿 worker;继续补完整资金流水对账、异常订单运营台、优惠券核销报表和复杂活动规则。 + - 已完成支付/退款补偿 worker 和资金对账导入比对;继续补异常订单运营台、优惠券核销报表和复杂活动规则。 9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 @@ -224,5 +225,5 @@ 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 -6. 并行补真实登录、完整资金流水对账、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 +6. 并行补真实登录、微信/支付宝官方账单自动下载与差错处理台、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 5c48e6b7..ffb0427f 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -1752,7 +1752,77 @@ approved/processing -> failed - 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=`、`POST /api/commerce/refunds/notify/alipay?tenantId=`。这是支付平台回调地址,Taro 前端不要主动调用。 - 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。 - 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。 -- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。完整资金流水对账、账单下载比对和异常订单运营台后续继续补;生产联调时仍需保留人工确认/失败登记入口。 +- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。资金对账已支持租户后台手工/API 导入供应商账单并查询差异;官方账单自动下载、差错处理工单和异常订单运营台后续继续补。生产联调时仍需保留人工确认/失败登记入口。 + +### 租户后台资金对账 + +资金对账是租户后台/财务运营能力,学生端不要接。对账接口只生成差异台账和审计,不会自动修改订单、支付、退款或权益。前端不能根据对账结果自行开通、退款或撤销权益。 + +预览账单: + +```text +POST /api/commerce/reconciliation/preview +权限:tenant:reconciliation:read +body: { + "provider": "wechat_pay | alipay | manual", + "billDate": "2026-06-29", + "billType": "payment | refund | combined", + "sourceName": "wechat-bill-20260629.csv", + "rows": [ + { + "transactionType": "payment", + "orderNo": "<本地 orderNo 或 out_trade_no>", + "providerTradeNo": "<微信/支付宝交易号>", + "amountCents": 990, + "providerStatus": "SUCCESS" + }, + { + "transactionType": "refund", + "orderNo": "", + "refundNo": "<本地 refundNo 或 out_refund_no>", + "providerRefundNo": "<支付平台退款单号>", + "refundAmountCents": 100, + "providerStatus": "REFUND_SUCCESS" + } + ], + "previewLimit": 200 +} +``` + +确认导入: + +```text +POST /api/commerce/reconciliation/import +权限:tenant:reconciliation:write +``` + +查询批次、明细和异常: + +```text +GET /api/commerce/reconciliation/batches?provider=wechat_pay&billDate=2026-06-29 +GET /api/commerce/reconciliation/items?batchId=&matchStatus=missing_provider +GET /api/commerce/reconciliation/anomalies?provider=wechat_pay +``` + +`matchStatus` 取值: + +```text +matched 本地和供应商账单匹配 +amount_mismatch 金额不一致 +status_mismatch 状态不一致 +missing_local 供应商账单有,本地没有 +missing_provider 本地已支付/退款成功,供应商账单没有 +duplicate 供应商账单重复行 +ignored 无效行或不符合本次 billType +``` + +前端处理规则: + +- 财务导入页建议使用 preview -> 人工确认 -> import -> anomalies 的流程。 +- `sourceHash` 可作为同一文件内容的识别线索,但当前接口不会阻止重复导入;前端应展示最近同名/同 hash 批次提醒。 +- 金额统一是分,前端不要传元。 +- 对账差异只是运营判断依据,最终订单修正必须走退款、补偿、人工确认或后续差错处理接口。 +- 当前后端支持 JSON 行导入;CSV/Excel 可以先由前端或后续后端 parser 转成上述 `rows`。微信/支付宝官方账单自动下载仍是后续后端任务。 ### 激活码预检查与兑换 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 1131e151..3c229eb0 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -2044,6 +2044,175 @@ async function testCommerce() { }); assert.equal(crossTenantOrderDetail.code, 'ORDER_NOT_FOUND', 'order detail must be tenant isolated'); + const studentReconciliationDenied = await request('/api/commerce/reconciliation/batches', { + expectStatus: 403, + }); + assert.equal(studentReconciliationDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not access finance reconciliation'); + + const reconMatchedOrder = 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: reconMatchedOrder.item.orderNo, + amountCents: reconMatchedOrder.item.amountCents, + providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`, + }, + }); + + const reconMissingProviderOrder = 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: reconMissingProviderOrder.item.orderNo, + amountCents: reconMissingProviderOrder.item.amountCents, + providerTradeNo: `manual-recon-${reconMissingProviderOrder.item.orderNo}`, + }, + }); + + const billDate = shanghaiDateKey(); + const reconciliationRows = [ + { + transactionType: 'payment', + orderNo: reconMatchedOrder.item.orderNo, + providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`, + amountCents: reconMatchedOrder.item.amountCents, + providerStatus: 'SUCCESS', + }, + { + transactionType: 'payment', + orderNo: reconMatchedOrder.item.orderNo, + providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`, + amountCents: reconMatchedOrder.item.amountCents, + providerStatus: 'SUCCESS', + }, + { + transactionType: 'payment', + orderNo: reconMatchedOrder.item.orderNo, + providerTradeNo: `manual-recon-mismatch-${reconMatchedOrder.item.orderNo}`, + amountCents: reconMatchedOrder.item.amountCents + 1, + providerStatus: 'SUCCESS', + }, + { + transactionType: 'payment', + orderNo: 'PROVIDER-ONLY-ORDER', + providerTradeNo: 'provider-only-trade', + amountCents: 1888, + providerStatus: 'SUCCESS', + }, + ]; + + const reconciliationPreview = await request('/api/commerce/reconciliation/preview', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + provider: 'manual', + billDate, + billType: 'payment', + sourceName: 'integration-manual-bill.json', + rows: reconciliationRows, + previewLimit: 20, + }, + }); + assert.equal(reconciliationPreview.item?.status, 'preview', 'reconciliation preview should not persist a completed batch'); + assert.ok( + reconciliationPreview.items?.some(item => item.orderNo === reconMatchedOrder.item.orderNo && item.matchStatus === 'matched'), + 'reconciliation preview should match a provider payment row to local payment', + ); + assert.ok( + reconciliationPreview.items?.some(item => item.matchStatus === 'duplicate'), + 'reconciliation preview should detect duplicate provider rows', + ); + assert.ok( + reconciliationPreview.items?.some(item => item.matchStatus === 'amount_mismatch'), + 'reconciliation preview should detect amount mismatches', + ); + assert.ok( + reconciliationPreview.items?.some(item => item.matchStatus === 'missing_local'), + 'reconciliation preview should detect provider rows missing local orders', + ); + assert.ok( + reconciliationPreview.items?.some(item => item.orderNo === reconMissingProviderOrder.item.orderNo && item.matchStatus === 'missing_provider'), + 'reconciliation preview should detect local paid orders missing from provider bill', + ); + + const reconciliationImport = await request('/api/commerce/reconciliation/import', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + provider: 'manual', + billDate, + billType: 'payment', + sourceName: 'integration-manual-bill.json', + rows: reconciliationRows, + metadata: { test: 'commerce-reconciliation' }, + previewLimit: 20, + }, + }); + assert.ok(reconciliationImport.item?.id, 'tenant admin should import reconciliation batch'); + assert.equal( + reconciliationImport.item?.status, + 'completed_with_issues', + 'reconciliation import should mark batches with anomalies as completed_with_issues', + ); + assert.ok(reconciliationImport.item?.matchedCount >= 1, 'reconciliation batch should count matched rows'); + assert.ok(reconciliationImport.item?.mismatchCount >= 1, 'reconciliation batch should count mismatch rows'); + assert.ok(reconciliationImport.item?.missingLocalCount >= 1, 'reconciliation batch should count missing local rows'); + assert.ok(reconciliationImport.item?.missingProviderCount >= 1, 'reconciliation batch should count missing provider rows'); + assert.ok(reconciliationImport.item?.duplicateCount >= 1, 'reconciliation batch should count duplicate rows'); + + const reconciliationBatches = await request('/api/commerce/reconciliation/batches', { + userId: TENANT_ADMIN_USER_ID, + query: { provider: 'manual', billDate }, + }); + assert.ok( + reconciliationBatches.items?.some(item => item.id === reconciliationImport.item.id), + 'reconciliation batches endpoint should list imported batch', + ); + + const reconciliationItems = await request('/api/commerce/reconciliation/items', { + userId: TENANT_ADMIN_USER_ID, + query: { batchId: reconciliationImport.item.id, matchStatus: 'missing_provider' }, + }); + assert.ok( + reconciliationItems.items?.some(item => item.orderNo === reconMissingProviderOrder.item.orderNo), + 'reconciliation items endpoint should filter missing provider rows', + ); + + const reconciliationAnomalies = await request('/api/commerce/reconciliation/anomalies', { + userId: TENANT_ADMIN_USER_ID, + query: { provider: 'manual' }, + }); + assert.ok( + reconciliationAnomalies.items?.some(item => item.batchId === reconciliationImport.item.id && item.matchStatus === 'missing_local'), + 'reconciliation anomalies endpoint should expose imported issues', + ); + + const crossTenantReconciliationDenied = await request('/api/commerce/reconciliation/items', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + query: { batchId: reconciliationImport.item.id }, + expectStatus: 403, + }); + assert.equal(crossTenantReconciliationDenied.code, 'TENANT_ADMIN_REQUIRED', 'reconciliation items must be tenant isolated'); + const fakeWechatPay = await startFakeWechatPayServer(); const wechatAccount = await request('/api/tenant-admin/payment-accounts', { userId: TENANT_ADMIN_USER_ID, @@ -5583,6 +5752,8 @@ async function testTenantMemberPermissionsAndAudit() { assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission'); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission'); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission'); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:read'), 'permission matrix should expose reconciliation read permission'); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:write'), 'permission matrix should expose reconciliation write permission'); assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults'); diff --git a/supabase/migrations/202606290022_commerce_reconciliation.sql b/supabase/migrations/202606290022_commerce_reconciliation.sql new file mode 100644 index 00000000..f9ecd7b9 --- /dev/null +++ b/supabase/migrations/202606290022_commerce_reconciliation.sql @@ -0,0 +1,100 @@ +create table if not exists public.commerce_reconciliation_batches ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + provider text not null, + bill_date date not null, + bill_type text not null default 'combined' + check (bill_type in ('payment', 'refund', 'combined')), + source text not null default 'manual_upload' + check (source in ('manual_upload', 'provider_download', 'api', 'worker')), + source_name text, + source_hash text not null, + status text not null default 'completed' + check (status in ('preview', 'pending', 'processing', 'completed', 'completed_with_issues', 'failed')), + total_count integer not null default 0 check (total_count >= 0), + matched_count integer not null default 0 check (matched_count >= 0), + mismatch_count integer not null default 0 check (mismatch_count >= 0), + missing_local_count integer not null default 0 check (missing_local_count >= 0), + missing_provider_count integer not null default 0 check (missing_provider_count >= 0), + duplicate_count integer not null default 0 check (duplicate_count >= 0), + ignored_count integer not null default 0 check (ignored_count >= 0), + amount_cents integer not null default 0 check (amount_cents >= 0), + refund_amount_cents integer not null default 0 check (refund_amount_cents >= 0), + fee_cents integer not null default 0, + created_by uuid references public.platform_users(id) on delete set null, + completed_at timestamptz, + error text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.commerce_reconciliation_items ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + batch_id uuid not null references public.commerce_reconciliation_batches(id) on delete cascade, + row_no integer not null check (row_no > 0), + provider text not null, + transaction_type text not null + check (transaction_type in ('payment', 'refund')), + provider_trade_no text, + provider_refund_no text, + order_no text, + refund_no text, + amount_cents integer not null default 0 check (amount_cents >= 0), + refund_amount_cents integer not null default 0 check (refund_amount_cents >= 0), + fee_cents integer not null default 0, + paid_at timestamptz, + refunded_at timestamptz, + provider_status text, + local_status text, + order_id uuid references public.orders(id) on delete set null, + payment_id uuid references public.payments(id) on delete set null, + refund_request_id uuid references public.commerce_refund_requests(id) on delete set null, + match_status text not null + check (match_status in ('matched', 'amount_mismatch', 'status_mismatch', 'missing_local', 'missing_provider', 'duplicate', 'ignored')), + severity text not null default 'info' + check (severity in ('info', 'warning', 'error', 'critical')), + issue_code text, + details jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + unique (batch_id, row_no) +); + +create index if not exists idx_commerce_recon_batches_tenant_date + on public.commerce_reconciliation_batches(tenant_id, provider, bill_date desc, created_at desc); + +create index if not exists idx_commerce_recon_batches_status + on public.commerce_reconciliation_batches(tenant_id, status, created_at desc); + +create index if not exists idx_commerce_recon_items_batch_status + on public.commerce_reconciliation_items(tenant_id, batch_id, match_status, row_no); + +create index if not exists idx_commerce_recon_items_order + on public.commerce_reconciliation_items(tenant_id, order_no, created_at desc); + +create index if not exists idx_commerce_recon_items_refund + on public.commerce_reconciliation_items(tenant_id, refund_no, provider_refund_no, created_at desc); + +create index if not exists idx_commerce_recon_items_trade + on public.commerce_reconciliation_items(tenant_id, provider, provider_trade_no, created_at desc); + +alter table public.commerce_reconciliation_batches enable row level security; +alter table public.commerce_reconciliation_items enable row level security; + +drop policy if exists tenant_isolation on public.commerce_reconciliation_batches; +create policy tenant_isolation on public.commerce_reconciliation_batches + 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_reconciliation_items; +create policy tenant_isolation on public.commerce_reconciliation_items + 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_reconciliation_batches; +create trigger set_updated_at + before update on public.commerce_reconciliation_batches + for each row execute function app.touch_updated_at();