feat: add commission settlement exports and proofs

This commit is contained in:
Codex
2026-06-29 21:03:57 +08:00
parent ec23cd3b9b
commit 8d4428214a
11 changed files with 706 additions and 24 deletions

View File

@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
@@ -5,6 +6,8 @@ import { query, queryOne, transaction } from '../../core/db.js';
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
const SETTLEMENT_STATUSES = ['draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled'];
const PROOF_TYPES = ['payment', 'invoice', 'receipt', 'adjustment', 'other'];
const PROOF_STATUSES = ['submitted', 'approved', 'rejected', 'voided'];
const RATE_SOURCE_ORDER: Record<string, number> = { batch: 1, member: 2, default: 3 };
type JsonBody = Record<string, unknown>;
@@ -148,6 +151,76 @@ function rowToCommissionItem(row: Record<string, unknown>) {
};
}
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
const text = String(value);
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function csvLine(values: unknown[]) {
return values.map(csvEscape).join(',');
}
function contentBase64AndHash(content: string) {
const buffer = Buffer.from(content, 'utf8');
return {
contentBase64: buffer.toString('base64'),
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
sizeBytes: buffer.length,
};
}
function proofTitle(value: unknown, fallback: string) {
const title = nullableString(value);
if (!title) return fallback;
return title.length > 120 ? title.slice(0, 120) : title;
}
function externalProofUrl(value: unknown) {
const url = nullableString(value);
if (!url) return null;
if (url.length > 1000) {
throw new HttpError(400, 'Proof URL is too long', 'INVALID_PROOF_URL');
}
try {
const parsed = new URL(url);
if (!['https:', 'http:'].includes(parsed.protocol)) {
throw new Error('unsupported protocol');
}
return parsed.toString();
} catch {
throw new HttpError(400, 'Proof URL must be a valid http(s) URL', 'INVALID_PROOF_URL');
}
}
function optionalTimestamp(value: unknown, fieldName: string) {
const text = nullableString(value);
if (!text) return null;
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) {
throw new HttpError(400, `${fieldName} must be a valid timestamp`, 'INVALID_TIMESTAMP');
}
return parsed.toISOString();
}
async function assertTenantAsset(client: pg.PoolClient, tenantId: string, assetId: string | null) {
if (!assetId) return null;
const result = await client.query<{ id: string }>(
`
select id
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[tenantId, assetId],
);
if (!result.rows[0]) {
throw new HttpError(400, 'Proof asset does not belong to this tenant', 'COMMISSION_PROOF_ASSET_INVALID');
}
return assetId;
}
function summarizeCommissionRows(rows: Record<string, unknown>[]) {
const seenUsers = new Set<string>();
const byReferrer = new Map<string, {
@@ -565,6 +638,294 @@ export async function commissionSettlementsRoute(ctx: RequestContext) {
return { items };
}
async function settlementForScope(auth: TenantAdminAuth, settlementId: string) {
const item = await queryOne<Record<string, unknown>>(
`
select cs.id, cs.settlement_no as "settlementNo", cs.referrer_user_id as "referrerUserId",
u.name as "referrerName", u.phone as "referrerPhone", cs.status,
cs.period_start as "periodStart", cs.period_end as "periodEnd",
cs.source_count as "sourceCount", cs.paid_user_count as "paidUserCount",
cs.gross_amount_cents as "grossAmountCents",
cs.commission_amount_cents as "commissionAmountCents",
cs.default_rate as "defaultRate", cs.effective_rate as "effectiveRate",
cs.reviewed_by as "reviewedBy", cs.reviewed_at as "reviewedAt",
cs.paid_by as "paidBy", cs.paid_at as "paidAt",
cs.payment_method as "paymentMethod", cs.payment_account as "paymentAccount",
cs.remark, cs.metadata, cs.created_at as "createdAt", cs.updated_at as "updatedAt"
from public.commission_settlements cs
join public.platform_users u on u.id = cs.referrer_user_id
where cs.tenant_id = $1 and cs.id = $2
limit 1
`,
[auth.tenantId, settlementId],
);
if (!item) throw new HttpError(404, 'Commission settlement not found', 'COMMISSION_SETTLEMENT_NOT_FOUND');
restrictReferrerScope(auth, String(item.referrerUserId));
return item;
}
async function settlementItems(tenantId: string, settlementId: string) {
return query<Record<string, unknown>>(
`
select csi.id, csi.source_type as "sourceType", csi.source_id as "sourceId",
csi.source_no as "sourceNo", csi.source_paid_at as "sourcePaidAt",
csi.student_user_id as "studentUserId", student.name as "studentName",
student.phone as "studentPhone", csi.referrer_user_id as "referrerUserId",
referrer.name as "referrerName", referrer.phone as "referrerPhone",
csi.gross_amount_cents as "grossAmountCents",
csi.commission_rate as "commissionRate",
csi.commission_amount_cents as "commissionAmountCents",
csi.rate_source as "rateSource", csi.attribution_type as "attributionType",
csi.metadata, csi.created_at as "createdAt"
from public.commission_settlement_items csi
join public.platform_users referrer on referrer.id = csi.referrer_user_id
left join public.platform_users student on student.id = csi.student_user_id
where csi.tenant_id = $1 and csi.settlement_id = $2
order by csi.source_paid_at asc nulls last, csi.source_no asc nulls last
`,
[tenantId, settlementId],
);
}
export async function commissionSettlementExportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionRead(auth);
const settlementId = stringParam(ctx, 'settlementId');
if (!settlementId) throw new HttpError(400, 'settlementId is required', 'REQUIRED_FIELD');
const format = optionalChoice(ctx.url.searchParams.get('format'), ['csv', 'json'], 'csv');
const settlement = await settlementForScope(auth, settlementId);
const rows = await settlementItems(auth.tenantId, settlementId);
const filename = `${settlement.settlementNo || settlementId}.${format}`;
const payload = {
settlement,
items: rows.map(rowToCommissionItem),
exportedAt: new Date().toISOString(),
};
const content = format === 'json'
? JSON.stringify(payload, null, 2)
: [
csvLine([
'settlementNo',
'referrerName',
'referrerPhone',
'periodStart',
'periodEnd',
'sourceType',
'sourceNo',
'sourcePaidAt',
'studentName',
'studentPhone',
'grossAmountCents',
'commissionRate',
'commissionAmountCents',
'rateSource',
]),
...rows.map(row => csvLine([
settlement.settlementNo,
settlement.referrerName,
settlement.referrerPhone,
settlement.periodStart,
settlement.periodEnd,
row.sourceType,
row.sourceNo,
row.sourcePaidAt,
row.studentName,
row.studentPhone,
row.grossAmountCents,
row.commissionRate,
row.commissionAmountCents,
row.rateSource,
])),
].join('\n');
const encoded = contentBase64AndHash(content);
await transaction(async client => {
await client.query(
`
insert into public.commission_settlement_export_events (
tenant_id, settlement_id, export_format, filename, row_count, content_sha256, exported_by, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`,
[
auth.tenantId,
settlementId,
format,
filename,
rows.length,
encoded.sha256,
auth.userId,
JSON.stringify({ sizeBytes: encoded.sizeBytes }),
],
);
await recordAudit(client, auth, 'commission.settlement.exported', 'commission_settlements', settlementId, {
format,
filename,
rowCount: rows.length,
sha256: encoded.sha256,
});
});
return {
item: {
settlementId,
filename,
format,
mimeType: format === 'json' ? 'application/json' : 'text/csv',
rowCount: rows.length,
...encoded,
},
};
}
export async function commissionSettlementProofsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionRead(auth);
const settlementId = stringParam(ctx, 'settlementId');
if (!settlementId) throw new HttpError(400, 'settlementId is required', 'REQUIRED_FIELD');
await settlementForScope(auth, settlementId);
const items = await query(
`
select p.id, p.settlement_id as "settlementId", p.proof_type as "proofType", p.status,
p.title, p.description, p.asset_id as "assetId", p.external_url as "externalUrl",
p.amount_cents as "amountCents", p.payment_method as "paymentMethod",
p.payment_account as "paymentAccount", p.paid_at as "paidAt",
p.submitted_by as "submittedBy", submitter.name as "submittedByName",
p.reviewed_by as "reviewedBy", reviewer.name as "reviewedByName",
p.reviewed_at as "reviewedAt", p.review_note as "reviewNote",
p.metadata, p.created_at as "createdAt", p.updated_at as "updatedAt"
from public.commission_settlement_proofs p
left join public.platform_users submitter on submitter.id = p.submitted_by
left join public.platform_users reviewer on reviewer.id = p.reviewed_by
where p.tenant_id = $1 and p.settlement_id = $2
order by p.created_at desc
`,
[auth.tenantId, settlementId],
);
return { items };
}
export async function createCommissionSettlementProofRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canReviewCommission(auth)) {
throw new HttpError(403, 'Commission review permission is required', 'TENANT_PERMISSION_REQUIRED');
}
const body = await readJsonBody(ctx);
const settlementId = requiredString(body, 'settlementId');
const settlement = await settlementForScope(auth, settlementId);
const proofType = optionalChoice(body.proofType, PROOF_TYPES, 'payment');
const amountCents = body.amountCents === undefined || body.amountCents === null
? intValue(settlement.commissionAmountCents)
: Math.max(0, intValue(body.amountCents));
const externalUrl = externalProofUrl(body.externalUrl);
const paidAt = optionalTimestamp(body.paidAt, 'paidAt');
const item = await transaction(async client => {
const assetId = await assertTenantAsset(client, auth.tenantId, nullableString(body.assetId));
const result = await client.query(
`
insert into public.commission_settlement_proofs (
tenant_id, settlement_id, proof_type, status, title, description,
asset_id, external_url, amount_cents, payment_method, payment_account,
paid_at, submitted_by, metadata
)
values ($1, $2, $3, 'submitted', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
returning id, settlement_id as "settlementId", proof_type as "proofType", status,
title, description, asset_id as "assetId", external_url as "externalUrl",
amount_cents as "amountCents", payment_method as "paymentMethod",
payment_account as "paymentAccount", paid_at as "paidAt",
submitted_by as "submittedBy", metadata, created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
settlementId,
proofType,
proofTitle(body.title, `${settlement.settlementNo || settlementId} 打款凭证`),
nullableString(body.description),
assetId,
externalUrl,
amountCents,
nullableString(body.paymentMethod),
nullableString(body.paymentAccount),
paidAt,
auth.userId,
JSON.stringify(objectValue(body.metadata)),
],
);
await recordAudit(client, auth, 'commission.settlement.proof_submitted', 'commission_settlement_proofs', result.rows[0].id, {
settlementId,
proofType,
amountCents,
hasAsset: Boolean(assetId),
hasExternalUrl: Boolean(externalUrl),
});
return result.rows[0];
});
return { item };
}
export async function updateCommissionSettlementProofStatusRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canReviewCommission(auth)) {
throw new HttpError(403, 'Commission review permission is required', 'TENANT_PERMISSION_REQUIRED');
}
const body = await readJsonBody(ctx);
const proofId = requiredString(body, 'proofId');
const status = optionalChoice(body.status, PROOF_STATUSES, 'approved');
if (status === 'submitted') {
throw new HttpError(400, 'Cannot move proof back to submitted through status API', 'INVALID_PROOF_STATUS');
}
const item = await transaction(async client => {
const current = await client.query<{ id: string; settlementId: string; status: string }>(
`
select id, settlement_id as "settlementId", status
from public.commission_settlement_proofs
where tenant_id = $1 and id = $2
limit 1
for update
`,
[auth.tenantId, proofId],
);
const proof = current.rows[0];
if (!proof) throw new HttpError(404, 'Commission settlement proof not found', 'COMMISSION_PROOF_NOT_FOUND');
await settlementForScope(auth, proof.settlementId);
if (['approved', 'rejected', 'voided'].includes(proof.status) && proof.status !== status) {
throw new HttpError(409, 'Closed proof cannot change status', 'COMMISSION_PROOF_CLOSED');
}
const result = await client.query(
`
update public.commission_settlement_proofs
set status = $3,
reviewed_by = $4,
reviewed_at = now(),
review_note = coalesce($5, review_note),
metadata = metadata || $6::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, settlement_id as "settlementId", proof_type as "proofType", status,
title, description, asset_id as "assetId", external_url as "externalUrl",
amount_cents as "amountCents", payment_method as "paymentMethod",
payment_account as "paymentAccount", paid_at as "paidAt",
submitted_by as "submittedBy", reviewed_by as "reviewedBy",
reviewed_at as "reviewedAt", review_note as "reviewNote",
metadata, created_at as "createdAt", updated_at as "updatedAt"
`,
[auth.tenantId, proofId, status, auth.userId, nullableString(body.reviewNote), JSON.stringify(objectValue(body.metadata))],
);
await recordAudit(client, auth, 'commission.settlement.proof_status_updated', 'commission_settlement_proofs', proofId, {
settlementId: proof.settlementId,
status,
});
return result.rows[0];
});
return { item };
}
export async function generateCommissionSettlementRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireCommissionWrite(auth);

