feat: add provider bill download reconciliation jobs

This commit is contained in:
Codex
2026-06-29 21:59:42 +08:00
parent aeca84b260
commit f3577e257c
21 changed files with 1691 additions and 158 deletions

View File

@@ -21,11 +21,13 @@ import {
createReconciliationIssueRoute,
importReconciliationRoute,
previewReconciliationRoute,
providerBillDownloadJobsRoute,
reconciliationAnomaliesRoute,
reconciliationBatchesRoute,
reconciliationIssueEventsRoute,
reconciliationIssuesRoute,
reconciliationItemsRoute,
requestProviderBillDownloadRoute,
updateReconciliationIssueStatusRoute,
} from './reconciliation.js';
@@ -52,6 +54,8 @@ export const commerceRoutes: RouteDefinition[] = [
['GET', '/api/commerce/reconciliation/batches', reconciliationBatchesRoute],
['GET', '/api/commerce/reconciliation/items', reconciliationItemsRoute],
['GET', '/api/commerce/reconciliation/anomalies', reconciliationAnomaliesRoute],
['POST', '/api/commerce/reconciliation/provider-bills/request', requestProviderBillDownloadRoute],
['GET', '/api/commerce/reconciliation/provider-bills/jobs', providerBillDownloadJobsRoute],
['POST', '/api/commerce/reconciliation/issues/create', createReconciliationIssueRoute],
['GET', '/api/commerce/reconciliation/issues', reconciliationIssuesRoute],
['POST', '/api/commerce/reconciliation/issues/status', updateReconciliationIssueStatusRoute],

View File

@@ -6,9 +6,9 @@ import { intParam, optionalInteger, optionalString, readJsonBody, requiredString
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';
export type ReconciliationProvider = 'wechat_pay' | 'alipay' | 'manual';
export type BillType = 'payment' | 'refund' | 'combined';
export type ReconciliationSource = 'manual_upload' | 'provider_download' | 'api' | 'worker';
type TransactionType = 'payment' | 'refund';
type MatchStatus =
| 'matched'
@@ -62,6 +62,7 @@ const PROVIDER_SUCCESS_STATUSES = new Set([
]);
const LOCAL_PAID_PAYMENT_STATUSES = new Set(['paid', 'partially_refunded', 'refunded']);
const LOCAL_SUCCEEDED_REFUND_STATUSES = new Set(['succeeded']);
const MAX_PROVIDER_DOWNLOAD_ROWS = 50_000;
interface RawBillRow {
[key: string]: unknown;
@@ -164,7 +165,19 @@ interface ReconciliationBuildResult {
items: ReconciliationItemDraft[];
}
function normalizeProvider(value: string): ReconciliationProvider {
export interface ReconciliationBatchImportInput {
tenantId: string;
actorUserId: string | null;
provider: ReconciliationProvider;
billDate: string;
billType: BillType;
source: ReconciliationSource;
sourceName: string | null;
rows: unknown[];
metadata?: Record<string, unknown>;
}
export function normalizeReconciliationProvider(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';
@@ -178,7 +191,7 @@ function providerAliases(provider: ReconciliationProvider) {
return ['manual'];
}
function normalizeBillType(value: unknown): BillType {
export function normalizeReconciliationBillType(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;
@@ -192,7 +205,7 @@ function normalizeSource(value: unknown): ReconciliationSource {
throw new HttpError(400, 'source is invalid', 'RECONCILIATION_SOURCE_INVALID');
}
function normalizeBillDate(value: string) {
export function normalizeReconciliationBillDate(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');
@@ -823,9 +836,9 @@ async function buildReconciliation(
}
function readReconciliationInput(body: Record<string, unknown>) {
const provider = normalizeProvider(requiredString(body, 'provider'));
const billDate = normalizeBillDate(requiredString(body, 'billDate'));
const billType = normalizeBillType(body.billType ?? body.bill_type);
const provider = normalizeReconciliationProvider(requiredString(body, 'provider'));
const billDate = normalizeReconciliationBillDate(requiredString(body, 'billDate'));
const billType = normalizeReconciliationBillType(body.billType ?? body.bill_type);
const source = normalizeSource(body.source);
const sourceName = optionalString(body, 'sourceName') || optionalString(body, 'source_name') || null;
const rawRows = body.rows;
@@ -839,6 +852,160 @@ function readReconciliationInput(body: Record<string, unknown>) {
return { provider, billDate, billType, source, sourceName, rows };
}
function normalizeProviderDownloadedRows(provider: ReconciliationProvider, billType: BillType, rows: unknown[]) {
if (rows.length > MAX_PROVIDER_DOWNLOAD_ROWS) {
throw new HttpError(413, `Too many provider bill rows. Max ${MAX_PROVIDER_DOWNLOAD_ROWS}.`, 'RECONCILIATION_PROVIDER_ROWS_TOO_MANY');
}
return normalizeRows(provider, billType, rows);
}
export async function importReconciliationBatch(
client: pg.PoolClient,
input: ReconciliationBatchImportInput,
) {
const provider = input.provider;
const billDate = normalizeReconciliationBillDate(input.billDate);
const billType = normalizeReconciliationBillType(input.billType);
const source = normalizeSource(input.source);
const sourceName = input.sourceName;
const rows = normalizeProviderDownloadedRows(provider, billType, input.rows);
const built = await buildReconciliation(client, {
tenantId: input.tenantId,
provider,
billDate,
billType,
source,
sourceName,
rows,
});
const insertedBatch = await client.query<Record<string, unknown>>(
`
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::uuid, 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"
`,
[
input.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,
input.actorUserId,
JSON.stringify(input.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
)
`,
[
input.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 client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2::uuid, 'commerce.reconciliation.imported', 'commerce_reconciliation_batch', $3, $4::jsonb)
`,
[
input.tenantId,
input.actorUserId,
batchId,
JSON.stringify({
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, built };
}
function batchPayload(row: Record<string, unknown>) {
return {
id: row.id,
@@ -1065,6 +1232,158 @@ async function authorizeWrite(ctx: RequestContext) {
return auth;
}
async function authorizeDownload(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:reconciliation:download');
return auth;
}
function providerBillJobPayload(row: Record<string, unknown>) {
return {
id: row.id,
provider: row.provider,
billDate: row.billDate,
billType: row.billType,
status: row.status,
sourceName: row.sourceName,
sourceHash: row.sourceHash,
rowCount: row.rowCount,
downloadHashType: row.downloadHashType,
downloadHashValue: row.downloadHashValue,
downloadUrlHost: row.downloadUrlHost,
reconciliationBatchId: row.reconciliationBatchId,
requestedBy: row.requestedBy,
claimedBy: row.claimedBy,
claimedAt: row.claimedAt,
completedAt: row.completedAt,
failedAt: row.failedAt,
errorCode: row.errorCode,
errorMessage: row.errorMessage,
metadata: row.metadata || {},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function requestProviderBillDownloadRoute(ctx: RequestContext) {
const auth = await authorizeDownload(ctx);
const body = await readJsonBody(ctx);
const provider = normalizeReconciliationProvider(requiredString(body, 'provider'));
if (provider === 'manual') {
throw new HttpError(400, 'Manual provider does not support official bill download', 'PROVIDER_BILL_DOWNLOAD_UNSUPPORTED');
}
const billDate = normalizeReconciliationBillDate(requiredString(body, 'billDate'));
const billType = normalizeReconciliationBillType(body.billType ?? body.bill_type);
const metadata = objectValue(body.metadata);
const sourceName = `provider-bill:${provider}:${billDate}:${billType}`;
const result = await transaction(async client => {
const inserted = await client.query<Record<string, unknown>>(
`
insert into public.commerce_bill_download_jobs (
tenant_id, provider, bill_date, bill_type, status,
source_name, requested_by, metadata
)
values ($1, $2, $3::date, $4, 'queued', $5, $6::uuid, $7::jsonb)
on conflict (tenant_id, provider, bill_date, bill_type)
where status in ('queued', 'running', 'completed')
do update set
metadata = public.commerce_bill_download_jobs.metadata || excluded.metadata,
updated_at = now()
returning id, provider, bill_date as "billDate", bill_type as "billType",
status, source_name as "sourceName", source_hash as "sourceHash",
row_count as "rowCount", download_hash_type as "downloadHashType",
download_hash_value as "downloadHashValue", download_url_host as "downloadUrlHost",
reconciliation_batch_id as "reconciliationBatchId",
requested_by as "requestedBy", claimed_by as "claimedBy",
claimed_at as "claimedAt", completed_at as "completedAt",
failed_at as "failedAt", error_code as "errorCode",
error_message as "errorMessage", metadata,
created_at as "createdAt", updated_at as "updatedAt",
(xmax = 0) as "inserted"
`,
[
auth.tenantId,
provider,
billDate,
billType,
sourceName,
auth.userId,
JSON.stringify({
...metadata,
requestedFrom: 'api',
}),
],
);
const job = inserted.rows[0];
await recordReconciliationAudit(
client,
auth,
job.inserted ? 'commerce.provider_bill_download.requested' : 'commerce.provider_bill_download.request_reused',
String(job.id),
{
provider,
billDate,
billType,
sourceName,
status: job.status,
},
);
return job;
});
return {
item: providerBillJobPayload(result),
idempotent: result.inserted === false,
};
}
export async function providerBillDownloadJobsRoute(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) {
const normalized = normalizeReconciliationProvider(provider);
if (normalized === 'manual') throw new HttpError(400, 'provider is invalid for bill download', 'PROVIDER_BILL_DOWNLOAD_UNSUPPORTED');
params.push(normalized);
where.push(`provider = $${params.length}`);
}
if (status) {
params.push(status);
where.push(`status = $${params.length}`);
}
if (billDate) {
params.push(normalizeReconciliationBillDate(billDate));
where.push(`bill_date = $${params.length}::date`);
}
const items = await query<Record<string, unknown>>(
`
select id, provider, bill_date as "billDate", bill_type as "billType",
status, source_name as "sourceName", source_hash as "sourceHash",
row_count as "rowCount", download_hash_type as "downloadHashType",
download_hash_value as "downloadHashValue", download_url_host as "downloadUrlHost",
reconciliation_batch_id as "reconciliationBatchId",
requested_by as "requestedBy", claimed_by as "claimedBy",
claimed_at as "claimedAt", completed_at as "completedAt",
failed_at as "failedAt", error_code as "errorCode",
error_message as "errorMessage", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.commerce_bill_download_jobs
where ${where.join(' and ')}
order by created_at desc
limit $2
`,
params,
);
return { items: items.map(providerBillJobPayload) };
}
export async function previewReconciliationRoute(ctx: RequestContext) {
const auth = await authorizeRead(ctx);
const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes });
@@ -1090,122 +1409,21 @@ export async function importReconciliationRoute(ctx: RequestContext) {
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<Record<string, unknown>>(
`
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,
},
const imported = await importReconciliationBatch(client, {
tenantId: auth.tenantId,
actorUserId: auth.userId,
provider: input.provider,
billDate: input.billDate,
billType: input.billType,
source: input.source,
sourceName: input.sourceName,
rows: body.rows as unknown[],
metadata,
});
return { batch, previewItems: built.items.slice(0, optionalInteger(body, 'previewLimit', 50)) };
return {
batch: imported.batch,
previewItems: imported.built.items.slice(0, optionalInteger(body, 'previewLimit', 50)),
};
});
return {
@@ -1224,7 +1442,7 @@ export async function reconciliationBatchesRoute(ctx: RequestContext) {
const params: unknown[] = [auth.tenantId, limit];
const where = ['tenant_id = $1'];
if (provider) {
params.push(normalizeProvider(provider));
params.push(normalizeReconciliationProvider(provider));
where.push(`provider = $${params.length}`);
}
if (status) {
@@ -1232,7 +1450,7 @@ export async function reconciliationBatchesRoute(ctx: RequestContext) {
where.push(`status = $${params.length}`);
}
if (billDate) {
params.push(normalizeBillDate(billDate));
params.push(normalizeReconciliationBillDate(billDate));
where.push(`bill_date = $${params.length}::date`);
}
@@ -1309,7 +1527,7 @@ 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 provider = providerParam ? normalizeReconciliationProvider(providerParam) : '';
const issueParams: unknown[] = [auth.tenantId, limit];
const issueWhere = [`i.tenant_id = $1`, `i.match_status not in ('matched', 'ignored')`];

View File

@@ -92,6 +92,7 @@ export function tenantPermissionCatalog() {
{ key: 'tenant:payment:write', label: '商户配置管理' },
{ key: 'tenant:reconciliation:read', label: '资金对账查看' },
{ key: 'tenant:reconciliation:write', label: '资金对账导入' },
{ key: 'tenant:reconciliation:download', label: '官方账单下载' },
{ key: 'tenant:refund:read', label: '退款查看' },
{ key: 'tenant:refund:write', label: '退款申请/处理' },
{ key: 'tenant:refund:review', label: '退款审核' },