payment-kit 1.24.4 → 1.25.1

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.
Files changed (116) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +3 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/refund.ts +41 -9
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/payment-links.ts +13 -0
  66. package/api/src/routes/prices.ts +84 -2
  67. package/api/src/routes/subscriptions.ts +526 -15
  68. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  69. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  70. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  71. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  72. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  73. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  74. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  75. package/api/src/store/models/auto-recharge-config.ts +12 -0
  76. package/api/src/store/models/checkout-session.ts +7 -0
  77. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  78. package/api/src/store/models/index.ts +6 -0
  79. package/api/src/store/models/payment-intent.ts +6 -0
  80. package/api/src/store/models/price-quote.ts +284 -0
  81. package/api/src/store/models/price.ts +53 -5
  82. package/api/src/store/models/subscription.ts +11 -0
  83. package/api/src/store/models/types.ts +61 -1
  84. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  85. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  86. package/api/tests/libs/quote-service.spec.ts +199 -0
  87. package/api/tests/libs/session.spec.ts +464 -0
  88. package/api/tests/libs/slippage.spec.ts +109 -0
  89. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  90. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  91. package/api/tests/models/price-dynamic.spec.ts +100 -0
  92. package/api/tests/models/price-quote.spec.ts +112 -0
  93. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  94. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  95. package/blocklet.yml +1 -1
  96. package/package.json +7 -6
  97. package/src/components/customer/credit-overview.tsx +14 -0
  98. package/src/components/discount/discount-info.tsx +8 -2
  99. package/src/components/invoice/list.tsx +146 -16
  100. package/src/components/invoice/table.tsx +276 -71
  101. package/src/components/invoice-pdf/template.tsx +3 -7
  102. package/src/components/metadata/form.tsx +6 -8
  103. package/src/components/price/form.tsx +519 -149
  104. package/src/components/promotion/active-redemptions.tsx +5 -3
  105. package/src/components/quote/info.tsx +234 -0
  106. package/src/hooks/subscription.ts +132 -2
  107. package/src/locales/en.tsx +145 -0
  108. package/src/locales/zh.tsx +143 -1
  109. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  110. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  111. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  112. package/src/pages/admin/products/index.tsx +12 -1
  113. package/src/pages/customer/invoice/detail.tsx +36 -12
  114. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  115. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  116. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -9,15 +9,28 @@ import { PaymentCurrency } from '../../../store/models/payment-currency';
9
9
  import { getCustomerInvoicePageUrl } from '../../invoice';
10
10
  import { SufficientForPaymentResult, getPaymentDetail } from '../../payment';
11
11
  import { formatTime } from '../../time';
12
- import { formatCurrencyInfo } from '../../util';
12
+ import { formatCurrencyInfo, getCustomerAutoRechargeSettingsUrl } from '../../util';
13
13
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
14
14
 
15
+ // Extended result type that includes skipped reasons
16
+ export interface AutoRechargeResult extends Partial<SufficientForPaymentResult> {
17
+ sufficient?: boolean;
18
+ reason?: string;
19
+ // Skipped scenario fields
20
+ currentRate?: string;
21
+ minAcceptableRate?: string;
22
+ // Additional context for better notifications
23
+ paymentCurrencySymbol?: string;
24
+ paymentCurrencyName?: string;
25
+ creditCurrencyName?: string;
26
+ }
27
+
15
28
  export interface CustomerAutoRechargeFailedEmailTemplateOptions {
16
29
  customerId: string;
17
30
  autoRechargeConfigId: string;
18
- invoiceId: string;
19
- paymentIntentId: string;
20
- result: SufficientForPaymentResult;
31
+ invoiceId?: string; // Optional for skipped scenarios
32
+ paymentIntentId?: string; // Optional for skipped scenarios
33
+ result: AutoRechargeResult;
21
34
  }
22
35
 
23
36
  interface CustomerAutoRechargeFailedEmailTemplateContext {
@@ -25,10 +38,12 @@ interface CustomerAutoRechargeFailedEmailTemplateContext {
25
38
  userDid: string;
26
39
  at: string;
27
40
  reason: string;
28
- paymentInfo: string;
29
- autoRechargeAmount: string;
41
+ paymentInfo?: string;
42
+ autoRechargeAmount?: string;
30
43
  creditCurrencyName: string;
31
- viewInvoiceLink: string;
44
+ viewInvoiceLink?: string;
45
+ settingsUrl?: string;
46
+ isSkipped: boolean;
32
47
  }
33
48
 
34
49
  export class CustomerAutoRechargeFailedEmailTemplate
@@ -40,7 +55,34 @@ export class CustomerAutoRechargeFailedEmailTemplate
40
55
  this.options = options;
41
56
  }
