feat: add platform dunning notifications

This commit is contained in:
Codex
2026-06-30 07:28:32 +08:00
parent e66623158d
commit 51f8cbdec8
26 changed files with 1782 additions and 24 deletions

View File

@@ -13,6 +13,8 @@ import {
platformAuditNotificationEventsRoute,
platformAuditLogsExportRoute,
platformAuditLogsRoute,
platformDunningNotificationChannelsRoute,
platformDunningNotificationEventsRoute,
platformOverviewRoute,
platformPlansRoute,
platformQuestionBanksRoute,
@@ -27,6 +29,7 @@ import {
updatePlatformAuditAlertStatusRoute,
updateTenantStatusRoute,
upsertPlatformAuditNotificationChannelRoute,
upsertPlatformDunningNotificationChannelRoute,
upsertQuestionBankGrantRoute,
upsertBillingProfileRoute,
} from './routes.js';
@@ -50,6 +53,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/audit-notification-channels', platformAuditNotificationChannelsRoute],
['PUT', '/api/platform-admin/audit-notification-channels', upsertPlatformAuditNotificationChannelRoute],
['GET', '/api/platform-admin/audit-notification-events', platformAuditNotificationEventsRoute],
['GET', '/api/platform-admin/dunning-notification-channels', platformDunningNotificationChannelsRoute],
['PUT', '/api/platform-admin/dunning-notification-channels', upsertPlatformDunningNotificationChannelRoute],
['GET', '/api/platform-admin/dunning-notification-events', platformDunningNotificationEventsRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -55,6 +55,8 @@ const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
const PLATFORM_AUDIT_NOTIFICATION_PROVIDERS = new Set(['generic', 'dingtalk', 'feishu', 'wecom']);
const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
const PLATFORM_DUNNING_REMINDER_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']);
const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']);
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
@@ -169,6 +171,36 @@ function platformAuditNotificationChannelCode(value: string) {
return code;
}
function platformDunningReminderTypes(value: unknown) {
const types = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
: ['overdue', 'final_notice'];
if (types.length === 0 || types.length > 4) {
throw new HttpError(400, 'reminderTypes is invalid', 'INVALID_REMINDER_TYPES');
}
for (const type of types) {
if (!PLATFORM_DUNNING_REMINDER_TYPES.has(type)) {
throw new HttpError(400, 'reminderTypes contains invalid type', 'INVALID_REMINDER_TYPES');
}
}
return [...new Set(types)];
}
function platformDunningReminderChannels(value: unknown) {
const channels = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
: ['internal'];
if (channels.length === 0 || channels.length > 6) {
throw new HttpError(400, 'reminderChannels is invalid', 'INVALID_REMINDER_CHANNELS');
}
for (const channel of channels) {
if (!PLATFORM_DUNNING_REMINDER_CHANNELS.has(channel)) {
throw new HttpError(400, 'reminderChannels contains invalid channel', 'INVALID_REMINDER_CHANNELS');
}
}
return [...new Set(channels)];
}
function platformSecretRef(scope: string, key: string) {
return `app_private.platform_secrets:${scope}:${key}`;
}
@@ -1296,6 +1328,217 @@ export async function platformAuditNotificationEventsRoute(ctx: RequestContext)
};
}
export async function platformDunningNotificationChannelsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const enabled = listQuery(ctx, 'enabled');
const provider = listQuery(ctx, 'provider');
const limit = intParam(ctx, 'limit', 100, 500);
if (enabled && !['true', 'false'].includes(enabled)) {
throw new HttpError(400, 'enabled must be true or false', 'INVALID_BOOLEAN');
}
if (provider && !PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
}
const items = await query<Record<string, unknown>>(
`
select id, channel_code as "channelCode", name, description, enabled,
provider, webhook_url as "webhookUrl", secret_ref as "secretRef",
reminder_types as "reminderTypes", reminder_channels as "reminderChannels",
min_reminder_level as "minReminderLevel", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.platform_dunning_notification_channels
where ($1::text = '' or enabled = ($1 = 'true'))
and ($2::text = '' or provider = $2)
order by enabled desc, min_reminder_level asc, channel_code asc
limit $3
`,
[enabled, provider, limit],
);
return { items: items.map(channelResponse) };
}
export async function upsertPlatformDunningNotificationChannelRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
const name = requiredString(body, 'name');
const provider = platformAuditNotificationProviderFrom(optionalString(body, 'provider'));
const webhookUrl = validatePlatformWebhookUrl(requiredString(body, 'webhookUrl'));
const reminderTypes = platformDunningReminderTypes(body.reminderTypes);
const reminderChannels = platformDunningReminderChannels(body.reminderChannels);
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds', 200);
const minReminderLevel = numberBetween(body.minReminderLevel, 1, 1, 20);
const timeoutSec = numberBetween(body.timeoutSec, 10, 1, 60);
const description = optionalString(body, 'description') || null;
const metadata = objectValue(body.metadata);
const secretKey = `platform_dunning_${channelCode}`;
let secretRef = optionalString(body, 'secretRef') || null;
const secret = typeof body.secret === 'string' && body.secret.trim() ? body.secret.trim() : '';
if (secret) secretRef = platformSecretRef('webhook', secretKey);
if (secretRef && !parsePlatformSecretRef(secretRef)) {
throw new HttpError(400, 'secretRef is invalid', 'INVALID_SECRET_REF');
}
const item = await transaction(async client => {
if (secret) {
await client.query(
`
insert into app_private.platform_secrets (
secret_scope, secret_key, secret_value, provider, last_rotated_at
)
values ('webhook', $1, $2, $3, now())
on conflict (secret_scope, secret_key)
do update set secret_value = excluded.secret_value,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
[secretKey, secret, provider],
);
}
const result = await client.query<Record<string, unknown>>(
`
insert into public.platform_dunning_notification_channels (
channel_code, name, description, enabled, provider, webhook_url,
secret_ref, reminder_types, reminder_channels, min_reminder_level,
tenant_ids, timeout_sec, metadata
)
values (
$1, $2, $3, $4, $5, $6,
$7, $8::text[], $9::text[], $10,
$11::uuid[], $12, $13::jsonb
)
on conflict (channel_code)
do update set name = excluded.name,
description = excluded.description,
enabled = excluded.enabled,
provider = excluded.provider,
webhook_url = excluded.webhook_url,
secret_ref = excluded.secret_ref,
reminder_types = excluded.reminder_types,
reminder_channels = excluded.reminder_channels,
min_reminder_level = excluded.min_reminder_level,
tenant_ids = excluded.tenant_ids,
timeout_sec = excluded.timeout_sec,
metadata = excluded.metadata,
updated_at = now()
returning id, channel_code as "channelCode", name, description, enabled,
provider, webhook_url as "webhookUrl", secret_ref as "secretRef",
reminder_types as "reminderTypes", reminder_channels as "reminderChannels",
min_reminder_level as "minReminderLevel", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec", metadata,
created_at as "createdAt", updated_at as "updatedAt"
`,
[
channelCode,
name,
description,
body.enabled !== false,
provider,
webhookUrl,
secretRef,
reminderTypes,
reminderChannels,
minReminderLevel,
tenantIds,
timeoutSec,
JSON.stringify(metadata),
],
);
const saved = result.rows[0];
await recordPlatformAudit(client, ctx, 'platform.invoice.dunning_notification_channel_upserted', 'platform_dunning_notification_channel', String(saved.id), {
channelCode,
provider,
enabled: body.enabled !== false,
reminderTypes,
reminderChannels,
minReminderLevel,
tenantIdCount: tenantIds.length,
secretRefSet: Boolean(secretRef),
secretRotated: Boolean(secret),
webhook: safeWebhookInfo(webhookUrl),
});
return saved;
});
return { item: channelResponse(item) };
}
export async function platformDunningNotificationEventsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const channelId = listQuery(ctx, 'channelId');
const reminderId = listQuery(ctx, 'reminderId');
const invoiceId = listQuery(ctx, 'invoiceId');
const tenantId = listQuery(ctx, 'tenantId');
const status = listQuery(ctx, 'status');
const provider = listQuery(ctx, 'provider');
const limit = intParam(ctx, 'limit', 100, 500);
if (channelId && !UUID_RE.test(channelId)) throw new HttpError(400, 'channelId is invalid', 'INVALID_UUID');
if (reminderId && !UUID_RE.test(reminderId)) throw new HttpError(400, 'reminderId is invalid', 'INVALID_UUID');
if (invoiceId && !UUID_RE.test(invoiceId)) throw new HttpError(400, 'invoiceId is invalid', 'INVALID_UUID');
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
if (status && !PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES.has(status)) {
throw new HttpError(400, 'status is invalid', 'INVALID_NOTIFICATION_EVENT_STATUS');
}
if (provider && !PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
}
const items = await query<Record<string, unknown>>(
`
select e.id, e.channel_id as "channelId", c.channel_code as "channelCode",
c.name as "channelName", e.reminder_id as "reminderId",
e.invoice_id as "invoiceId", i.invoice_no as "invoiceNo",
e.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", e.provider, e.status, e.attempts,
e.scheduled_at as "scheduledAt", e.next_attempt_at as "nextAttemptAt",
e.last_attempt_at as "lastAttemptAt", e.sent_at as "sentAt",
e.last_error as "lastError", e.last_http_code as "lastHttpCode",
e.last_response_summary as "lastResponseSummary",
e.request_payload as "requestPayload", e.metadata,
r.reminder_type as "reminderType", r.channel as "reminderChannel",
r.reminder_level as "reminderLevel", r.reminder_date as "reminderDate",
r.due_date as "dueDate", r.balance_cents_snapshot as "balanceCentsSnapshot",
r.message as "reminderMessage", i.status as "invoiceStatus",
i.balance_cents as "invoiceBalanceCents",
i.total_cents as "invoiceTotalCents",
e.created_at as "createdAt", e.updated_at as "updatedAt"
from public.platform_dunning_notification_events e
join public.platform_dunning_notification_channels c on c.id = e.channel_id
join public.tenant_invoice_reminders r on r.id = e.reminder_id
join public.tenant_invoices i on i.id = e.invoice_id
join public.tenants t on t.id = e.tenant_id
where ($1::uuid is null or e.channel_id = $1::uuid)
and ($2::uuid is null or e.reminder_id = $2::uuid)
and ($3::uuid is null or e.invoice_id = $3::uuid)
and ($4::uuid is null or e.tenant_id = $4::uuid)
and ($5::text = '' or e.status = $5)
and ($6::text = '' or e.provider = $6)
order by
case e.status when 'pending' then 1 when 'retrying' then 2 when 'processing' then 3 when 'failed' then 4 else 5 end,
e.created_at desc
limit $7
`,
[channelId || null, reminderId || null, invoiceId || null, tenantId || null, status, provider, limit],
);
return {
items: items.map(item => ({
...item,
requestPayload: redactAuditAlertValue(item.requestPayload),
lastError: truncate(item.lastError),
lastResponseSummary: truncate(item.lastResponseSummary),
})),
};
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);