forked from wangziqi/gongxue-base
feat: add platform invoice dunning workflow
This commit is contained in:
@@ -6,10 +6,12 @@ import {
|
||||
createTenantInvoiceFromSubscriptionRoute,
|
||||
createTenantInvoicesBatchFromSubscriptionsRoute,
|
||||
createTenantRoute,
|
||||
invoiceRemindersRoute,
|
||||
platformAuditLogsRoute,
|
||||
platformOverviewRoute,
|
||||
platformPlansRoute,
|
||||
platformQuestionBanksRoute,
|
||||
processOverdueInvoicesRoute,
|
||||
questionBankGrantsRoute,
|
||||
recordUsageRoute,
|
||||
subscriptionInvoiceCandidatesRoute,
|
||||
@@ -40,6 +42,8 @@ export const platformAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/platform-admin/invoices/subscription-candidates', subscriptionInvoiceCandidatesRoute],
|
||||
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
|
||||
['POST', '/api/platform-admin/invoices/from-subscriptions-batch', createTenantInvoicesBatchFromSubscriptionsRoute],
|
||||
['POST', '/api/platform-admin/invoices/process-overdue', processOverdueInvoicesRoute],
|
||||
['GET', '/api/platform-admin/invoices/reminders', invoiceRemindersRoute],
|
||||
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
|
||||
['GET', '/api/platform-admin/usage', tenantUsageRoute],
|
||||
['POST', '/api/platform-admin/usage', recordUsageRoute],
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
normalizeHost,
|
||||
normalizeInvoiceItems,
|
||||
normalizeSlug,
|
||||
processOverduePlatformInvoices,
|
||||
quantityFrom,
|
||||
recalculateInvoiceTotals,
|
||||
} from './service.js';
|
||||
@@ -1167,6 +1168,78 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function processOverdueInvoicesRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const dryRun = booleanFrom(body.dryRun, false);
|
||||
const limit = Math.min(Math.max(Number(body.limit || 100), 1), 500);
|
||||
const channel = optionalString(body, 'channel') || 'internal';
|
||||
if (!['manual', 'internal', 'sms', 'email', 'wechat', 'crm'].includes(channel)) {
|
||||
throw new HttpError(400, 'channel is invalid', 'INVALID_REMINDER_CHANNEL');
|
||||
}
|
||||
|
||||
const session = currentSessionFromContext(ctx);
|
||||
const item = await transaction(async client => {
|
||||
const result = await processOverduePlatformInvoices(client, {
|
||||
actorUserId: session?.id || null,
|
||||
channel,
|
||||
dryRun,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (!dryRun) {
|
||||
await recordPlatformAudit(client, ctx, 'platform.invoice.overdue_batch_processed', 'tenant_invoice_batch', null, {
|
||||
processed: result.processed,
|
||||
markedOverdue: result.markedOverdue,
|
||||
reminderCreated: result.reminderCreated,
|
||||
skippedReminder: result.skippedReminder,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function invoiceRemindersRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const invoiceId = ctx.url.searchParams.get('invoiceId') || '';
|
||||
const status = listQuery(ctx, 'status');
|
||||
const reminderType = listQuery(ctx, 'reminderType');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
|
||||
if (invoiceId && !UUID_RE.test(invoiceId)) throw new HttpError(400, 'invoiceId is invalid', 'INVALID_UUID');
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select r.id, r.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||
r.invoice_id as "invoiceId", i.invoice_no as "invoiceNo",
|
||||
r.reminder_type as "reminderType", r.channel, r.status,
|
||||
r.reminder_date as "reminderDate", r.reminder_level as "reminderLevel",
|
||||
r.due_date as "dueDate", r.balance_cents_snapshot as "balanceCentsSnapshot",
|
||||
r.message, r.metadata, r.sent_at as "sentAt", r.acknowledged_at as "acknowledgedAt",
|
||||
r.created_at as "createdAt", r.updated_at as "updatedAt"
|
||||
from public.tenant_invoice_reminders r
|
||||
join public.tenants t on t.id = r.tenant_id
|
||||
join public.tenant_invoices i on i.id = r.invoice_id
|
||||
where ($1::uuid is null or r.tenant_id = $1::uuid)
|
||||
and ($2::uuid is null or r.invoice_id = $2::uuid)
|
||||
and ($3::text = '' or r.status = $3)
|
||||
and ($4::text = '' or r.reminder_type = $4)
|
||||
order by r.reminder_date desc, r.created_at desc
|
||||
limit $5
|
||||
`,
|
||||
[tenantId || null, invoiceId || null, status, reminderType, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function recordUsageRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
|
||||
@@ -116,3 +116,232 @@ export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId:
|
||||
|
||||
return updateResult.rows[0];
|
||||
}
|
||||
|
||||
interface ProcessOverduePlatformInvoicesOptions {
|
||||
actorUserId?: string | null;
|
||||
channel?: string;
|
||||
reminderType?: string;
|
||||
dryRun?: boolean;
|
||||
limit?: number;
|
||||
today?: string | null;
|
||||
workerId?: string | null;
|
||||
}
|
||||
|
||||
function clampPositiveInteger(value: unknown, fallback: number, max: number) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(Math.trunc(parsed), max);
|
||||
}
|
||||
|
||||
function yyyyMmDd(value: Date) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function currentDateText(value?: string | null) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
||||
return yyyyMmDd(new Date());
|
||||
}
|
||||
|
||||
export async function processOverduePlatformInvoices(
|
||||
client: pg.PoolClient,
|
||||
options: ProcessOverduePlatformInvoicesOptions = {},
|
||||
) {
|
||||
const limit = clampPositiveInteger(options.limit, 100, 1000);
|
||||
const today = currentDateText(options.today);
|
||||
const channel = options.channel || 'internal';
|
||||
const reminderType = options.reminderType || 'overdue';
|
||||
|
||||
const invoiceResult = await client.query<{
|
||||
id: string;
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
tenantName: string;
|
||||
invoiceNo: string;
|
||||
status: string;
|
||||
balanceCents: number;
|
||||
dueDate: string | null;
|
||||
existingReminderId: string | null;
|
||||
reminderCount: number;
|
||||
}>(
|
||||
`
|
||||
select i.id,
|
||||
i.tenant_id as "tenantId",
|
||||
t.slug::text as "tenantSlug",
|
||||
t.name as "tenantName",
|
||||
i.invoice_no as "invoiceNo",
|
||||
i.status,
|
||||
i.balance_cents as "balanceCents",
|
||||
i.due_date as "dueDate",
|
||||
existing.id as "existingReminderId",
|
||||
coalesce(history.reminder_count, 0)::integer as "reminderCount"
|
||||
from public.tenant_invoices i
|
||||
join public.tenants t on t.id = i.tenant_id
|
||||
left join lateral (
|
||||
select id
|
||||
from public.tenant_invoice_reminders r
|
||||
where r.tenant_id = i.tenant_id
|
||||
and r.invoice_id = i.id
|
||||
and r.reminder_type = $2
|
||||
and r.channel = $3
|
||||
and r.reminder_date = $1::date
|
||||
limit 1
|
||||
) existing on true
|
||||
left join lateral (
|
||||
select count(*)::integer as reminder_count
|
||||
from public.tenant_invoice_reminders r
|
||||
where r.tenant_id = i.tenant_id
|
||||
and r.invoice_id = i.id
|
||||
and r.reminder_type = $2
|
||||
) history on true
|
||||
where i.status in ('issued', 'overdue')
|
||||
and i.balance_cents > 0
|
||||
and i.due_date is not null
|
||||
and i.due_date < $1::date
|
||||
and t.status = 'active'
|
||||
order by i.due_date asc, i.created_at asc
|
||||
limit $4
|
||||
for update of i skip locked
|
||||
`,
|
||||
[today, reminderType, channel, limit],
|
||||
);
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
dryRun: true,
|
||||
processed: invoiceResult.rowCount,
|
||||
markedOverdue: 0,
|
||||
reminderCreated: 0,
|
||||
skippedReminder: invoiceResult.rows.filter(row => row.existingReminderId).length,
|
||||
items: invoiceResult.rows.map(row => ({
|
||||
...row,
|
||||
wouldMarkOverdue: row.status !== 'overdue',
|
||||
wouldCreateReminder: !row.existingReminderId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const items: unknown[] = [];
|
||||
let markedOverdue = 0;
|
||||
let reminderCreated = 0;
|
||||
let skippedReminder = 0;
|
||||
|
||||
for (const invoice of invoiceResult.rows) {
|
||||
if (invoice.status !== 'overdue') {
|
||||
await client.query(
|
||||
`
|
||||
update public.tenant_invoices
|
||||
set status = 'overdue',
|
||||
metadata = metadata || $3::jsonb,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
invoice.tenantId,
|
||||
invoice.id,
|
||||
JSON.stringify({
|
||||
overdueMarkedAt: new Date().toISOString(),
|
||||
overdueMarkedBy: options.workerId || options.actorUserId || 'platform-admin',
|
||||
}),
|
||||
],
|
||||
);
|
||||
markedOverdue += 1;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.tenants
|
||||
set billing_status = case when billing_status = 'active' then 'past_due' else billing_status end,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[invoice.tenantId],
|
||||
);
|
||||
|
||||
let reminder = null;
|
||||
if (invoice.existingReminderId) {
|
||||
skippedReminder += 1;
|
||||
} else {
|
||||
const reminderResult = await client.query(
|
||||
`
|
||||
insert into public.tenant_invoice_reminders (
|
||||
tenant_id, invoice_id, reminder_type, channel, status,
|
||||
reminder_date, reminder_level, due_date,
|
||||
balance_cents_snapshot, message, metadata, created_by
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, 'pending',
|
||||
$5::date, $6, $7::date,
|
||||
$8, $9, $10::jsonb, $11::uuid
|
||||
)
|
||||
on conflict (tenant_id, invoice_id, reminder_type, channel, reminder_date)
|
||||
do nothing
|
||||
returning id, tenant_id as "tenantId", invoice_id as "invoiceId",
|
||||
reminder_type as "reminderType", channel, status,
|
||||
reminder_date as "reminderDate", reminder_level as "reminderLevel",
|
||||
due_date as "dueDate", balance_cents_snapshot as "balanceCentsSnapshot",
|
||||
message, metadata, created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
invoice.tenantId,
|
||||
invoice.id,
|
||||
reminderType,
|
||||
channel,
|
||||
today,
|
||||
Number(invoice.reminderCount || 0) + 1,
|
||||
invoice.dueDate,
|
||||
Number(invoice.balanceCents || 0),
|
||||
`租户 ${invoice.tenantName} 的平台服务费账单 ${invoice.invoiceNo} 已逾期,请跟进收款。`,
|
||||
JSON.stringify({
|
||||
source: options.workerId ? 'worker' : 'platform_admin',
|
||||
workerId: options.workerId || null,
|
||||
invoiceNo: invoice.invoiceNo,
|
||||
tenantSlug: invoice.tenantSlug,
|
||||
}),
|
||||
options.actorUserId || null,
|
||||
],
|
||||
);
|
||||
reminder = reminderResult.rows[0] || null;
|
||||
if (reminder) reminderCreated += 1;
|
||||
else skippedReminder += 1;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2::uuid, 'platform.invoice.overdue_processed', 'tenant_invoice', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
invoice.tenantId,
|
||||
options.actorUserId || null,
|
||||
invoice.id,
|
||||
JSON.stringify({
|
||||
invoiceNo: invoice.invoiceNo,
|
||||
dueDate: invoice.dueDate,
|
||||
balanceCents: invoice.balanceCents,
|
||||
markedOverdue: invoice.status !== 'overdue',
|
||||
reminderCreated: Boolean(reminder),
|
||||
channel,
|
||||
reminderType,
|
||||
workerId: options.workerId || null,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
items.push({
|
||||
...invoice,
|
||||
status: 'overdue',
|
||||
markedOverdue: invoice.status !== 'overdue',
|
||||
reminderCreated: Boolean(reminder),
|
||||
reminder,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun: false,
|
||||
processed: invoiceResult.rowCount,
|
||||
markedOverdue,
|
||||
reminderCreated,
|
||||
skippedReminder,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user