42
57
 
43
- private async getReason(userDid: string, invoice: Invoice, locale: string): Promise<string> {
58
+ private isSkippedScenario(): boolean {
59
+ const skippedReasons = ['slippage_exceeded', 'exchange_rate_not_supported', 'exchange_rate_fetch_failed'];
60
+ return skippedReasons.includes(this.options.result?.reason || '');
61
+ }
62
+
63
+ // Get the title key based on the skip reason
64
+ private getSkippedTitleKey(): string {
65
+ const reason = this.options.result?.reason;
66
+ const titleMap: Record<string, string> = {
67
+ slippage_exceeded: 'titleSkippedSlippageExceeded',
68
+ exchange_rate_not_supported: 'titleSkippedExchangeRateNotSupported',
69
+ exchange_rate_fetch_failed: 'titleSkippedExchangeRateFetchFailed',
70
+ };
71
+ return `notification.autoRechargeFailed.${titleMap[reason || ''] || 'titleSkipped'}`;
72
+ }
73
+
74
+ private getSkippedReason(locale: string): string {
75
+ const { result } = this.options;
76
+ const i18nText = `notification.autoRechargeFailed.reason.${camelCase(result.reason as string)}`;
77
+
78
+ return translate(i18nText, locale, {
79
+ currentRate: result.currentRate || '-',
80
+ minAcceptableRate: result.minAcceptableRate || '-',
81
+ paymentCurrency: result.paymentCurrencySymbol || result.paymentCurrencyName || '-',
82
+ });
83
+ }
84
+
85
+ private async getFailedReason(userDid: string, invoice: Invoice, locale: string): Promise<string> {
44
86
  if (this.options.result?.sufficient) {
45
87
  throw new Error(`SufficientForPaymentResult.sufficient should be false: ${JSON.stringify(this.options.result)}`);
46
88
  }
@@ -75,6 +117,38 @@ export class CustomerAutoRechargeFailedEmailTemplate
75
117
  throw new Error(`AutoRechargeConfig not found: ${this.options.autoRechargeConfigId}`);
76
118
  }
77
119
 
120
+ const userDid: string = customer.did;
121
+ const locale = await getUserLocale(userDid);
122
+ const at: string = formatTime(Date.now());
123
+ const isSkipped = this.isSkippedScenario();
124
+
125
+ // Get credit currency
126
+ const creditCurrency = await PaymentCurrency.findByPk(autoRechargeConfig.currency_id);
127
+ if (!creditCurrency) {
128
+ throw new Error(`Credit currency not found: ${autoRechargeConfig.currency_id}`);
129
+ }
130
+ const creditCurrencyName = creditCurrency.name || 'Credits';
131
+
132
+ // Handle skipped scenario (no invoice)
133
+ if (isSkipped) {
134
+ const reason = this.getSkippedReason(locale);
135
+ const settingsUrl = getCustomerAutoRechargeSettingsUrl({
136
+ locale,
137
+ userDid,
138
+ currencyId: autoRechargeConfig.currency_id,
139
+ });
140
+ return {
141
+ locale,
142
+ userDid,
143
+ at,
144
+ reason,
145
+ creditCurrencyName,
146
+ settingsUrl,
147
+ isSkipped: true,
148
+ };
149
+ }
150
+
151
+ // Handle normal failed scenario (with invoice)
78
152
  const invoice = await Invoice.findByPk(this.options.invoiceId);
79
153
  if (!invoice) {
80
154
  throw new Error(`Invoice not found: ${this.options.invoiceId}`);
@@ -86,25 +160,14 @@ export class CustomerAutoRechargeFailedEmailTemplate
86
160
  },
87
161
  })) as PaymentCurrency;
88
162
 