View File

@@ -2,10 +2,14 @@ import type { RouteDefinition } from '../../core/router.js';
import {
commissionOrdersRoute,
commissionSettingsRoute,
commissionSettlementExportRoute,
commissionSettlementProofsRoute,
commissionSettlementsRoute,
commissionSummaryRoute,
createCommissionSettlementProofRoute,
generateCommissionSettlementRoute,
updateCommissionSettingsRoute,
updateCommissionSettlementProofStatusRoute,
updateCommissionSettlementStatusRoute,
updateMemberCommissionRateRoute,
} from './commission.js';
@@ -47,6 +51,10 @@ export const referralRoutes: RouteDefinition[] = [
['GET', '/api/commission/summary', commissionSummaryRoute],
['GET', '/api/commission/orders', commissionOrdersRoute],
['GET', '/api/commission/settlements', commissionSettlementsRoute],
['GET', '/api/commission/settlements/export', commissionSettlementExportRoute],
['GET', '/api/commission/settlements/proofs', commissionSettlementProofsRoute],
['POST', '/api/commission/settlements/generate', generateCommissionSettlementRoute],
['POST', '/api/commission/settlements/status', updateCommissionSettlementStatusRoute],
['POST', '/api/commission/settlements/proofs', createCommissionSettlementProofRoute],
['POST', '/api/commission/settlements/proofs/status', updateCommissionSettlementProofStatusRoute],
];