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.
@@ -12,6 +12,8 @@ export const revokeStakeCronTime: string = process.env.REVOKE_STAKE_CRON_TIME ||
12
12
  export const daysUntilCancel: string | undefined = process.env.DAYS_UNTIL_CANCEL;
13
13
  export const meteringSubscriptionDetectionCronTime: string =
14
14
  process.env.METERING_SUBSCRIPTION_DETECTION_CRON_TIME || '0 0 10 * * *'; // 默认每天 10:00 执行
15
+ export const overdueDetectionCronTime: string = process.env.OVERDUE_DETECTION_CRON_TIME || '0 0 10 * * *'; // 默认每天 10:00 执行
16
+ export const overdueThreshold: number = process.env.OVERDUE_THRESHOLD ? +process.env.OVERDUE_THRESHOLD : 5; // 默认超额阈值为 5
15
17
  export const depositVaultCronTime: string = process.env.DEPOSIT_VAULT_CRON_TIME || '0 */5 * * * *'; // 默认每 5 min 执行一次
16
18
  export const creditConsumptionCronTime: string = process.env.CREDIT_CONSUMPTION_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
17
19
  export const vendorStatusCheckCronTime: string = process.env.VENDOR_STATUS_CHECK_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
@@ -0,0 +1,222 @@
1
+ /* eslint-disable @typescript-eslint/brace-style */
2
+ /* eslint-disable @typescript-eslint/indent */
3
+ import { withQuery } from 'ufo';
4
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
5
+ import { translate } from '../../../locales';
6
+ import { AutoRechargeConfig, Customer, PaymentMethod, Price, Product } from '../../../store/models';
7
+ import { PaymentCurrency } from '../../../store/models/payment-currency';
8
+ import { getRechargePaymentUrl } from '../../currency';
9
+ import { formatTime } from '../../time';
10
+ import { formatCurrencyInfo, getConnectQueryParam, getCustomerIndexUrl } from '../../util';
11
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
12
+
13
+ export interface CustomerAutoRechargeDailyLimitExceededEmailTemplateOptions {
14
+ customerId: string;
15
+ autoRechargeConfigId: string;
16
+ currencyId: string;
17
+ currentBalance: string;
18
+ }
19
+
20
+ interface CustomerAutoRechargeDailyLimitExceededEmailTemplateContext {
21
+ locale: string;
22
+ userDid: string;
23
+ at: string;
24
+ creditCurrencyName: string;
25
+ currentBalance: string;
26
+ dailyLimit: string;
27
+ dailyUsed: string;
28
+ rechargeUrl: string | null;
29
+ customerIndexUrl: string;
30
+ }
31
+
32
+ export class CustomerAutoRechargeDailyLimitExceededEmailTemplate
33
+ implements BaseEmailTemplate<CustomerAutoRechargeDailyLimitExceededEmailTemplateContext>
34
+ {
35
+ options: CustomerAutoRechargeDailyLimitExceededEmailTemplateOptions;
36
+
37
+ constructor(options: CustomerAutoRechargeDailyLimitExceededEmailTemplateOptions) {
38
+ this.options = options;
39
+ }
40
+
41
+ async getContext(): Promise<CustomerAutoRechargeDailyLimitExceededEmailTemplateContext> {
42
+ const customer = await Customer.findByPk(this.options.customerId);
43
+ if (!customer) {
44
+ throw new Error(`Customer not found: ${this.options.customerId}`);
45
+ }
46
+
47
+ const autoRechargeConfig = await AutoRechargeConfig.findByPk(this.options.autoRechargeConfigId, {
48
+ include: [
49
+ {
50
+ model: Price,
51
+ as: 'price',
52
+ include: [{ model: Product, as: 'product' }],
53
+ },
54
+ {
55
+ model: PaymentMethod,
56
+ as: 'paymentMethod',
57
+ },
58
+ {
59
+ model: PaymentCurrency,
60
+ as: 'rechargeCurrency',
61
+ },
62
+ ],
63
+ });
64
+ if (!autoRechargeConfig) {
65
+ throw new Error(`AutoRechargeConfig not found: ${this.options.autoRechargeConfigId}`);
66
+ }
67
+
68
+ // Get credit currency
69
+ const creditCurrency = await PaymentCurrency.findByPk(autoRechargeConfig.currency_id);
70
+ if (!creditCurrency) {
71
+ throw new Error(`Credit currency not found: ${autoRechargeConfig.currency_id}`);
72
+ }
73
+
74
+ const rechargeCurrency = await PaymentCurrency.findByPk(autoRechargeConfig.recharge_currency_id);
75
+ const paymentMethod = await PaymentMethod.findByPk(autoRechargeConfig.payment_method_id);
76
+
77
+ const userDid: string = customer.did;
78
+ const locale = await getUserLocale(userDid);
79
+ const at: string = formatTime(Date.now());
80
+
81
+ const creditCurrencyName = creditCurrency.name || 'Credits';
82
+ const currentBalance = formatCurrencyInfo(this.options.currentBalance, creditCurrency, null);
83
+
84
+ // Format daily limit and usage
85
+ const dailyLimit = rechargeCurrency
86
+ ? formatCurrencyInfo(autoRechargeConfig.daily_limits?.max_amount || '0', rechargeCurrency, paymentMethod)
87
+ : autoRechargeConfig.daily_limits?.max_amount || '0';
88
+ const dailyUsed = rechargeCurrency
89
+ ? formatCurrencyInfo(autoRechargeConfig.daily_stats?.total_amount || '0', rechargeCurrency, paymentMethod)
90
+ : autoRechargeConfig.daily_stats?.total_amount || '0';
91
+
92
+ // Get recharge URL
93
+ let rechargeUrl: string | null = await getRechargePaymentUrl(creditCurrency);
94
+ if (rechargeUrl) {
95
+ rechargeUrl = withQuery(rechargeUrl, { ...getConnectQueryParam({ userDid }) });
96
+ }
97
+
98
+ // Get customer index URL for managing credits
99
+ const customerIndexUrl = getCustomerIndexUrl({
100
+ locale,
101
+ userDid,
102
+ });
103
+
104
+ return {
105
+ locale,
106
+ userDid,
107
+ at,
108
+ creditCurrencyName,
109
+ currentBalance,
110
+ dailyLimit,
111
+ dailyUsed,
112
+ rechargeUrl,
113
+ customerIndexUrl,
114
+ };
115
+ }
116
+
117
+ async getTemplate(): Promise<BaseEmailTemplateType> {
118
+ const {
119
+ locale,
120
+ userDid,
121
+ at,
122
+ creditCurrencyName,
123
+ currentBalance,
124
+ dailyLimit,
125
+ dailyUsed,
126
+ rechargeUrl,
127
+ customerIndexUrl,
128
+ } = await this.getContext();
129
+
130
+ const template: BaseEmailTemplateType = {
131
+ title: translate('notification.autoRechargeDailyLimitExceeded.title', locale),
132
+ body: translate('notification.autoRechargeDailyLimitExceeded.body', locale, {
133
+ at,
134
+ creditCurrencyName,
135
+ }),
136
+ // @ts-expect-error
137
+ attachments: [
138
+ {
139
+ 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.currentBalance', locale),
162
+ },
163
+ },
164
+ {
165
+ type: 'text',
166
+ data: {
167
+ type: 'plain',
168
+ color: '#FF6B00',
169
+ text: currentBalance,
170
+ },
171
+ },
172
+ {
173
+ type: 'text',
174
+ data: {
175
+ type: 'plain',
176
+ color: '#9397A1',
177
+ text: translate('notification.autoRechargeDailyLimitExceeded.dailyLimit', locale),
178
+ },
179
+ },
180
+ {
181
+ type: 'text',
182
+ data: {
183
+ type: 'plain',
184
+ text: dailyLimit,
185
+ },
186
+ },
187
+ {
188
+ type: 'text',
189
+ data: {
190
+ type: 'plain',
191
+ color: '#9397A1',
192
+ text: translate('notification.autoRechargeDailyLimitExceeded.dailyUsed', locale),
193
+ },
194
+ },
195
+ {
196
+ type: 'text',
197
+ data: {
198
+ type: 'plain',
199
+ text: dailyUsed,
200
+ },
201
+ },
202
+ ].filter(Boolean),
203
+ },
204
+ ].filter(Boolean),
205
+ // @ts-ignore
206
+ actions: [
207
+ rechargeUrl && {
208
+ name: translate('notification.common.reloadCredits', locale),
209
+ title: translate('notification.common.reloadCredits', locale),
210
+ link: rechargeUrl,
211
+ },
212
+ {
213
+ name: translate('notification.common.manageCredit', locale),
214
+ title: translate('notification.common.manageCredit', locale),
215
+ link: customerIndexUrl,
216
+ },
217
+ ].filter(Boolean),
218
+ };
219
+
220
+ return template;
221
+ }
222
+ }
@@ -29,9 +29,8 @@ interface CustomerCreditInsufficientEmailTemplateContext {
29
29
  currencySymbol: string;
30
30
  meterEventName: string;
31
31
  meterName: string;
32
- requiredAmount: string;
33
- availableAmount: string;
34
- isExhausted: boolean;
32
+ pendingAmount: string;
33
+ hasPendingAmount: boolean;
35
34
  productName?: string;
36
35
  viewSubscriptionLink?: string;
37
36
  rechargeUrl: string | null;
@@ -63,9 +62,6 @@ export class CustomerCreditInsufficientEmailTemplate
63
62
  const currencySymbol = paymentCurrency.symbol;
64
63
  const at = formatTime(Date.now());
65
64
 
66
- // 检查是否完全耗尽(可用额度为0或负数)
67
- const isExhausted = new BN(this.options.availableAmount).lte(new BN(0));
68
-
69
65
  // 如果有订阅ID,获取订阅信息
70
66
  let productName: string | undefined;
71
67
  let viewSubscriptionLink: string | undefined;
@@ -87,10 +83,9 @@ export class CustomerCreditInsufficientEmailTemplate
87
83
  rechargeUrl = withQuery(rechargeUrl, { ...getConnectQueryParam({ userDid }) });
88
84
  }
