forked from wangziqi/gongxue-base
feat: add commission settlement exports and proofs
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user