89
- const userDid: string = customer.did;
90
- const locale = await getUserLocale(userDid);
91
- const at: string = formatTime(Date.now());
92
- const reason: string = await this.getReason(userDid, invoice, locale);
163
+ const reason: string = await this.getFailedReason(userDid, invoice, locale);
93
164
 
94
165
  const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
95
166
  const paymentInfo: string = formatCurrencyInfo(invoice.amount_remaining, paymentCurrency, paymentMethod);
96
167
 
97
- // 获取 credit currency
98
- const creditCurrency = await PaymentCurrency.findByPk(autoRechargeConfig.currency_id);
99
- if (!creditCurrency) {
100
- throw new Error(`Credit currency not found: ${autoRechargeConfig.currency_id}`);
101
- }
102
-
103
- // 格式化自动充值金额(根据 invoice 的金额)
168
+ // Format auto recharge amount (based on invoice amount)
104
169
  const autoRechargeAmount = formatCurrencyInfo(invoice.amount_remaining, paymentCurrency, paymentMethod);
105
170
 
106
- const creditCurrencyName = creditCurrency.name || 'Credits';
107
-
108
171
  const viewInvoiceLink = getCustomerInvoicePageUrl({
109
172
  invoiceId: invoice.id,
110
173
  userDid,
@@ -121,15 +184,100 @@ export class CustomerAutoRechargeFailedEmailTemplate
121
184
  autoRechargeAmount,
122
185
  creditCurrencyName,
123
186
  viewInvoiceLink,
187
+ isSkipped: false,
124
188
  };
125
189
  }
126
190
 