89
85
 
90
- const formattedRequired =
91
- formatNumber(fromUnitToToken(this.options.requiredAmount, paymentCurrency.decimal)) || '0';
92
- const formattedAvailable =
93
- formatNumber(fromUnitToToken(this.options.availableAmount, paymentCurrency.decimal)) || '0';
86
+ const pendingAmountValue = this.options.pendingAmount || '0';
87
+ const pendingAmountBN = new BN(pendingAmountValue);
88
+ const formattedPending = formatNumber(fromUnitToToken(pendingAmountValue, paymentCurrency.decimal)) || '0';
94
89
 
95
90
  return {
96
91
  locale,
@@ -98,9 +93,8 @@ export class CustomerCreditInsufficientEmailTemplate
98
93
  currencySymbol,
99
94
  meterName: this.options.meterName,
100
95
  meterEventName: this.options.meterEventName,
101
- requiredAmount: formatCreditAmount(formattedRequired, currencySymbol),
102
- availableAmount: formatCreditAmount(formattedAvailable, currencySymbol),
103
- isExhausted,
96
+ pendingAmount: formatCreditAmount(formattedPending, currencySymbol),
97
+ hasPendingAmount: pendingAmountBN.gt(new BN(0)),
104
98
  productName,
105
99
  viewSubscriptionLink,
106
100
  rechargeUrl,
@@ -114,9 +108,8 @@ export class CustomerCreditInsufficientEmailTemplate
114
108
  locale,
115
109
  userDid,
116
110
  meterName,
117
- requiredAmount,
118
- availableAmount,
119
- isExhausted,
111
+ pendingAmount,
112
+ hasPendingAmount,
120
113
  productName,
121
114
  viewSubscriptionLink,
122
115
  rechargeUrl,
@@ -177,42 +170,22 @@ export class CustomerCreditInsufficientEmailTemplate
177
170
  ]
