payment-kit 1.24.2 → 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.
- package/api/src/crons/index.ts +5 -5
- package/api/src/crons/overdue-detection.ts +725 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +222 -0
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +20 -63
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +57 -7
- package/api/src/locales/en.ts +74 -36
- package/api/src/locales/zh.ts +77 -39
- package/api/src/queues/auto-recharge.ts +7 -0
- package/api/src/queues/credit-consume.ts +23 -2
- package/api/src/queues/notification.ts +119 -1
- package/api/src/routes/credit-transactions.ts +85 -7
- package/api/src/routes/invoices.ts +12 -0
- package/api/src/routes/meter-events.ts +169 -0
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/components/customer/credit-overview.tsx +7 -1
- package/src/locales/en.tsx +20 -0
- package/src/locales/zh.tsx +20 -0
- package/src/pages/admin/billing/index.tsx +4 -0
- package/src/pages/admin/billing/meter-events/index.tsx +588 -0
- package/src/pages/admin/billing/overdue/index.tsx +289 -0
- package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +15 -0
- package/src/pages/admin/overview.tsx +129 -1
- package/src/pages/customer/credit-transaction/detail.tsx +12 -0
package/api/src/libs/env.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
const
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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, ...
|
|
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
|
|
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(
|
|
105
|
+
const formattedAmount = formatNumber(fromUnitToToken(remainingAmount.toString(), paymentCurrency.decimal));
|
|
55
106
|
const available = formatCreditAmount(formattedAmount || '0', paymentCurrency.symbol);
|
|
56
|
-
const
|
|
57
|
-
const isCritical = percentageNum < 1;
|
|
107
|
+
const isCritical = currentPercentage < 1;
|
|
58
108
|
const lowBalancePercentage = isCritical
|
|
59
109
|
? translate('notification.creditLowBalance.lessThanOnePercent', locale)
|
|
60
|
-
: `${
|
|
110
|
+
: `${currentPercentage}%`;
|
|
61
111
|
|
|
62
112
|
let rechargeUrl: string | null = await getRechargePaymentUrl(paymentCurrency);
|
|
63
113
|
if (rechargeUrl) {
|