127
191
  async getTemplate(): Promise<BaseEmailTemplateType> {
128
- const { locale, userDid, at, reason, paymentInfo, creditCurrencyName, viewInvoiceLink } = await this.getContext();
192
+ const context = await this.getContext();
193
+ const { locale, userDid, at, reason, paymentInfo, creditCurrencyName, viewInvoiceLink, settingsUrl, isSkipped } =
194
+ context;
195
+
196
+ // Use different title/body for skipped scenarios
197
+ // For skipped scenarios, use reason-specific title
198
+ const titleKey = isSkipped ? this.getSkippedTitleKey() : 'notification.autoRechargeFailed.title';
199
+ const bodyKey = isSkipped ? 'notification.autoRechargeFailed.bodySkipped' : 'notification.autoRechargeFailed.body';
200
+
201
+ const fields = [
202
+ {
203
+ type: 'text',
204
+ data: {
205
+ type: 'plain',
206
+ color: '#9397A1',
207
+ text: translate('notification.common.account', locale),
208
+ },
209
+ },
210
+ {
211
+ type: 'text',
212
+ data: {
213
+ type: 'plain',
214
+ text: userDid,
215
+ },
216
+ },
217
+ ];
218
+
219
+ // Add payment amount field only for non-skipped scenarios
220
+ if (!isSkipped && paymentInfo) {
221
+ fields.push(
222
+ {
223
+ type: 'text',
224
+ data: {
225
+ type: 'plain',
226
+ color: '#9397A1',
227
+ text: translate('notification.common.paymentAmount', locale),
228
+ },
229
+ },
230
+ {
231
+ type: 'text',
232
+ data: {
233
+ type: 'plain',
234
+ text: paymentInfo,
235
+ },
236
+ }
237
+ );
238
+ }
239
+
240
+ // Add reason field
241
+ fields.push(
242
+ {
243
+ type: 'text',
244
+ data: {
245
+ type: 'plain',
246
+ color: '#9397A1',
247
+ text: translate('notification.common.failReason', locale),
248
+ },
249
+ },
250
+ {
251
+ type: 'text',
252
+ data: {
253
+ type: 'plain',
254
+ color: isSkipped ? '#FF9500' : '#FF0000', // Orange for skipped, red for failed
255
+ text: reason,
256
+ },
257
+ }
258
+ );
259
+
260
+ // Build actions based on scenario
261
+ const actions = [];
262
+ if (isSkipped && settingsUrl) {
263
+ // For skipped scenarios, link to settings to adjust slippage config
264
+ actions.push({
265
+ name: translate('notification.autoRechargeFailed.adjustSettings', locale),
266
+ title: translate('notification.autoRechargeFailed.adjustSettings', locale),
267
+ link: settingsUrl,
268
+ });
269
+ } else if (viewInvoiceLink) {
270
+ // For failed scenarios, link to invoice to retry payment
271
+ actions.push({
272
+ name: translate('notification.common.renewNow', locale),
273
+ title: translate('notification.common.renewNow', locale),
274
+ link: viewInvoiceLink,
275
+ });
276
+ }
129
277
 
130
278
  const template: BaseEmailTemplateType = {
131
- title: translate('notification.autoRechargeFailed.title', locale),
132
- body: translate('notification.autoRechargeFailed.body', locale, {
279
+ title: translate(titleKey, locale),
280
+ body: translate(bodyKey, locale, {
133
281
  at,
134
282
  creditCurrencyName,
135
283
  }),
@@ -137,64 +285,11 @@ export class CustomerAutoRechargeFailedEmailTemplate
137
285
  attachments: [
138
286
  {
139
287
  type: 'section',
140
- fields: [
141
- {
142
- type: 'text',
143
- data: {
144
- type: 'plain',
145
- color: '#9397A1',
146
- text: translate('notification.common.account', locale),
147
- },
148
- },
149
- {
150
- type: 'text',
151
- data: {
152
- type: 'plain',
153
- text: userDid,
154
- },
155
- },
156
- {
157
- type: 'text',
158
- data: {
159
- type: 'plain',
160
- color: '#9397A1',
161
- text: translate('notification.common.paymentAmount', locale),
162
- },
163
- },
164
- {
165
- type: 'text',
166
- data: {
167
- type: 'plain',
168
- text: paymentInfo,
169
- },
170
- },
171
- {
172
- type: 'text',
173
- data: {
174
- type: 'plain',
175
- color: '#9397A1',
176
- text: translate('notification.common.failReason', locale),
177
- },
178
- },
179
- {
180
- type: 'text',
181
- data: {
182
- type: 'plain',
183
- color: '#FF0000',
184
- text: reason,
185
- },
186
- },
187
- ].filter(Boolean),
288
+ fields: fields.filter(Boolean),
188
289
  },
189
290
  ].filter(Boolean),
190
291
  // @ts-ignore
191
- actions: [
192
- {
193
- name: translate('notification.common.renewNow', locale),
194
- title: translate('notification.common.renewNow', locale),
195
- link: viewInvoiceLink,
196
- },
197
- ].filter(Boolean),
292
+ actions,
198
293
  };
199
294
 
200
295
  return template;
@@ -1,11 +1,10 @@
1
1
  /* eslint-disable @typescript-eslint/brace-style */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
- import { fromUnitToToken } from '@ocap/util';
4
3
  import { getUserLocale } from '../../../integrations/blocklet/notification';
5
4
  import { translate } from '../../../locales';
6
5
  import { CreditGrant, Customer, PaymentCurrency } from '../../../store/models';
7
6
  import { formatTime } from '../../time';
8
- import { formatCreditAmount, formatNumber, getCustomerIndexUrl } from '../../util';
7
+ import { formatCreditAmount, formatTokenAmount, getCustomerIndexUrl } from '../../util';
9
8
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
10
9
 
11
10
  export interface CustomerCreditGrantGrantedEmailTemplateOptions {
@@ -60,7 +59,7 @@ export class CustomerCreditGrantGrantedEmailTemplate
60
59
  userDid,
61
60
  currencySymbol,
62
61
  grantedAmount: formatCreditAmount(
63
- formatNumber(fromUnitToToken(creditGrant.amount.toString(), paymentCurrency.decimal)) || '0',
62
+ formatTokenAmount(creditGrant.amount.toString(), paymentCurrency.decimal) || '0',
64
63
  currencySymbol
65
64
  ),
66
65
  expiresAt,
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/brace-style */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
- import { BN, fromUnitToToken } from '@ocap/util';
3
+ import { BN } from '@ocap/util';
4
4
  import { withQuery } from 'ufo';
5
5
  import { getUserLocale } from '../../../integrations/blocklet/notification';
6
6
  import { translate } from '../../../locales';
@@ -9,7 +9,7 @@ import { getMainProductName } from '../../product';
9
9
  import { getCustomerSubscriptionPageUrl } from '../../subscription';
10
10
  import { formatTime } from '../../time';
11
11
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
12
- import { formatCreditAmount, getConnectQueryParam, getCustomerIndexUrl, formatNumber } from '../../util';
12
+ import { formatCreditAmount, formatTokenAmount, getConnectQueryParam, getCustomerIndexUrl } from '../../util';
13
13
  import { getRechargePaymentUrl } from '../../currency';
14
14
 
15
15
  export interface CustomerCreditInsufficientEmailTemplateOptions {
@@ -85,7 +85,7 @@ export class CustomerCreditInsufficientEmailTemplate
85
85
 
86
86
  const pendingAmountValue = this.options.pendingAmount || '0';
87
87
  const pendingAmountBN = new BN(pendingAmountValue);
88
- const formattedPending = formatNumber(fromUnitToToken(pendingAmountValue, paymentCurrency.decimal)) || '0';
88
+ const formattedPending = formatTokenAmount(pendingAmountValue, paymentCurrency.decimal) || '0';
89
89
 
90
90
  return {
91
91
  locale,
@@ -1,12 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/brace-style */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
3
  import { withQuery } from 'ufo';
4
- import { BN, fromUnitToToken } from '@ocap/util';
4
+ import { BN } from '@ocap/util';
5
5
  import { getUserLocale } from '../../../integrations/blocklet/notification';
6
6
  import { translate } from '../../../locales';
7
7
  import { Customer, PaymentCurrency, CreditGrant } from '../../../store/models';
8
8
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
9
- import { formatCreditAmount, getConnectQueryParam, getCustomerIndexUrl, formatNumber } from '../../util';
9
+ import { formatCreditAmount, formatTokenAmount, getConnectQueryParam, getCustomerIndexUrl } from '../../util';
10
10
  import { getRechargePaymentUrl } from '../../currency';
11
11
 
12
12
  export interface CustomerCreditLowBalanceEmailTemplateOptions {
@@ -102,7 +102,7 @@ export class CustomerCreditLowBalanceEmailTemplate
102
102
  const userDid = customer.did;
103
103
  const locale = await getUserLocale(userDid);
104
104
 
105
- const formattedAmount = formatNumber(fromUnitToToken(remainingAmount.toString(), paymentCurrency.decimal));
105
+ const formattedAmount = formatTokenAmount(remainingAmount.toString(), paymentCurrency.decimal);
106
106
  const available = formatCreditAmount(formattedAmount || '0', paymentCurrency.symbol);
107
107
  const isCritical = currentPercentage < 1;
108
108
  const lowBalancePercentage = isCritical
@@ -1,13 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/brace-style */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
- import { fromUnitToToken } from '@ocap/util';
4
3
  import { getUrl } from '@blocklet/sdk';
5
4
  import { getUserLocale } from '../../../integrations/blocklet/notification';
6
5
  import { translate } from '../../../locales';
7
6
  import { CheckoutSession, Customer, PaymentLink, PaymentMethod, Payout } from '../../../store/models';
8
7
  import { PaymentCurrency } from '../../../store/models/payment-currency';
9
8
  import { formatTime } from '../../time';
10
- import { getCustomerProfileUrl, getExplorerLink, getUserOrAppInfo } from '../../util';
9
+ import { formatTokenAmount, getCustomerProfileUrl, getExplorerLink, getUserOrAppInfo } from '../../util';
11
10
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
12
11
  import { getCustomerPayoutPageUrl } from '../../payout';
13
12
  import { PaymentIntent } from '../../../store/models/payment-intent';
@@ -105,7 +104,7 @@ export class CustomerRevenueSucceededEmailTemplate
105
104
  throw new Error(`Payer not found for paymentIntent: ${payout.paymentIntent?.customer_id}`);
106
105
  }
107
106
 
108
- const paymentInfo: string = `${fromUnitToToken(payout.amount, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
107
+ const paymentInfo: string = `${formatTokenAmount(payout.amount, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
109
108
  const userInfo = await getUserOrAppInfo(payer.did);
110
109
  const revenueDetail = {
111
110
  url: getCustomerProfileUrl({ userDid: payer.did, locale }),
@@ -1,6 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/brace-style */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
- import { fromUnitToToken } from '@ocap/util';
4
3
  import isEmpty from 'lodash/isEmpty';
5
4
  import pWaitFor from 'p-wait-for';
6
5
  import type { LiteralUnion } from 'type-fest';
@@ -22,7 +21,13 @@ import { PaymentCurrency } from '../../../store/models/payment-currency';
22
21
  import { getCustomerInvoicePageUrl } from '../../invoice';
23
22
  import logger from '../../logger';
24
23
  import { formatTime } from '../../time';
25
- import { getBlockletJson, getCustomerProfileUrl, getExplorerLink, getUserOrAppInfo } from '../../util';
24
+ import {
25
+ formatTokenAmount,
26
+ getBlockletJson,
27
+ getCustomerProfileUrl,
28
+ getExplorerLink,
29
+ getUserOrAppInfo,
30
+ } from '../../util';
26
31
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
27
32
 
28
33
  export interface CustomerRewardSucceededEmailTemplateOptions {
@@ -140,7 +145,7 @@ export class CustomerRewardSucceededEmailTemplate
140
145
  const locale = await getUserLocale(userDid);
141
146
  const at: string = formatTime(checkoutSession.created_at);
142
147
 
143
- const paymentInfo: string = `${fromUnitToToken(checkoutSession?.amount_total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
148
+ const paymentInfo: string = `${formatTokenAmount(checkoutSession?.amount_total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
144
149
 
145
150
  const rewardDetail = await this.getRewardDetail({
146
151
  paymentIntent,
@@ -229,7 +234,7 @@ export class CustomerRewardSucceededEmailTemplate
229
234
  url: users[index]?.url || getCustomerProfileUrl({ userDid: x.address, locale }),
230
235
  title: translate('notification.customerRewardSucceeded.received', locale, {
231
236
  address: users[index]?.name || x.address,
232
- amount: `${fromUnitToToken(x.share, paymentCurrency.decimal)} ${paymentCurrency.symbol}`,
237
+ amount: `${formatTokenAmount(x.share, paymentCurrency.decimal)} ${paymentCurrency.symbol}`,
233
238
  }),
234
239
  logo: users[index]?.avatar ? users[index]?.avatar : getUrl('/methods/default.png'),
235
240
  appDID: x.address,
@@ -0,0 +1,202 @@
1
+ import { getUrl } from '@blocklet/sdk/lib/component';
2
+ import { withQuery } from 'ufo';
3
+ import { getConnectQueryParam, getOwnerDid } from '../../util';
4
+ import { translate } from '../../../locales';
5
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
6
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
7
+
8
+ /**
9
+ * Exchange Rate Alert Types
10
+ */
11
+ export type ExchangeRateAlertType = 'spread_exceeded' | 'providers_unavailable';
12
+
13
+ export interface ExchangeRateAlertEmailTemplateOptions {
14
+ alertType: ExchangeRateAlertType;
15
+ symbol: string;
16
+ severity: string;
17
+ timestamp: string;
18
+ // For spread_exceeded
19
+ spreadPercent?: string;
20
+ threshold?: number;
21
+ // Provider info
22
+ providers: Array<{
23
+ id: string;
24
+ name: string;
25
+ rate?: string;
26
+ }>;
27
+ }
28
+
29
+ interface ExchangeRateAlertEmailTemplateContext {
30
+ locale: string;
31
+ userDid: string;
32
+ alertType: ExchangeRateAlertType;
33
+ symbol: string;
34
+ severity: string;
35
+ timestamp: string;
36
+ spreadPercent?: string;
37
+ threshold?: number;
38
+ providers: Array<{
39
+ id: string;
40
+ name: string;
41
+ rate?: string;
42
+ }>;
43
+ viewProvidersLink: string;
44
+ }
45
+
46
+ export class ExchangeRateAlertEmailTemplate implements BaseEmailTemplate<ExchangeRateAlertEmailTemplateContext> {
47
+ options: ExchangeRateAlertEmailTemplateOptions;
48
+
49
+ constructor(options: ExchangeRateAlertEmailTemplateOptions) {
50
+ this.options = options;
51
+ }
52
+
53
+ async getContext(): Promise<ExchangeRateAlertEmailTemplateContext> {
54
+ const userDid = await getOwnerDid();
55
+ if (!userDid) {
56
+ throw new Error('get owner did failed');
57
+ }
58
+
59
+ const locale = await getUserLocale(userDid);
60
+
61
+ const viewProvidersLink = getUrl(
62
+ withQuery('admin/settings/exchange-rate-providers', {
63
+ locale,
64
+ ...getConnectQueryParam({ userDid }),
65
+ })
66
+ );
67
+
68
+ return {
69
+ userDid,
70
+ locale,
71
+ alertType: this.options.alertType,
72
+ symbol: this.options.symbol,
73
+ severity: this.options.severity,
74
+ timestamp: this.options.timestamp,
75
+ spreadPercent: this.options.spreadPercent,
76
+ threshold: this.options.threshold,
77
+ providers: this.options.providers,
78
+ viewProvidersLink,
79
+ };
80
+ }
81
+
82
+ async getTemplate(): Promise<BaseEmailTemplateType> {
83
+ const { locale, alertType, symbol, spreadPercent, threshold, providers, timestamp, viewProvidersLink } =
84
+ await this.getContext();
85
+
86
+ const titleKey = `notification.exchangeRateAlert.${alertType}.title`;
87
+ const bodyKey = `notification.exchangeRateAlert.${alertType}.body`;
88
+
89
+ const titleParams: Record<string, any> = { symbol };
90
+ const bodyParams: Record<string, any> = { symbol };
91
+
92
+ if (alertType === 'spread_exceeded') {
93
+ titleParams.spreadPercent = spreadPercent;
94
+ bodyParams.spreadPercent = spreadPercent;
95
+ bodyParams.threshold = threshold;
96
+ }
97
+
98
+ // Build provider list for attachments
99
+ const providerFields: any[] = [];
100
+
101
+ // Add timestamp
102
+ providerFields.push(
103
+ {
104
+ type: 'text',
105
+ data: {
106
+ type: 'plain',
107
+ color: '#9397A1',
108
+ text: translate('notification.exchangeRateAlert.timestamp', locale),
109
+ },
110
+ },
111
+ {
112
+ type: 'text',
113
+ data: {
114
+ type: 'plain',
115
+ text: timestamp,
116
+ },
117
+ }
118
+ );
119
+
120
+ // Add symbol
121
+ providerFields.push(
122
+ {
123
+ type: 'text',
124
+ data: {
125
+ type: 'plain',
126
+ color: '#9397A1',
127
+ text: translate('notification.exchangeRateAlert.symbol', locale),
128
+ },
129
+ },
130
+ {
131
+ type: 'text',
132
+ data: {
133
+ type: 'plain',
134
+ text: symbol,
135
+ },
136
+ }
137
+ );
138
+
139
+ // Add spread info for spread_exceeded
140
+ if (alertType === 'spread_exceeded' && spreadPercent) {
141
+ providerFields.push(
142
+ {
143
+ type: 'text',
144
+ data: {
145
+ type: 'plain',
146
+ color: '#9397A1',
147
+ text: translate('notification.exchangeRateAlert.spread', locale),
148
+ },
149
+ },
150
+ {
151
+ type: 'text',
152
+ data: {
153
+ type: 'plain',
154
+ color: '#FF6600',
155
+ text: `${spreadPercent}% (threshold: ${threshold}%)`,
156
+ },
157
+ }
158
+ );
159
+ }
160
+
161
+ // Add provider rates
162
+ if (providers.length > 0) {
163
+ providerFields.push(
164
+ {
165
+ type: 'text',
166
+ data: {
167
+ type: 'plain',
168
+ color: '#9397A1',
169
+ text: translate('notification.exchangeRateAlert.providers', locale),
170
+ },
171
+ },
172
+ {
173
+ type: 'text',
174
+ data: {
175
+ type: 'plain',
176
+ text: providers.map((p) => `${p.name}: ${p.rate || 'N/A'}`).join('\n'),
177
+ },
178
+ }
179
+ );
180
+ }
181
+
182
+ const template: BaseEmailTemplateType = {
183
+ title: translate(titleKey, locale, titleParams),
184
+ body: translate(bodyKey, locale, bodyParams),
185
+ attachments: [
186
+ {
187
+ type: 'section',
188
+ fields: providerFields,
189
+ },
190
+ ],
191
+ actions: [
192
+ {
193
+ name: translate('notification.exchangeRateAlert.viewProviders', locale),
194
+ title: translate('notification.exchangeRateAlert.viewProviders', locale),
195
+ link: viewProvidersLink,
196
+ },
197
+ ],
198
+ };
199
+
200
+ return template;
201
+ }
202
+ }