178
171
  : [];
179
172
 
180
- // 额度信息字段
181
- const creditFields = [
182
- {
183
- type: 'text',
184
- data: {
185
- type: 'plain',
186
- color: '#9397A1',
187
- text: translate('notification.creditInsufficient.availableCredit', locale),
188
- },
189
- },
190
- {
191
- type: 'text',
192
- data: {
193
- type: 'plain',
194
- color: isExhausted ? '#FF0000' : '#333333',
195
- text: `${availableAmount}`,
196
- },
197
- },
198
- ];
199
-
200
- // 所需额度字段(仅在未完全耗尽时显示)
201
- const requiredCreditFields = !isExhausted
173
+ const pendingFields = hasPendingAmount
202
174
  ? [
203
175
  {
204
176
  type: 'text',
205
177
  data: {
206
178
  type: 'plain',
207
179
  color: '#9397A1',
208
- text: translate('notification.creditInsufficient.requiredCredit', locale),
180
+ text: translate('notification.creditInsufficient.pendingAmount', locale),
209
181
  },
210
182
  },
211
183
  {
212
184
  type: 'text',
213
185
  data: {
214
186
  type: 'plain',
215
- text: `${requiredAmount}`,
187
+ color: '#FF0000',
188
+ text: `${pendingAmount}`,
216
189
  },
217
190
  },
218
191
  ]
