forked from wangziqi/gongxue-base
feat: add platform dunning notifications
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
loadPlatformAuditLogs,
|
||||
loadPlatformAuditNotificationChannels,
|
||||
loadPlatformAuditNotificationEvents,
|
||||
loadPlatformDunningNotificationChannels,
|
||||
loadPlatformDunningNotificationEvents,
|
||||
loadPlatformInvoices,
|
||||
loadPlatformOverview,
|
||||
loadPlatformQuestionBankGrants,
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
type PlatformAuditLogItem,
|
||||
type PlatformAuditNotificationChannelItem,
|
||||
type PlatformAuditNotificationEventItem,
|
||||
type PlatformDunningNotificationChannelItem,
|
||||
type PlatformDunningNotificationEventItem,
|
||||
type PlatformInvoiceItem,
|
||||
type PlatformOverview,
|
||||
type PlatformQuestionBankGrant,
|
||||
@@ -48,6 +52,8 @@ export default function PlatformWorkbenchPage() {
|
||||
const [auditAlerts, setAuditAlerts] = useState<PlatformAuditAlertItem[]>([]);
|
||||
const [auditNotificationChannels, setAuditNotificationChannels] = useState<PlatformAuditNotificationChannelItem[]>([]);
|
||||
const [auditNotificationEvents, setAuditNotificationEvents] = useState<PlatformAuditNotificationEventItem[]>([]);
|
||||
const [dunningNotificationChannels, setDunningNotificationChannels] = useState<PlatformDunningNotificationChannelItem[]>([]);
|
||||
const [dunningNotificationEvents, setDunningNotificationEvents] = useState<PlatformDunningNotificationEventItem[]>([]);
|
||||
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
|
||||
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
@@ -64,7 +70,9 @@ export default function PlatformWorkbenchPage() {
|
||||
loadPlatformAuditAlerts({ status: 'open', limit: 6 }).catch(() => ({ items: [] })),
|
||||
loadPlatformAuditNotificationChannels({ enabled: true, limit: 6 }).catch(() => ({ items: [] })),
|
||||
loadPlatformAuditNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
|
||||
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload, channelPayload, eventPayload]) => {
|
||||
loadPlatformDunningNotificationChannels({ enabled: true, limit: 6 }).catch(() => ({ items: [] })),
|
||||
loadPlatformDunningNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
|
||||
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload, channelPayload, eventPayload, dunningChannelPayload, dunningEventPayload]) => {
|
||||
setOverview(overviewPayload.item || null);
|
||||
setTenants(tenantPayload.items || []);
|
||||
setInvoices(invoicePayload.items || []);
|
||||
@@ -74,6 +82,8 @@ export default function PlatformWorkbenchPage() {
|
||||
setAuditAlerts(alertPayload.items || []);
|
||||
setAuditNotificationChannels(channelPayload.items || []);
|
||||
setAuditNotificationEvents(eventPayload.items || []);
|
||||
setDunningNotificationChannels(dunningChannelPayload.items || []);
|
||||
setDunningNotificationEvents(dunningEventPayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败'));
|
||||
}, []);
|
||||
|
||||
@@ -230,6 +240,30 @@ export default function PlatformWorkbenchPage() {
|
||||
</View>
|
||||
{!auditNotificationChannels.length && !auditNotificationEvents.length ? <View className='platform-empty'>暂无外部通知渠道或发送事件。</View> : null}
|
||||
</View>
|
||||
<View className='platform-section'>
|
||||
<Text className='platform-section-title'>催缴外部通知</Text>
|
||||
<View className='platform-grid'>
|
||||
<View className='platform-metric'><Text className='platform-metric-label'>启用渠道</Text><Text className='platform-metric-value'>{String(dunningNotificationChannels.length)}</Text></View>
|
||||
<View className='platform-metric'><Text className='platform-metric-label'>最近事件</Text><Text className='platform-metric-value'>{String(dunningNotificationEvents.length)}</Text></View>
|
||||
<View className='platform-metric'><Text className='platform-metric-label'>失败事件</Text><Text className='platform-metric-value'>{String(dunningNotificationEvents.filter(item => item.status === 'failed').length)}</Text></View>
|
||||
<View className='platform-metric'><Text className='platform-metric-label'>待重试</Text><Text className='platform-metric-value'>{String(dunningNotificationEvents.filter(item => item.status === 'retrying').length)}</Text></View>
|
||||
</View>
|
||||
<View className='platform-list'>
|
||||
{dunningNotificationChannels.map(item => (
|
||||
<View className='platform-row' key={item.id}>
|
||||
<Text className='platform-row-main'>{item.name || item.channelCode || '-'}</Text>
|
||||
<Text className='platform-row-meta'>{item.provider || '-'} · level {String(item.minReminderLevel || '-')} · {item.webhook?.host || '-'} · {item.reminderTypes?.join('/') || '-'}</Text>
|
||||
</View>
|
||||
))}
|
||||
{dunningNotificationEvents.map(item => (
|
||||
<View className='platform-row' key={item.id}>
|
||||
<Text className='platform-row-main'>{item.invoiceNo || item.invoiceId || '-'}</Text>
|
||||
<Text className='platform-row-meta'>{item.channelName || item.channelCode || '-'} · {item.status || '-'} · HTTP {String(item.lastHttpCode || '-')} · {item.tenantName || item.tenantSlug || '-'} · 欠款 {money(item.invoiceBalanceCents)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!dunningNotificationChannels.length && !dunningNotificationEvents.length ? <View className='platform-empty'>暂无催缴通知渠道或发送事件。</View> : null}
|
||||
</View>
|
||||
{error ? <Text className='platform-error'>{error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -328,6 +328,55 @@ export interface PlatformAuditNotificationEventItem {
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformDunningNotificationChannelItem {
|
||||
id: string;
|
||||
channelCode?: string | null;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
enabled?: boolean | null;
|
||||
provider?: string | null;
|
||||
secretRef?: string | null;
|
||||
reminderTypes?: string[] | null;
|
||||
reminderChannels?: string[] | null;
|
||||
minReminderLevel?: number | string | null;
|
||||
tenantIds?: string[] | null;
|
||||
timeoutSec?: number | string | null;
|
||||
webhook?: {
|
||||
protocol?: string | null;
|
||||
host?: string | null;
|
||||
pathname?: string | null;
|
||||
} | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformDunningNotificationEventItem {
|
||||
id: string;
|
||||
channelId?: string | null;
|
||||
channelCode?: string | null;
|
||||
channelName?: string | null;
|
||||
reminderId?: string | null;
|
||||
invoiceId?: string | null;
|
||||
invoiceNo?: string | null;
|
||||
tenantId?: string | null;
|
||||
tenantName?: string | null;
|
||||
tenantSlug?: string | null;
|
||||
provider?: string | null;
|
||||
status?: string | null;
|
||||
attempts?: number | string | null;
|
||||
lastHttpCode?: number | string | null;
|
||||
lastError?: string | null;
|
||||
lastResponseSummary?: string | null;
|
||||
reminderType?: string | null;
|
||||
reminderChannel?: string | null;
|
||||
reminderLevel?: number | string | null;
|
||||
invoiceBalanceCents?: number | string | null;
|
||||
sentAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePlatformTenantInput {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -521,6 +570,32 @@ export async function loadPlatformAuditNotificationEvents(query: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPlatformDunningNotificationChannels(query: {
|
||||
enabled?: boolean;
|
||||
provider?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return apiRequest<{ items?: PlatformDunningNotificationChannelItem[] }>('/api/platform-admin/dunning-notification-channels', {
|
||||
query: { ...query, limit: query.limit || 50 },
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPlatformDunningNotificationEvents(query: {
|
||||
channelId?: string;
|
||||
reminderId?: string;
|
||||
invoiceId?: string;
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
provider?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return apiRequest<{ items?: PlatformDunningNotificationEventItem[] }>('/api/platform-admin/dunning-notification-events', {
|
||||
query: { ...query, limit: query.limit || 50 },
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', {
|
||||
query: { ...query, limit: query.limit || 80 },
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"provider-bills:once": "tsx src/index.ts --once --job provider-bills",
|
||||
"platform-billing:once": "tsx src/index.ts --once --job platform-billing",
|
||||
"platform-dunning:once": "tsx src/index.ts --once --job platform-dunning",
|
||||
"platform-dunning-notifications:once": "tsx src/index.ts --once --job platform-dunning-notifications",
|
||||
"platform-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts",
|
||||
"platform-audit-notifications:once": "tsx src/index.ts --once --job platform-audit-notifications",
|
||||
"assets:once": "tsx src/index.ts --once --job assets",
|
||||
|
||||
@@ -24,6 +24,11 @@ export interface WorkerConfig {
|
||||
platformBillingWorkerId: string;
|
||||
platformDunningBatchSize: number;
|
||||
platformDunningWorkerId: string;
|
||||
platformDunningNotificationBatchSize: number;
|
||||
platformDunningNotificationMaxAttempts: number;
|
||||
platformDunningNotificationBackoffSeconds: number[];
|
||||
platformDunningNotificationRequestTimeoutMs: number;
|
||||
platformDunningNotificationAllowInsecureLocalhost: boolean;
|
||||
platformAuditAlertBatchSize: number;
|
||||
platformAuditAlertWorkerId: string;
|
||||
platformAuditAlertLookbackDays: number;
|
||||
@@ -119,6 +124,9 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
|
||||
if (nextConfig.platformAuditNotificationAllowInsecureLocalhost) {
|
||||
failures.push('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true is not allowed in production workers');
|
||||
}
|
||||
if (nextConfig.platformDunningNotificationAllowInsecureLocalhost) {
|
||||
failures.push('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true is not allowed in production workers');
|
||||
}
|
||||
const scannerModes = nextConfig.assetSecurityScanner
|
||||
.split(',')
|
||||
.map(item => item.trim().toLowerCase())
|
||||
@@ -189,6 +197,13 @@ const loadedConfig: WorkerConfig = {
|
||||
platformBillingWorkerId: envString('WORKER_PLATFORM_BILLING_ID', `platform-billing-${process.pid}`),
|
||||
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
|
||||
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
|
||||
platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50),
|
||||
platformDunningNotificationMaxAttempts: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS', 5),
|
||||
platformDunningNotificationBackoffSeconds: envList('WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800')
|
||||
.map((value: string) => Number(value))
|
||||
.filter((value: number) => Number.isFinite(value) && value > 0),
|
||||
platformDunningNotificationRequestTimeoutMs: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS', 10_000),
|
||||
platformDunningNotificationAllowInsecureLocalhost: envBoolean('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false),
|
||||
platformAuditAlertBatchSize: envNumber('WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE', 200),
|
||||
platformAuditAlertWorkerId: envString('WORKER_PLATFORM_AUDIT_ALERT_ID', `platform-audit-alerts-${process.pid}`),
|
||||
platformAuditAlertLookbackDays: envNumber('WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS', 14),
|
||||
|
||||
@@ -61,6 +61,16 @@ async function runOnce() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (job === 'platform-dunning-notifications') {
|
||||
const { processPlatformDunningNotificationBatch } = await import('./jobs/platform-dunning-notifications.js');
|
||||
const result = await processPlatformDunningNotificationBatch();
|
||||
console.log(
|
||||
`[worker] platform-dunning-notifications batch enqueued=${result.enqueued}`
|
||||
+ ` processed=${result.processed} sent=${result.sent} failed=${result.failed}`
|
||||
+ ` retrying=${result.retrying} discarded=${result.discarded}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (job === 'platform-audit-alerts') {
|
||||
const { processPlatformAuditAlertBatch } = await import('./jobs/platform-audit-alerts.js');
|
||||
const result = await processPlatformAuditAlertBatch();
|
||||
|
||||
702
apps/worker/src/jobs/platform-dunning-notifications.ts
Normal file
702
apps/worker/src/jobs/platform-dunning-notifications.ts
Normal file
@@ -0,0 +1,702 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { pool } from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const PROVIDERS = ['generic', 'dingtalk', 'feishu', 'wecom'] as const;
|
||||
type NotificationProvider = typeof PROVIDERS[number];
|
||||
|
||||
interface DunningChannelRow {
|
||||
id: string;
|
||||
channelCode: string;
|
||||
name: string;
|
||||
provider: NotificationProvider;
|
||||
webhookUrl: string;
|
||||
secretRef: string | null;
|
||||
reminderTypes: string[];
|
||||
reminderChannels: string[];
|
||||
minReminderLevel: number;
|
||||
tenantIds: string[];
|
||||
timeoutSec: number | null;
|
||||
}
|
||||
|
||||
interface DunningReminderRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
invoiceId: string;
|
||||
reminderType: string;
|
||||
channel: string;
|
||||
reminderLevel: number;
|
||||
}
|
||||
|
||||
interface DunningEventRow {
|
||||
id: string;
|
||||
channelId: string;
|
||||
channelCode: string;
|
||||
channelName: string;
|
||||
reminderId: string;
|
||||
invoiceId: string;
|
||||
tenantId: string;
|
||||
provider: NotificationProvider;
|
||||
attempts: number;
|
||||
webhookUrl: string;
|
||||
secretRef: string | null;
|
||||
timeoutSec: number | null;
|
||||
tenantSlug: string;
|
||||
tenantName: string;
|
||||
legalName: string | null;
|
||||
billingStatus: string;
|
||||
invoiceNo: string;
|
||||
invoiceType: string;
|
||||
invoiceStatus: string;
|
||||
currency: string;
|
||||
totalCents: number;
|
||||
paidCents: number;
|
||||
balanceCents: number;
|
||||
dueDate: string | null;
|
||||
issuedAt: string | null;
|
||||
reminderType: string;
|
||||
reminderChannel: string;
|
||||
reminderStatus: string;
|
||||
reminderDate: string;
|
||||
reminderLevel: number;
|
||||
balanceCentsSnapshot: number;
|
||||
message: string | null;
|
||||
reminderMetadata: Record<string, unknown> | null;
|
||||
billingName: string | null;
|
||||
contactName: string | null;
|
||||
contactPhone: string | null;
|
||||
contactEmail: string | null;
|
||||
}
|
||||
|
||||
interface SecretRow {
|
||||
secretValue: string | null;
|
||||
secretJson: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface PreparedRequest {
|
||||
provider: NotificationProvider;
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
ok: boolean;
|
||||
httpCode: number;
|
||||
responseSummary: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
interface ProcessResult {
|
||||
enqueued: number;
|
||||
processed: number;
|
||||
sent: number;
|
||||
failed: number;
|
||||
retrying: number;
|
||||
discarded: number;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function truncate(value: unknown, max = 1900) {
|
||||
return String(value ?? '').slice(0, max);
|
||||
}
|
||||
|
||||
function maskPhone(value: string | null) {
|
||||
if (!value) return null;
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (digits.length < 7) return '***';
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
function maskEmail(value: string | null) {
|
||||
if (!value) return null;
|
||||
const [name, domain] = value.split('@');
|
||||
if (!name || !domain) return '***';
|
||||
return `${name.slice(0, 2)}***@${domain}`;
|
||||
}
|
||||
|
||||
function redactDunningNotificationValue(value: unknown, parentKey = '', depth = 0): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (depth > 8) return '[REDACTED_DEPTH_LIMIT]';
|
||||
if (
|
||||
/(?:password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|app[_-]?secret|authorization|cookie|session|cert|signature|nonce)$/i
|
||||
.test(parentKey)
|
||||
) {
|
||||
return '[REDACTED]';
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(item => redactDunningNotificationValue(item, parentKey, depth + 1));
|
||||
if (typeof value === 'object') {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
output[key] = redactDunningNotificationValue(item, key, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSecretRef(ref: string | null) {
|
||||
if (!ref) return null;
|
||||
const parts = ref.split(':');
|
||||
if (parts.length !== 3 || parts[0] !== 'app_private.platform_secrets') return null;
|
||||
return { scope: parts[1], key: parts[2] };
|
||||
}
|
||||
|
||||
function secretText(secret: SecretRow | null, keys: string[]) {
|
||||
if (!secret) return '';
|
||||
if (secret.secretValue?.trim()) return secret.secretValue.trim();
|
||||
const json = objectValue(secret.secretJson);
|
||||
for (const key of keys) {
|
||||
const value = json[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateWebhookUrl(rawUrl: string) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error('Platform dunning notification webhook URL is invalid');
|
||||
}
|
||||
const isLocalhost = ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
|
||||
if (url.protocol !== 'https:' && !(config.platformDunningNotificationAllowInsecureLocalhost && isLocalhost)) {
|
||||
throw new Error('Platform dunning notification webhook URL must use HTTPS outside local development');
|
||||
}
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
return url;
|
||||
}
|
||||
|
||||
function dingtalkSign(secret: string): Record<string, string> {
|
||||
if (!secret) return {};
|
||||
const timestamp = String(Date.now());
|
||||
const sign = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(`${timestamp}\n${secret}`)
|
||||
.digest('base64');
|
||||
return { timestamp, sign };
|
||||
}
|
||||
|
||||
function feishuSign(secret: string): Record<string, string> {
|
||||
if (!secret) return {};
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const sign = crypto
|
||||
.createHmac('sha256', `${timestamp}\n${secret}`)
|
||||
.update('')
|
||||
.digest('base64');
|
||||
return { timestamp, sign };
|
||||
}
|
||||
|
||||
function appendQuery(url: URL, params: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function dunningPayload(task: DunningEventRow) {
|
||||
return {
|
||||
event: 'platform.invoice.dunning_reminder',
|
||||
tenant: {
|
||||
id: task.tenantId,
|
||||
slug: task.tenantSlug,
|
||||
name: task.tenantName,
|
||||
legalName: task.legalName,
|
||||
billingStatus: task.billingStatus,
|
||||
},
|
||||
invoice: {
|
||||
id: task.invoiceId,
|
||||
invoiceNo: task.invoiceNo,
|
||||
invoiceType: task.invoiceType,
|
||||
status: task.invoiceStatus,
|
||||
currency: task.currency,
|
||||
totalCents: Number(task.totalCents || 0),
|
||||
paidCents: Number(task.paidCents || 0),
|
||||
balanceCents: Number(task.balanceCents || 0),
|
||||
dueDate: task.dueDate,
|
||||
issuedAt: task.issuedAt,
|
||||
},
|
||||
reminder: {
|
||||
id: task.reminderId,
|
||||
type: task.reminderType,
|
||||
channel: task.reminderChannel,
|
||||
status: task.reminderStatus,
|
||||
date: task.reminderDate,
|
||||
level: Number(task.reminderLevel || 0),
|
||||
balanceCentsSnapshot: Number(task.balanceCentsSnapshot || 0),
|
||||
message: task.message,
|
||||
metadata: redactDunningNotificationValue(task.reminderMetadata || {}),
|
||||
},
|
||||
billingContact: {
|
||||
billingName: task.billingName,
|
||||
contactName: task.contactName,
|
||||
phoneMasked: maskPhone(task.contactPhone),
|
||||
emailMasked: maskEmail(task.contactEmail),
|
||||
},
|
||||
source: {
|
||||
channelId: task.channelId,
|
||||
channelCode: task.channelCode,
|
||||
eventId: task.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dunningMarkdown(task: DunningEventRow) {
|
||||
return [
|
||||
`### SaaS 服务费催缴:${task.tenantName}`,
|
||||
`- 租户:${task.tenantName} (${task.tenantSlug})`,
|
||||
`- 账单号:${task.invoiceNo}`,
|
||||
`- 账单状态:${task.invoiceStatus}`,
|
||||
`- 未结清金额:${Math.round(Number(task.balanceCents || 0)) / 100} ${task.currency}`,
|
||||
`- 到期日:${task.dueDate || '未设置'}`,
|
||||
`- 催缴类型:${task.reminderType}`,
|
||||
`- 催缴级别:${task.reminderLevel}`,
|
||||
`- 联系人:${task.contactName || '未设置'} ${maskPhone(task.contactPhone) || ''}`,
|
||||
`- 催缴记录ID:${task.reminderId}`,
|
||||
`- 备注:${task.message || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function prepareRequest(task: DunningEventRow, secret: SecretRow | null): PreparedRequest {
|
||||
const target = validateWebhookUrl(task.webhookUrl);
|
||||
const provider = task.provider;
|
||||
const secretValue = secretText(secret, ['secret', 'signSecret', 'webhookSecret']);
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
const markdown = dunningMarkdown(task);
|
||||
|
||||
if (provider === 'dingtalk') {
|
||||
return {
|
||||
provider,
|
||||
url: appendQuery(target, dingtalkSign(secretValue)),
|
||||
headers,
|
||||
body: {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
title: 'SaaS 服务费催缴',
|
||||
text: markdown,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'feishu') {
|
||||
return {
|
||||
provider,
|
||||
url: target.toString(),
|
||||
headers,
|
||||
body: {
|
||||
msg_type: 'interactive',
|
||||
...feishuSign(secretValue),
|
||||
card: {
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { tag: 'plain_text', content: 'SaaS 服务费催缴' }, template: 'orange' },
|
||||
elements: [{ tag: 'markdown', content: markdown }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'wecom') {
|
||||
return {
|
||||
provider,
|
||||
url: target.toString(),
|
||||
headers,
|
||||
body: {
|
||||
msgtype: 'markdown',
|
||||
markdown: { content: markdown },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
url: target.toString(),
|
||||
headers,
|
||||
body: dunningPayload(task),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadEnabledChannels(client: pg.PoolClient) {
|
||||
const result = await client.query<DunningChannelRow>(
|
||||
`
|
||||
select id, channel_code as "channelCode", name, 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"
|
||||
from public.platform_dunning_notification_channels
|
||||
where enabled = true
|
||||
order by min_reminder_level asc, created_at asc
|
||||
`,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function loadPendingReminders(client: pg.PoolClient, limit: number) {
|
||||
const result = await client.query<DunningReminderRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId", invoice_id as "invoiceId",
|
||||
reminder_type as "reminderType", channel, reminder_level as "reminderLevel"
|
||||
from public.tenant_invoice_reminders
|
||||
where status in ('pending', 'failed')
|
||||
and reminder_type in ('due_soon', 'overdue', 'final_notice', 'manual')
|
||||
order by reminder_level desc, created_at asc
|
||||
limit $1
|
||||
`,
|
||||
[limit],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function enqueueNotificationEvents(client: pg.PoolClient, limit: number) {
|
||||
const channels = await loadEnabledChannels(client);
|
||||
if (!channels.length) return 0;
|
||||
const reminders = await loadPendingReminders(client, Math.max(limit * 4, 50));
|
||||
let enqueued = 0;
|
||||
|
||||
for (const reminder of reminders) {
|
||||
for (const channel of channels) {
|
||||
if (enqueued >= limit) return enqueued;
|
||||
if (!channel.reminderTypes.includes(reminder.reminderType)) continue;
|
||||
if (!channel.reminderChannels.includes(reminder.channel)) continue;
|
||||
if (Number(reminder.reminderLevel || 0) < Number(channel.minReminderLevel || 1)) continue;
|
||||
if (channel.tenantIds.length && !channel.tenantIds.includes(reminder.tenantId)) continue;
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.platform_dunning_notification_events (
|
||||
channel_id, reminder_id, invoice_id, tenant_id, provider, status,
|
||||
request_payload, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'pending', $6::jsonb, $7::jsonb)
|
||||
on conflict (channel_id, reminder_id) do nothing
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
channel.id,
|
||||
reminder.id,
|
||||
reminder.invoiceId,
|
||||
reminder.tenantId,
|
||||
channel.provider,
|
||||
JSON.stringify({
|
||||
channelCode: channel.channelCode,
|
||||
reminderId: reminder.id,
|
||||
invoiceId: reminder.invoiceId,
|
||||
tenantId: reminder.tenantId,
|
||||
reminderType: reminder.reminderType,
|
||||
}),
|
||||
JSON.stringify({ enqueuedBy: 'platform-dunning-notifications-worker' }),
|
||||
],
|
||||
);
|
||||
if (result.rowCount) enqueued += 1;
|
||||
}
|
||||
}
|
||||
return enqueued;
|
||||
}
|
||||
|
||||
async function claimDueEvents(client: pg.PoolClient, limit: number) {
|
||||
const result = await client.query<DunningEventRow>(
|
||||
`
|
||||
with due as (
|
||||
select e.id
|
||||
from public.platform_dunning_notification_events e
|
||||
where e.status in ('pending', 'retrying')
|
||||
and coalesce(e.next_attempt_at, e.scheduled_at, e.created_at) <= now()
|
||||
order by coalesce(e.next_attempt_at, e.scheduled_at, e.created_at) asc, e.created_at asc
|
||||
limit $1
|
||||
for update skip locked
|
||||
)
|
||||
update public.platform_dunning_notification_events e
|
||||
set status = 'processing',
|
||||
last_attempt_at = now(),
|
||||
updated_at = now()
|
||||
from due
|
||||
join public.platform_dunning_notification_channels c on true
|
||||
join public.tenant_invoice_reminders r on true
|
||||
join public.tenant_invoices i on true
|
||||
join public.tenants t on true
|
||||
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
|
||||
where e.id = due.id
|
||||
and c.id = e.channel_id
|
||||
and r.id = e.reminder_id
|
||||
and i.id = e.invoice_id
|
||||
and t.id = e.tenant_id
|
||||
returning 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", e.tenant_id as "tenantId",
|
||||
e.provider, e.attempts, c.webhook_url as "webhookUrl",
|
||||
c.secret_ref as "secretRef", c.timeout_sec as "timeoutSec",
|
||||
t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||
t.legal_name as "legalName", t.billing_status as "billingStatus",
|
||||
i.invoice_no as "invoiceNo", i.invoice_type as "invoiceType",
|
||||
i.status as "invoiceStatus", i.currency, i.total_cents as "totalCents",
|
||||
i.paid_cents as "paidCents", i.balance_cents as "balanceCents",
|
||||
i.due_date as "dueDate", i.issued_at as "issuedAt",
|
||||
r.reminder_type as "reminderType", r.channel as "reminderChannel",
|
||||
r.status as "reminderStatus", r.reminder_date as "reminderDate",
|
||||
r.reminder_level as "reminderLevel",
|
||||
r.balance_cents_snapshot as "balanceCentsSnapshot",
|
||||
r.message, r.metadata as "reminderMetadata",
|
||||
bp.billing_name as "billingName", bp.contact_name as "contactName",
|
||||
bp.contact_phone as "contactPhone", bp.contact_email as "contactEmail"
|
||||
`,
|
||||
[limit],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function recoverStaleProcessingEvents(client: pg.PoolClient) {
|
||||
const staleMs = Math.max(config.platformDunningNotificationRequestTimeoutMs * 3, 60_000);
|
||||
await client.query(
|
||||
`
|
||||
update public.platform_dunning_notification_events
|
||||
set status = 'retrying',
|
||||
next_attempt_at = now(),
|
||||
last_error = coalesce(last_error, 'Recovered stale processing platform dunning notification'),
|
||||
updated_at = now()
|
||||
where status = 'processing'
|
||||
and coalesce(last_attempt_at, updated_at, created_at) < now() - ($1::int * interval '1 millisecond')
|
||||
`,
|
||||
[staleMs],
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSecret(client: pg.PoolClient, secretRef: string | null) {
|
||||
const parsed = parseSecretRef(secretRef);
|
||||
if (!parsed) return null;
|
||||
const result = await client.query<SecretRow>(
|
||||
`
|
||||
select secret_value as "secretValue", secret_json as "secretJson"
|
||||
from app_private.platform_secrets
|
||||
where secret_scope = $1
|
||||
and secret_key = $2
|
||||
limit 1
|
||||
`,
|
||||
[parsed.scope, parsed.key],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function sendWebhook(request: PreparedRequest, timeoutMs: number): Promise<SendResult> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(request.url, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(request.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text().catch(() => '');
|
||||
return {
|
||||
ok: response.ok,
|
||||
httpCode: response.status,
|
||||
responseSummary: truncate(text, 1900),
|
||||
errorMessage: response.ok ? '' : `Platform dunning notification webhook returned HTTP ${response.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
httpCode: 0,
|
||||
responseSummary: '',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function markEventResult(
|
||||
client: pg.PoolClient,
|
||||
task: DunningEventRow,
|
||||
request: PreparedRequest | null,
|
||||
result: SendResult,
|
||||
attempt: number,
|
||||
) {
|
||||
const backoff = config.platformDunningNotificationBackoffSeconds[Math.min(attempt - 1, config.platformDunningNotificationBackoffSeconds.length - 1)] || 60;
|
||||
const requestPayload = {
|
||||
provider: request?.provider || task.provider,
|
||||
urlHost: request ? new URL(request.url).hostname : null,
|
||||
body: request?.body ? redactDunningNotificationValue(request.body) : null,
|
||||
};
|
||||
|
||||
if (result.ok) {
|
||||
await client.query(
|
||||
`
|
||||
update public.platform_dunning_notification_events
|
||||
set status = 'sent',
|
||||
attempts = $2,
|
||||
next_attempt_at = null,
|
||||
last_error = null,
|
||||
last_http_code = $3,
|
||||
last_response_summary = $4,
|
||||
request_payload = $5::jsonb,
|
||||
sent_at = now(),
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[task.id, attempt, result.httpCode, truncate(result.responseSummary), JSON.stringify(requestPayload)],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.tenant_invoice_reminders
|
||||
set status = 'sent',
|
||||
sent_at = coalesce(sent_at, now()),
|
||||
metadata = metadata || $2::jsonb,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and status in ('pending', 'failed')
|
||||
`,
|
||||
[
|
||||
task.reminderId,
|
||||
JSON.stringify({
|
||||
externalNotification: {
|
||||
status: 'sent',
|
||||
eventId: task.id,
|
||||
channelCode: task.channelCode,
|
||||
sentAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
const terminal = attempt >= config.platformDunningNotificationMaxAttempts;
|
||||
await client.query(
|
||||
`
|
||||
update public.platform_dunning_notification_events
|
||||
set status = $2,
|
||||
attempts = $3,
|
||||
next_attempt_at = case when $2 = 'retrying' then now() + ($4::int * interval '1 second') else null end,
|
||||
last_error = $5,
|
||||
last_http_code = $6,
|
||||
last_response_summary = $7,
|
||||
request_payload = $8::jsonb,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[
|
||||
task.id,
|
||||
terminal ? 'failed' : 'retrying',
|
||||
attempt,
|
||||
backoff,
|
||||
truncate(result.errorMessage),
|
||||
result.httpCode,
|
||||
truncate(result.responseSummary),
|
||||
JSON.stringify(requestPayload),
|
||||
],
|
||||
);
|
||||
|
||||
if (terminal) {
|
||||
await client.query(
|
||||
`
|
||||
update public.tenant_invoice_reminders
|
||||
set status = 'failed',
|
||||
metadata = metadata || $2::jsonb,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and status in ('pending', 'failed')
|
||||
`,
|
||||
[
|
||||
task.reminderId,
|
||||
JSON.stringify({
|
||||
externalNotification: {
|
||||
status: 'failed',
|
||||
eventId: task.id,
|
||||
channelCode: task.channelCode,
|
||||
failedAt: new Date().toISOString(),
|
||||
lastError: truncate(result.errorMessage, 500),
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return terminal ? 'failed' : 'retrying';
|
||||
}
|
||||
|
||||
async function discardEvent(client: pg.PoolClient, task: DunningEventRow, message: string) {
|
||||
await client.query(
|
||||
`
|
||||
update public.platform_dunning_notification_events
|
||||
set status = 'discarded',
|
||||
attempts = attempts + 1,
|
||||
last_attempt_at = now(),
|
||||
last_error = $2,
|
||||
request_payload = $3::jsonb,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[
|
||||
task.id,
|
||||
truncate(message),
|
||||
JSON.stringify({
|
||||
provider: task.provider,
|
||||
urlHost: '',
|
||||
body: null,
|
||||
}),
|
||||
],
|
||||
);
|
||||
return 'discarded';
|
||||
}
|
||||
|
||||
async function processEvent(task: DunningEventRow) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
if (!PROVIDERS.includes(task.provider)) {
|
||||
return discardEvent(client, task, 'Unsupported platform dunning notification provider');
|
||||
}
|
||||
const secret = await loadSecret(client, task.secretRef);
|
||||
const request = prepareRequest(task, secret);
|
||||
const timeoutMs = Math.max(1000, (task.timeoutSec || 0) * 1000 || config.platformDunningNotificationRequestTimeoutMs);
|
||||
const attempt = task.attempts + 1;
|
||||
const result = await sendWebhook(request, timeoutMs);
|
||||
return markEventResult(client, task, request, result, attempt);
|
||||
} catch (error) {
|
||||
const attempt = task.attempts + 1;
|
||||
return markEventResult(client, task, null, {
|
||||
ok: false,
|
||||
httpCode: 0,
|
||||
responseSummary: '',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
}, attempt);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function processPlatformDunningNotificationBatch(limit = config.platformDunningNotificationBatchSize): Promise<ProcessResult> {
|
||||
const client = await pool.connect();
|
||||
let tasks: DunningEventRow[] = [];
|
||||
let enqueued = 0;
|
||||
try {
|
||||
await client.query('begin');
|
||||
await recoverStaleProcessingEvents(client);
|
||||
enqueued = await enqueueNotificationEvents(client, limit);
|
||||
tasks = await claimDueEvents(client, limit);
|
||||
await client.query('commit');
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
const result: ProcessResult = { enqueued, processed: tasks.length, sent: 0, failed: 0, retrying: 0, discarded: 0 };
|
||||
for (const task of tasks) {
|
||||
const status = await processEvent(task);
|
||||
if (status === 'sent') result.sent += 1;
|
||||
if (status === 'failed') result.failed += 1;
|
||||
if (status === 'retrying') result.retrying += 1;
|
||||
if (status === 'discarded') result.discarded += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user