payment-kit 1.24.1 → 1.24.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,725 @@
1
+ import { Notification } from '@blocklet/sdk';
2
+ import { getUrl } from '@blocklet/sdk/lib/component';
3
+ import { env } from '@blocklet/sdk/lib/config';
4
+ import { fromUnitToToken, BN } from '@ocap/util';
5
+ import { Op } from 'sequelize';
6
+ import { withQuery } from 'ufo';
7
+ import { getUserLocale } from '../integrations/blocklet/notification';
8
+ import dayjs from '../libs/dayjs';
9
+ import { overdueThreshold } from '../libs/env';
10
+ import logger from '../libs/logger';
11
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from '../libs/notification/template/base';
12
+ import { getOwnerDid } from '../libs/util';
13
+ import { translate } from '../locales';
14
+ import {
15
+ Event,
16
+ Invoice,
17
+ InvoiceItem,
18
+ Meter,
19
+ MeterEvent,
20
+ PaymentCurrency,
21
+ PaymentIntent,
22
+ Price,
23
+ Refund,
24
+ Subscription,
25
+ } from '../store/models';
26
+
27
+ function getAppName() {
28
+ return env.appName;
29
+ }
30
+
31
+ interface PendingConsumptionMetrics {
32
+ customerCount: number;
33
+ pendingAmounts: string;
34
+ exceedsThreshold: boolean;
35
+ }
36
+
37
+ interface FinancialMetrics {
38
+ totalRevenue: string;
39
+ refundAmount: string;
40
+ refundCount: number;
41
+ }
42
+
43
+ interface SubscriptionMetrics {
44
+ newSubscriptions: number;
45
+ canceledSubscriptions: number;
46
+ pastDueSubscriptions: number;
47
+ }
48
+
49
+ interface PaymentMetrics {
50
+ succeededCount: number;
51
+ succeededAmount: string;
52
+ failedCount: number;
53
+ failedAmount: string;
54
+ successRate: string;
55
+ }
56
+
57
+ interface MeteringAnomaliesMetrics {
58
+ unreportedCount: number;
59
+ unreportedIds: string[];
60
+ discrepantCount: number;
61
+ discrepantIds: string[];
62
+ }
63
+
64
+ interface HealthReportContext {
65
+ locale: string;
66
+ userDid: string;
67
+ pendingConsumption: PendingConsumptionMetrics;
68
+ financial: FinancialMetrics;
69
+ subscription: SubscriptionMetrics;
70
+ payment: PaymentMetrics;
71
+ meteringAnomalies: MeteringAnomaliesMetrics;
72
+ viewDataOverviewUrl: string;
73
+ viewSubscriptionsUrl: string;
74
+ viewOverdueUrl: string;
75
+ }
76
+
77
+ export class HealthReportTemplate implements BaseEmailTemplate<HealthReportContext> {
78
+ private timeRange: { start: number; end: number };
79
+ options: {
80
+ timeRange: { start: number; end: number };
81
+ };
82
+
83
+ constructor() {
84
+ const end = dayjs();
85
+ const start = end.subtract(24, 'hours');
86
+ this.timeRange = {
87
+ start: start.unix(),
88
+ end: end.unix(),
89
+ };
90
+ this.options = {
91
+ timeRange: this.timeRange,
92
+ };
93
+ }
94
+
95
+ private async getPendingConsumptionMetrics(): Promise<PendingConsumptionMetrics> {
96
+ const { sequelize } = MeterEvent;
97
+ if (!sequelize) {
98
+ return { customerCount: 0, pendingAmounts: '0', exceedsThreshold: false };
99
+ }
100
+
101
+ const { start, end } = this.timeRange;
102
+ const startTime = dayjs.unix(start).toISOString();
103
+ const endTime = dayjs.unix(end).toISOString();
104
+
105
+ // 1. Get all currency configurations
106
+ const allCurrencies = await PaymentCurrency.findAll();
107
+ const currencyMap = new Map(allCurrencies.map((c) => [c.id, c]));
108
+
109
+ // 2. Fetch all pending meter events in the last 24 hours
110
+ const events = await MeterEvent.findAll({
111
+ where: {
112
+ status: ['pending', 'requires_capture', 'requires_action'],
113
+ created_at: {
114
+ [Op.between]: [startTime, endTime],
115
+ },
116
+ },
117
+ });
118
+
119
+ if (events.length === 0) {
120
+ return { customerCount: 0, pendingAmounts: '0', exceedsThreshold: false };
121
+ }
122
+
123
+ // 3. Aggregate by user and currency (accumulated mode)
124
+ // Structure: Map<customer_id, Map<currency_id, { total: BN }>>
125
+ const userAggregates = new Map<string, Map<string, { total: any }>>();
126
+
127
+ // Preload all Meter configurations to reduce database queries
128
+ const eventNames = [...new Set(events.map((e) => e.event_name))];
129
+ const meters = await Meter.findAll({ where: { event_name: eventNames } });
130
+ const meterMap = new Map(meters.map((m) => [m.event_name, m]));
131
+
132
+ for (const event of events) {
133
+ const customerId = event.getCustomerId();
134
+ if (customerId) {
135
+ const meter = meterMap.get(event.event_name);
136
+ const currencyId = meter?.currency_id;
137
+ if (currencyId) {
138
+ if (!userAggregates.has(customerId)) {
139
+ userAggregates.set(customerId, new Map());
140
+ }
141
+
142
+ const currencyGroups = userAggregates.get(customerId)!;
143
+ if (!currencyGroups.has(currencyId)) {
144
+ currencyGroups.set(currencyId, { total: new BN('0') });
145
+ }
146
+
147
+ const group = currencyGroups.get(currencyId)!;
148
+ group.total = group.total.add(new BN(event.credit_pending || '0'));
149
+ }
150
+ }
151
+ }
152
+
153
+ // 4. Calculate totals and check threshold
154
+ const allUsers = new Set<string>();
155
+ const currencyTotals = new Map<string, any>(); // Map<currency_id, totalBN>
156
+ let exceedsThreshold = false;
157
+
158
+ for (const [customerId, currencyGroups] of userAggregates) {
159
+ allUsers.add(customerId);
160
+
161
+ for (const [currencyId, group] of currencyGroups) {
162
+ const currency = currencyMap.get(currencyId);
163
+ if (currency) {
164
+ // Accumulate to global total for this currency
165
+ const currentTotal = currencyTotals.get(currencyId) || new BN('0');
166
+ currencyTotals.set(currencyId, currentTotal.add(group.total));
167
+
168
+ // Check if this user's pending exceeds threshold for this currency
169
+ const thresholdInUnit = new BN(overdueThreshold.toString()).mul(new BN(10).pow(new BN(currency.decimal)));
170
+ if (group.total.gt(thresholdInUnit)) {
171
+ exceedsThreshold = true;
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ if (allUsers.size === 0) {
178
+ return { customerCount: 0, pendingAmounts: '0', exceedsThreshold: false };
179
+ }
180
+
181
+ // 5. Format results
182
+ const formattedAmounts: string[] = [];
183
+ for (const [currencyId, totalBN] of currencyTotals) {
184
+ const currency = currencyMap.get(currencyId);
185
+ if (currency) {
186
+ const formattedAmount = fromUnitToToken(totalBN.toString(), currency.decimal);
187
+ formattedAmounts.push(this.formatAmountWithoutSuffix(formattedAmount, currency.symbol));
188
+ }
189
+ }
190
+
191
+ return {
192
+ customerCount: allUsers.size,
193
+ pendingAmounts: formattedAmounts.join(' / ') || '0',
194
+ exceedsThreshold,
195
+ };
196
+ }
197
+
198
+ private async getFinancialMetrics(): Promise<FinancialMetrics> {
199
+ const { start, end } = this.timeRange;
200
+ const startTime = dayjs.unix(start).toISOString();
201
+ const endTime = dayjs.unix(end).toISOString();
202
+
203
+ // Get all successful payments in the last 24 hours
204
+ const payments = await PaymentIntent.findAll({
205
+ where: {
206
+ status: 'succeeded',
207
+ created_at: {
208
+ [Op.between]: [startTime, endTime],
209
+ },
210
+ },
211
+ });
212
+
213
+ // Get all refunds in the last 24 hours
214
+ const refunds = await Refund.findAll({
215
+ where: {
216
+ status: 'succeeded',
217
+ created_at: {
218
+ [Op.between]: [startTime, endTime],
219
+ },
220
+ },
221
+ });
222
+
223
+ // Aggregate total revenue by currency
224
+ const revenueByCurrency = new Map<string, BN>();
225
+ for (const payment of payments) {
226
+ const current = revenueByCurrency.get(payment.currency_id) || new BN('0');
227
+ revenueByCurrency.set(payment.currency_id, current.add(new BN(payment.amount_received || '0')));
228
+ }
229
+
230
+ // Aggregate total refund by currency
231
+ const refundByCurrency = new Map<string, BN>();
232
+ for (const refund of refunds) {
233
+ const current = refundByCurrency.get(refund.currency_id) || new BN('0');
234
+ refundByCurrency.set(refund.currency_id, current.add(new BN(refund.amount || '0')));
235
+ }
236
+
237
+ // Get currency info
238
+ const allCurrencyIds = Array.from(new Set([...revenueByCurrency.keys(), ...refundByCurrency.keys()]));
239
+ const currencies = await PaymentCurrency.findAll({
240
+ where: { id: { [Op.in]: allCurrencyIds } },
241
+ });
242
+ const currencyMap = new Map(currencies.map((c) => [c.id, c]));
243
+
244
+ // Format revenue
245
+ const revenueFormatted: string[] = [];
246
+ for (const [currencyId, totalBN] of revenueByCurrency) {
247
+ const currency = currencyMap.get(currencyId);
248
+ if (currency) {
249
+ const formattedAmount = fromUnitToToken(totalBN.toString(), currency.decimal);
250
+ revenueFormatted.push(this.formatAmountWithoutSuffix(formattedAmount, currency.symbol));
251
+ }
252
+ }
253
+
254
+ // Format refund
255
+ const refundFormatted: string[] = [];
256
+ for (const [currencyId, totalBN] of refundByCurrency) {
257
+ const currency = currencyMap.get(currencyId);
258
+ if (currency) {
259
+ const formattedAmount = fromUnitToToken(totalBN.toString(), currency.decimal);
260
+ refundFormatted.push(this.formatAmountWithoutSuffix(formattedAmount, currency.symbol));
261
+ }
262
+ }
263
+
264
+ return {
265
+ totalRevenue: revenueFormatted.join(', ') || '0',
266
+ refundAmount: refundFormatted.join(', ') || '0',
267
+ refundCount: refunds.length,
268
+ };
269
+ }
270
+
271
+ private async getSubscriptionMetrics(): Promise<SubscriptionMetrics> {
272
+ const { start, end } = this.timeRange;
273
+ const startTime = dayjs.unix(start).toISOString();
274
+ const endTime = dayjs.unix(end).toISOString();
275
+
276
+ // New subscriptions created in the last 24 hours
277
+ const newSubscriptions = await Subscription.count({
278
+ where: {
279
+ created_at: {
280
+ [Op.between]: [startTime, endTime],
281
+ },
282
+ },
283
+ });
284
+
285
+ // Subscriptions canceled in the last 24 hours
286
+ const canceledSubscriptions = await Subscription.count({
287
+ where: {
288
+ canceled_at: {
289
+ [Op.between]: [start, end],
290
+ },
291
+ },
292
+ });
293
+
294
+ // Subscriptions that became past_due in the last 24 hours
295
+ const pastDueSubscriptions = await Subscription.count({
296
+ where: {
297
+ status: 'past_due',
298
+ updated_at: {
299
+ [Op.between]: [startTime, endTime],
300
+ },
301
+ },
302
+ });
303
+
304
+ return {
305
+ newSubscriptions,
306
+ canceledSubscriptions,
307
+ pastDueSubscriptions,
308
+ };
309
+ }
310
+
311
+ private async getMeteringAnomaliesMetrics(): Promise<MeteringAnomaliesMetrics> {
312
+ const { start, end } = this.timeRange;
313
+
314
+ // Get all invoices with metered items in the last 24 hours
315
+ const invoices = await Invoice.findAll({
316
+ where: {
317
+ created_at: {
318
+ [Op.between]: [start * 1000, end * 1000],
319
+ },
320
+ billing_reason: {
321
+ [Op.notIn]: ['stake', 'slash_stake', 'recharge'],
322
+ },
323
+ },
324
+ include: [
325
+ { model: Subscription, as: 'subscription' },
326
+ { model: InvoiceItem, as: 'lines' },
327
+ ],
328
+ });
329
+
330
+ const prices = (await Price.findAll()).map((x) => x.toJSON());
331
+ const priceMap = new Map(prices.map((price) => [price.id, price]));
332
+
333
+ const meteringInvoices: any[] = [];
334
+ for (const invoice of invoices) {
335
+ const invoiceJson = invoice.toJSON();
336
+ // @ts-ignore
337
+ (invoiceJson.lines || []).forEach((item) => {
338
+ item.price = priceMap.get(item.price_id);
339
+ });
340
+ // @ts-ignore
341
+ if ((invoiceJson.lines || []).some((line) => line.price?.recurring?.usage_type === 'metered')) {
342
+ meteringInvoices.push(invoice);
343
+ }
344
+ }
345
+
346
+ const meteringInvoiceIds = Array.from(new Set(meteringInvoices.map((invoice) => invoice.id)));
347
+ const meteringSubscriptionIds = Array.from(
348
+ new Set(meteringInvoices.map((invoice) => invoice?.subscription_id).filter(Boolean) as string[])
349
+ );
350
+
351
+ if (meteringSubscriptionIds.length === 0) {
352
+ return { unreportedCount: 0, unreportedIds: [], discrepantCount: 0, discrepantIds: [] };
353
+ }
354
+
355
+ // Get unreported and discrepant events
356
+ const [unreportedEvents, discrepantEvents] = await Promise.all([
357
+ Event.findAll({
358
+ where: {
359
+ type: 'usage.report.empty',
360
+ created_at: {
361
+ [Op.between]: [start * 1000, end * 1000],
362
+ },
363
+ object_id: {
364
+ [Op.in]: meteringSubscriptionIds,
365
+ },
366
+ },
367
+ }),
368
+ Event.findAll({
369
+ where: {
370
+ type: 'billing.discrepancy',
371
+ created_at: {
372
+ [Op.between]: [start * 1000, end * 1000],
373
+ },
374
+ object_id: {
375
+ [Op.in]: meteringInvoiceIds,
376
+ },
377
+ },
378
+ }),
379
+ ]);
380
+
381
+ const unreportedIds = Array.from(new Set(unreportedEvents.map((e) => e.object_id).filter(Boolean)));
382
+ const discrepantIds = Array.from(
383
+ new Set(discrepantEvents.map((e) => e.data?.object?.subscription_id).filter(Boolean))
384
+ );
385
+
386
+ return {
387
+ unreportedCount: unreportedIds.length,
388
+ unreportedIds,
389
+ discrepantCount: discrepantIds.length,
390
+ discrepantIds,
391
+ };
392
+ }
393
+
394
+ private async getPaymentMetrics(): Promise<PaymentMetrics> {
395
+ const { start, end } = this.timeRange;
396
+ const startTime = dayjs.unix(start).toISOString();
397
+ const endTime = dayjs.unix(end).toISOString();
398
+
399
+ // Successful payments
400
+ const succeededPayments = await PaymentIntent.findAll({
401
+ where: {
402
+ status: 'succeeded',
403
+ created_at: {
404
+ [Op.between]: [startTime, endTime],
405
+ },
406
+ },
407
+ });
408
+
409
+ // Failed payments (canceled status)
410
+ const failedStatuses = ['canceled', 'requires_payment_method', 'requires_action'];
411
+ const failedPayments = await PaymentIntent.findAll({
412
+ where: {
413
+ status: { [Op.in]: failedStatuses },
414
+ created_at: {
415
+ [Op.between]: [startTime, endTime],
416
+ },
417
+ },
418
+ });
419
+
420
+ const succeededCount = succeededPayments.length;
421
+ const failedCount = failedPayments.length;
422
+ const totalCount = succeededCount + failedCount;
423
+
424
+ // Calculate success rate
425
+ const successRate = totalCount > 0 ? ((succeededCount / totalCount) * 100).toFixed(2) : '100.00';
426
+
427
+ // Aggregate amounts
428
+ const succeededAmountByCurrency = new Map<string, BN>();
429
+ for (const payment of succeededPayments) {
430
+ const current = succeededAmountByCurrency.get(payment.currency_id) || new BN('0');
431
+ succeededAmountByCurrency.set(payment.currency_id, current.add(new BN(payment.amount_received || '0')));
432
+ }
433
+
434
+ const failedAmountByCurrency = new Map<string, BN>();
435
+ for (const payment of failedPayments) {
436
+ const current = failedAmountByCurrency.get(payment.currency_id) || new BN('0');
437
+ failedAmountByCurrency.set(payment.currency_id, current.add(new BN(payment.amount || '0')));
438
+ }
439
+
440
+ // Get currency info
441
+ const allCurrencyIds = Array.from(new Set([...succeededAmountByCurrency.keys(), ...failedAmountByCurrency.keys()]));
442
+ const currencies = await PaymentCurrency.findAll({
443
+ where: { id: { [Op.in]: allCurrencyIds } },
444
+ });
445
+ const currencyMap = new Map(currencies.map((c) => [c.id, c]));
446
+
447
+ // Format amounts
448
+ const succeededFormatted: string[] = [];
449
+ for (const [currencyId, totalBN] of succeededAmountByCurrency) {
450
+ const currency = currencyMap.get(currencyId);
451
+ if (currency) {
452
+ const formattedAmount = fromUnitToToken(totalBN.toString(), currency.decimal);
453
+ succeededFormatted.push(this.formatAmountWithoutSuffix(formattedAmount, currency.symbol));
454
+ }
455
+ }
456
+
457
+ const failedFormatted: string[] = [];
458
+ for (const [currencyId, totalBN] of failedAmountByCurrency) {
459
+ const currency = currencyMap.get(currencyId);
460
+ if (currency) {
461
+ const formattedAmount = fromUnitToToken(totalBN.toString(), currency.decimal);
462
+ failedFormatted.push(this.formatAmountWithoutSuffix(formattedAmount, currency.symbol));
463
+ }
464
+ }
465
+
466
+ return {
467
+ succeededCount,
468
+ succeededAmount: succeededFormatted.join(', ') || '0',
469
+ failedCount,
470
+ failedAmount: failedFormatted.join(', ') || '0',
471
+ successRate,
472
+ };
473
+ }
474
+
475
+ async getContext(): Promise<HealthReportContext> {
476
+ const pendingConsumption = await this.getPendingConsumptionMetrics();
477
+ const financial = await this.getFinancialMetrics();
478
+ const subscription = await this.getSubscriptionMetrics();
479
+ const payment = await this.getPaymentMetrics();
480
+ const meteringAnomalies = await this.getMeteringAnomaliesMetrics();
481
+
482
+ const userDid = await getOwnerDid();
483
+ if (!userDid) {
484
+ throw new Error('get owner did failed');
485
+ }
486
+ const locale = await getUserLocale(userDid);
487
+ const viewDataOverviewUrl = getUrl(withQuery('admin/billing', { locale }));
488
+ const viewSubscriptionsUrl = getUrl(withQuery('admin/billing/subscriptions', { locale }));
489
+ const viewOverdueUrl = getUrl(withQuery('admin/billing/overdue', { locale }));
490
+
491
+ return {
492
+ locale,
493
+ userDid,
494
+ pendingConsumption,
495
+ financial,
496
+ subscription,
497
+ payment,
498
+ meteringAnomalies,
499
+ viewDataOverviewUrl,
500
+ viewSubscriptionsUrl,
501
+ viewOverdueUrl,
502
+ };
503
+ }
504
+
505
+ private formatAmountWithoutSuffix(formattedAmount: string, currencySymbol: string | undefined): string {
506
+ // Currency symbol mapping (same as in util.ts)
507
+ const CURRENCY_SYMBOLS: Record<string, string> = {
508
+ USD: '$',
509
+ EUR: '€',
510
+ GBP: '£',
511
+ JPY: '¥',
512
+ CNY: '¥',
513
+ };
514
+
515
+ const mappedSymbol = CURRENCY_SYMBOLS[currencySymbol || ''];
516
+ if (mappedSymbol) {
517
+ return `${mappedSymbol}${formattedAmount}`;
518
+ }
519
+ return `${formattedAmount} ${currencySymbol || ''}`;
520
+ }
521
+
522
+ private createTextBlock(text: string, color?: string) {
523
+ return {
524
+ type: 'text',
525
+ data: {
526
+ type: 'plain',
527
+ ...(color ? { color } : {}),
528
+ text,
529
+ },
530
+ };
531
+ }
532
+
533
+ private buildSectionFields(rows: Array<{ left: string; right?: string; leftColor?: string; rightColor?: string }>) {
534
+ return rows.flatMap((row) => [
535
+ this.createTextBlock(row.left, row.leftColor),
536
+ this.createTextBlock(row.right ?? ' ', row.rightColor),
537
+ ]);
538
+ }
539
+
540
+ async getTemplate(context?: HealthReportContext): Promise<BaseEmailTemplateType> {
541
+ const resolvedContext = context ?? (await this.getContext());
542
+ const {
543
+ locale,
544
+ pendingConsumption,
545
+ financial,
546
+ subscription,
547
+ payment,
548
+ meteringAnomalies,
549
+ viewDataOverviewUrl,
550
+ viewSubscriptionsUrl,
551
+ viewOverdueUrl,
552
+ } = resolvedContext;
553
+
554
+ const startTimeLabel = dayjs.unix(this.timeRange.start).utc().format('YYYY-MM-DD HH:mm');
555
+ const endTimeLabel = dayjs.unix(this.timeRange.end).utc().format('YYYY-MM-DD HH:mm');
556
+
557
+ const labelColor = '#9397A1';
558
+ const attentionDetailRows: Array<{ left: string; right?: string; leftColor?: string; count?: number }> = [];
559
+ if (meteringAnomalies.unreportedCount > 0) {
560
+ attentionDetailRows.push({
561
+ left: translate('notification.healthReport.attentionUnreportedLabel', locale),
562
+ right: translate('notification.healthReport.attentionUnreportedValue', locale, {
563
+ count: meteringAnomalies.unreportedCount,
564
+ ids: meteringAnomalies.unreportedIds.join('、'),
565
+ }),
566
+ leftColor: labelColor,
567
+ count: meteringAnomalies.unreportedCount,
568
+ });
569
+ }
570
+ if (meteringAnomalies.discrepantCount > 0) {
571
+ attentionDetailRows.push({
572
+ left: translate('notification.healthReport.attentionDiscrepantLabel', locale),
573
+ right: translate('notification.healthReport.attentionDiscrepantValue', locale, {
574
+ count: meteringAnomalies.discrepantCount,
575
+ ids: meteringAnomalies.discrepantIds.join('、'),
576
+ }),
577
+ leftColor: labelColor,
578
+ count: meteringAnomalies.discrepantCount,
579
+ });
580
+ }
581
+ if (pendingConsumption.customerCount > 0 && pendingConsumption.exceedsThreshold) {
582
+ attentionDetailRows.push({
583
+ left: translate('notification.healthReport.attentionPendingConsumptionLabel', locale),
584
+ right: translate('notification.healthReport.attentionPendingConsumptionValue', locale, {
585
+ customers: pendingConsumption.customerCount,
586
+ amount: pendingConsumption.pendingAmounts,
587
+ }),
588
+ leftColor: labelColor,
589
+ count: pendingConsumption.customerCount,
590
+ });
591
+ }
592
+ if (financial.refundCount > 0) {
593
+ attentionDetailRows.push({
594
+ left: translate('notification.healthReport.attentionRefundLabel', locale),
595
+ right: translate('notification.healthReport.attentionRefundValue', locale, {
596
+ refundCount: financial.refundCount,
597
+ refundAmount: financial.refundAmount,
598
+ }),
599
+ leftColor: labelColor,
600
+ count: financial.refundCount,
601
+ });
602
+ }
603
+ if (subscription.pastDueSubscriptions > 0) {
604
+ attentionDetailRows.push({
605
+ left: translate('notification.healthReport.attentionPastDueLabel', locale),
606
+ right: translate('notification.healthReport.attentionPastDueValue', locale, {
607
+ count: subscription.pastDueSubscriptions,
608
+ }),
609
+ leftColor: labelColor,
610
+ count: subscription.pastDueSubscriptions,
611
+ });
612
+ }
613
+
614
+ const attentionSummary =
615
+ attentionDetailRows.length > 0
616
+ ? translate('notification.healthReport.attentionSummary', locale, { count: attentionDetailRows.length })
617
+ : translate('notification.healthReport.attentionSummaryEmpty', locale);
618
+
619
+ const attentionSectionRows = attentionDetailRows.length > 0 ? attentionDetailRows : [];
620
+
621
+ const succeededText = translate('notification.healthReport.paymentSuccessCount', locale, {
622
+ count: payment.succeededCount,
623
+ });
624
+ const failedText = translate('notification.healthReport.paymentFailedCount', locale, {
625
+ count: payment.failedCount,
626
+ });
627
+
628
+ const metricsSectionRows = [
629
+ {
630
+ left: translate('notification.healthReport.coreRevenueLabel', locale) + ' '.repeat(100),
631
+ right: translate('notification.healthReport.coreRevenueValue', locale, {
632
+ revenue: financial.totalRevenue,
633
+ }),
634
+ leftColor: labelColor,
635
+ },
636
+ {
637
+ left: translate('notification.healthReport.coreNewSubscriptionsLabel', locale),
638
+ right: translate('notification.healthReport.coreNewSubscriptionsValue', locale, {
639
+ count: subscription.newSubscriptions,
640
+ }),
641
+ leftColor: labelColor,
642
+ },
643
+ {
644
+ left: translate('notification.healthReport.coreCanceledSubscriptionsLabel', locale),
645
+ right: translate('notification.healthReport.coreCanceledSubscriptionsValue', locale, {
646
+ count: subscription.canceledSubscriptions,
647
+ }),
648
+ leftColor: labelColor,
649
+ },
650
+ {
651
+ left: translate('notification.healthReport.corePaymentSuccessRateLabel', locale),
652
+ right: translate('notification.healthReport.corePaymentSuccessRateValue', locale, {
653
+ rate: payment.successRate,
654
+ succeeded: succeededText,
655
+ failed: failedText,
656
+ }),
657
+ leftColor: labelColor,
658
+ },
659
+ ];
660
+
661
+ const combinedRows = [
662
+ { left: attentionSummary },
663
+ ...attentionSectionRows,
664
+ { left: translate('notification.healthReport.coreMetricsTitle', locale) },
665
+ ...metricsSectionRows,
666
+ ];
667
+
668
+ const attachments = [
669
+ {
670
+ type: 'section',
671
+ fields: this.buildSectionFields(combinedRows),
672
+ },
673
+ ];
674
+
675
+ return {
676
+ title: translate('notification.healthReport.title', locale, { appName: getAppName() }),
677
+ body: `${translate('notification.healthReport.period', locale, {
678
+ start: startTimeLabel,
679
+ end: endTimeLabel,
680
+ })}`,
681
+ // @ts-expect-error
682
+ attachments,
683
+ // @ts-ignore
684
+ actions: [
685
+ viewDataOverviewUrl && {
686
+ name: translate('notification.healthReport.viewDataOverview', locale),
687
+ title: translate('notification.healthReport.viewDataOverview', locale),
688
+ link: viewDataOverviewUrl,
689
+ },
690
+ viewSubscriptionsUrl && {
691
+ name: translate('notification.healthReport.viewSubscriptions', locale),
692
+ title: translate('notification.healthReport.viewSubscriptions', locale),
693
+ link: viewSubscriptionsUrl,
694
+ },
695
+ viewOverdueUrl && {
696
+ name: translate('notification.healthReport.viewOverdue', locale),
697
+ title: translate('notification.healthReport.viewOverdue', locale),
698
+ link: viewOverdueUrl,
699
+ },
700
+ ].filter(Boolean),
701
+ };
702
+ }
703
+ }
704
+
705
+ export async function createOverdueDetection() {
706
+ logger.info('Start health report generation');
707
+
708
+ try {
709
+ const template = new HealthReportTemplate();
710
+ const context = await template.getContext();
711
+
712
+ const notification = await template.getTemplate(context);
713
+
714
+ if (notification) {
715
+ await Notification.sendToUser(context.userDid, notification as any, { allowUnsubscribe: false });
716
+ logger.info('Health report notification sent', {
717
+ pendingCustomers: context.pendingConsumption.customerCount,
718
+ pendingExceedsThreshold: context.pendingConsumption.exceedsThreshold,
719
+ pastDueSubscriptions: context.subscription.pastDueSubscriptions,
720
+ });
721
+ }
722
+ } catch (error) {
723
+ logger.error('Failed to create health report', error);
724
+ }
725
+ }