@@ -243,31 +216,15 @@ export class CustomerCreditInsufficientEmailTemplate
243
216
  ].filter(Boolean);
244
217
 
245
218
  // 构建标题和正文
246
- let title: string;
247
219
  let body: string;
248
220
 
249
- if (isExhausted) {
250
- title = translate('notification.creditInsufficient.exhaustedTitle', locale);
251
- if (productName) {
252
- body = translate('notification.creditInsufficient.exhaustedBodyWithSubscription', locale, {
253
- subscriptionName: productName,
254
- });
255
- } else {
256
- body = translate('notification.creditInsufficient.exhaustedBodyWithoutSubscription', locale);
257
- }
221
+ const title = translate('notification.creditInsufficient.exhaustedTitle', locale);
222
+ if (productName) {
223
+ body = translate('notification.creditInsufficient.exhaustedBodyWithSubscription', locale, {
224
+ subscriptionName: productName,
225
+ });
258
226
  } else {
259
- title = translate('notification.creditInsufficient.title', locale);
260
- if (productName) {
261
- body = translate('notification.creditInsufficient.bodyWithSubscription', locale, {
262
- availableAmount,
263
-
264
- subscriptionName: productName,
265
- });
266
- } else {
267
- body = translate('notification.creditInsufficient.bodyWithoutSubscription', locale, {
268
- availableAmount,
269
- });
270
- }
227
+ body = translate('notification.creditInsufficient.exhaustedBodyWithoutSubscription', locale);
271
228
  }
272
229
 
273
230
  const template: BaseEmailTemplateType = {
@@ -276,7 +233,7 @@ export class CustomerCreditInsufficientEmailTemplate
276
233
  attachments: [
277
234
  {
278
235
  type: 'section',
279
- fields: [...baseFields, ...productFields, ...creditFields, ...requiredCreditFields].filter(Boolean),
236
+ fields: [...baseFields, ...productFields, ...pendingFields].filter(Boolean),
280
237
  },
281
238
  ],
282
239
  // @ts-ignore
@@ -1,10 +1,10 @@
1
1
  /* eslint-disable @typescript-eslint/brace-style */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
3
  import { withQuery } from 'ufo';
4
- import { fromUnitToToken } from '@ocap/util';
4
+ import { BN, fromUnitToToken } from '@ocap/util';
5
5
  import { getUserLocale } from '../../../integrations/blocklet/notification';
6
6
  import { translate } from '../../../locales';
7
- import { Customer, PaymentCurrency } from '../../../store/models';
7
+ import { Customer, PaymentCurrency, CreditGrant } from '../../../store/models';
8
8
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
9
9
  import { formatCreditAmount, getConnectQueryParam, getCustomerIndexUrl, formatNumber } from '../../util';
10
10
  import { getRechargePaymentUrl } from '../../currency';
@@ -29,6 +29,48 @@ interface CustomerCreditLowBalanceEmailTemplateContext {
29
29
  export class CustomerCreditLowBalanceEmailTemplate
30
30
  implements BaseEmailTemplate<CustomerCreditLowBalanceEmailTemplateContext>
31
31
  {
32
+ // Notification configuration: 10 minute grace period before sending
33
+ static readonly delay = 10 * 60; // seconds
34
+
35
+ /**
36
+ * Get low balance threshold percentage from environment variable
37
+ * @returns threshold percentage (default: 10)
38
+ */
39
+ static getLowBalanceThresholdPercent(): number {
40
+ return parseInt(process.env.CREDIT_LOW_BALANCE_THRESHOLD_PERCENTAGE || '10', 10);
41
+ }
42
+
43
+ /**
44
+ * Preflight check: verify if notification should still be sent
45
+ * Returns false if user has recharged during grace period
46
+ */
47
+ static async preflightCheck(options: CustomerCreditLowBalanceEmailTemplateOptions): Promise<boolean> {
48
+ try {
49
+ const { customerId, currencyId } = options;
50
+
51
+ const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId);
52
+ const totalAmount = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.amount)), new BN(0));
53
+ const remainingAmount = availableGrants.reduce(
54
+ (sum, grant) => sum.add(new BN(grant.remaining_amount)),
55
+ new BN(0)
56
+ );
57
+
58
+ // Skip if no credits exist
59
+ if (totalAmount.lte(new BN(0))) {
60
+ return false;
61
+ }
62
+
63
+ // Calculate current percentage
64
+ const currentPercentage = remainingAmount.mul(new BN(100)).div(totalAmount).toNumber();
65
+
66
+ // If balance is above threshold, user has recharged - skip notification
67
+ return currentPercentage <= CustomerCreditLowBalanceEmailTemplate.getLowBalanceThresholdPercent();
68
+ } catch {
69
+ // On error, proceed with sending to avoid missing important notifications
70
+ return true;
71
+ }
72
+ }
73
+
32
74
  options: CustomerCreditLowBalanceEmailTemplateOptions;
