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

@@ -1602,6 +1602,107 @@ async function testPlatformTenantOperationsAndAudit() {
query: { tenantId, invoiceId: ids.platformOverdueInvoice, limit: 10 },
});
assert.ok(reminders.items?.some(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue'), 'platform admin should list invoice reminders');
const platformReminder = reminders.items?.find(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue');
assert.ok(platformReminder?.id, 'platform overdue reminder should expose reminder id');
const dunningChannel = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
channelCode: 'integration_platform_dunning',
name: '集成测试平台催缴通知',
provider: 'generic',
webhookUrl: 'https://ops.example.test/platform-dunning',
secret: 'integration-platform-dunning-notification-secret',
reminderTypes: ['overdue'],
reminderChannels: ['internal'],
minReminderLevel: 1,
tenantIds: [tenantId],
timeoutSec: 5,
metadata: { owner: 'finance' },
},
});
assert.equal(dunningChannel.item?.channelCode, 'integration_platform_dunning', 'platform admin should upsert dunning notification channel');
assert.equal(dunningChannel.item?.secretRef, 'app_private.platform_secrets:webhook:platform_dunning_integration_platform_dunning', 'dunning channel should expose only platform secretRef');
assert.equal(dunningChannel.item?.webhook?.host, 'ops.example.test', 'dunning channel response should expose safe webhook host');
assert.equal(dunningChannel.item?.webhookUrl, undefined, 'dunning channel response must not expose raw webhook URL');
assert.ok(!JSON.stringify(dunningChannel).includes('integration-platform-dunning-notification-secret'), 'dunning channel response must not leak webhook secret');
const dunningChannels = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { enabled: true, provider: 'generic', limit: 20 },
});
assert.ok(
dunningChannels.items?.some(item => item.channelCode === 'integration_platform_dunning'),
'platform admin should list dunning notification channels',
);
assert.ok(!JSON.stringify(dunningChannels).includes('integration-platform-dunning-notification-secret'), 'dunning channel list must not leak webhook secret');
const invalidDunningChannel = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
channelCode: 'bad_dunning_channel',
name: 'bad dunning channel',
provider: 'generic',
webhookUrl: 'ftp://ops.example.test/hook',
},
expectStatus: 400,
});
assert.equal(invalidDunningChannel.code, 'INVALID_WEBHOOK_URL', 'dunning notification channel should reject unsafe webhook URL');
const dunningEventPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
let platformDunningNotificationEventId = '';
try {
const channelRow = await dunningEventPool.query(
"select id from public.platform_dunning_notification_channels where channel_code = 'integration_platform_dunning' limit 1",
);
const insertedEvent = await dunningEventPool.query(
`
insert into public.platform_dunning_notification_events (
channel_id, reminder_id, invoice_id, tenant_id, provider, status, attempts,
last_http_code, request_payload, last_response_summary
)
values (
$1, $2, $3, $4, 'generic', 'sent', 1,
200,
'{"body":{"token":"must-not-leak","nested":{"password":"must-not-leak"}}}'::jsonb,
'{"ok":true}'
)
on conflict (channel_id, reminder_id)
do update set status = excluded.status,
attempts = excluded.attempts,
request_payload = excluded.request_payload,
updated_at = now()
returning id
`,
[channelRow.rows[0].id, platformReminder.id, ids.platformOverdueInvoice, tenantId],
);
platformDunningNotificationEventId = insertedEvent.rows[0].id;
} finally {
await dunningEventPool.end();
}
const dunningEvents = await request('/api/platform-admin/dunning-notification-events', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { reminderId: platformReminder.id, status: 'sent', limit: 20 },
});
assert.ok(
dunningEvents.items?.some(item => item.id === platformDunningNotificationEventId),
'platform admin should list dunning notification events',
);
const listedDunningEvent = dunningEvents.items?.find(item => item.id === platformDunningNotificationEventId);
assert.equal(listedDunningEvent?.requestPayload?.body?.token, '[REDACTED]', 'dunning event list should redact token-like payload details');
assert.equal(listedDunningEvent?.requestPayload?.body?.nested?.password, '[REDACTED]', 'dunning event list should redact nested password-like payload details');
assert.ok(!JSON.stringify(dunningEvents).includes('must-not-leak'), 'dunning event list must not leak sensitive details');
const overdueAudit = await request('/api/platform-admin/audit-logs', {
tenantId: false,
@@ -1668,6 +1769,20 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.equal(studentAuditNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification events');
const studentDunningNotificationChannelDenied = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentDunningNotificationChannelDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform dunning notification channels');
const studentDunningNotificationEventDenied = await request('/api/platform-admin/dunning-notification-events', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentDunningNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform dunning notification events');
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: USER_ID,