33
75
 
34
76
  constructor(options: CustomerCreditLowBalanceEmailTemplateOptions) {
@@ -36,7 +78,7 @@ export class CustomerCreditLowBalanceEmailTemplate
36
78
  }
37
79
 
38
80
  async getContext(): Promise<CustomerCreditLowBalanceEmailTemplateContext> {
39
- const { customerId, currencyId, availableAmount, percentage } = this.options;
81
+ const { customerId, currencyId } = this.options;
40
82
 
41
83
  const customer = await Customer.findByPk(customerId);
42
84
  if (!customer) {
@@ -48,16 +90,24 @@ export class CustomerCreditLowBalanceEmailTemplate
48
90
  throw new Error(`PaymentCurrency not found: ${currencyId}`);
49
91
  }
50
92
 
93
+ // Fetch latest balance data instead of using stale data from options
94
+ const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId);
95
+ const totalAmount = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.amount)), new BN(0));
96
+ const remainingAmount = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.remaining_amount)), new BN(0));
97
+
98
+ const currentPercentage = totalAmount.gt(new BN(0))
99
+ ? remainingAmount.mul(new BN(100)).div(totalAmount).toNumber()
100
+ : 0;
101
+
51
102
  const userDid = customer.did;
52
103
  const locale = await getUserLocale(userDid);
53
104
 
54
- const formattedAmount = formatNumber(fromUnitToToken(availableAmount, paymentCurrency.decimal));
105
+ const formattedAmount = formatNumber(fromUnitToToken(remainingAmount.toString(), paymentCurrency.decimal));
55
106
  const available = formatCreditAmount(formattedAmount || '0', paymentCurrency.symbol);
56
- const percentageNum = parseFloat(percentage);
57
- const isCritical = percentageNum < 1;
107
+ const isCritical = currentPercentage < 1;
58
108
  const lowBalancePercentage = isCritical
59
109
  ? translate('notification.creditLowBalance.lessThanOnePercent', locale)
60
- : `${percentage}%`;
110
+ : `${currentPercentage}%`;
61
111
 
62
112
  let rechargeUrl: string | null = await getRechargePaymentUrl(paymentCurrency);
63
113
  if (rechargeUrl) {