payment-kit 1.22.23 → 1.22.25
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/libs/currency.ts +72 -1
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +25 -8
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +52 -27
- package/api/src/locales/en.ts +12 -9
- package/api/src/locales/zh.ts +11 -8
- package/api/src/routes/credit-grants.ts +205 -2
- package/api/src/routes/payment-currencies.ts +3 -30
- package/blocklet.yml +1 -1
- package/package.json +27 -27
package/api/src/libs/currency.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
2
|
-
import {
|
|
2
|
+
import { getUrl } from '@blocklet/sdk';
|
|
3
|
+
import { PaymentCurrency, Price, Product, RechargeConfig } from '../store/models';
|
|
3
4
|
import { trimDecimals } from './math-utils';
|
|
5
|
+
import { createPaymentLink } from '../routes/payment-links';
|
|
6
|
+
import logger from './logger';
|
|
4
7
|
|
|
5
8
|
export async function formatCurrencyToken(amount: string, currencyId: string) {
|
|
6
9
|
if (!amount) {
|
|
@@ -29,3 +32,71 @@ export async function formatCurrencyUnit(amount: string, currencyId: string) {
|
|
|
29
32
|
}
|
|
30
33
|
return fromTokenToUnit(amount || '0', currency.decimal).toString();
|
|
31
34
|
}
|
|
35
|
+
|
|
36
|
+
export async function getRechargePaymentUrl(
|
|
37
|
+
currency: PaymentCurrency & { recharge_config?: RechargeConfig }
|
|
38
|
+
): Promise<string | null> {
|
|
39
|
+
try {
|
|
40
|
+
if (!currency || !currency.recharge_config) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rechargeConfig = currency.recharge_config;
|
|
45
|
+
const checkoutUrl = rechargeConfig.checkout_url;
|
|
46
|
+
|
|
47
|
+
if (checkoutUrl) {
|
|
48
|
+
return checkoutUrl;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (rechargeConfig.payment_link_id) {
|
|
52
|
+
return getUrl(`/checkout/pay/${rechargeConfig.payment_link_id}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (rechargeConfig.base_price_id) {
|
|
56
|
+
const basePrice = (await Price.findByPk(rechargeConfig.base_price_id, {
|
|
57
|
+
include: [{ model: Product, as: 'product' }],
|
|
58
|
+
})) as (Price & { product: Product }) | null;
|
|
59
|
+
|
|
60
|
+
if (!basePrice) {
|
|
61
|
+
logger.warn('Base price not found for recharge config', {
|
|
62
|
+
currencyId: currency.id,
|
|
63
|
+
basePriceId: rechargeConfig.base_price_id,
|
|
64
|
+
});
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const paymentLink = await createPaymentLink({
|
|
69
|
+
livemode: currency.livemode,
|
|
70
|
+
currency_id: basePrice.currency_id,
|
|
71
|
+
name: basePrice.product?.name || `${currency.name} Recharge`,
|
|
72
|
+
submit_type: 'pay',
|
|
73
|
+
allow_promotion_codes: true,
|
|
74
|
+
line_items: [
|
|
75
|
+
{
|
|
76
|
+
price_id: rechargeConfig.base_price_id,
|
|
77
|
+
quantity: 1,
|
|
78
|
+
adjustable_quantity: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
minimum: 1,
|
|
81
|
+
maximum: 100000000,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await currency.update({
|
|
88
|
+
recharge_config: { ...rechargeConfig, payment_link_id: paymentLink.id },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return getUrl(`/checkout/pay/${paymentLink.id}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
} catch (error: any) {
|
|
96
|
+
logger.error('Failed to get recharge payment URL', {
|
|
97
|
+
currencyId: currency.id,
|
|
98
|
+
error: error.message,
|
|
99
|
+
});
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/brace-style */
|
|
2
2
|
/* eslint-disable @typescript-eslint/indent */
|
|
3
3
|
import { BN, fromUnitToToken } from '@ocap/util';
|
|
4
|
+
import { withQuery } from 'ufo';
|
|
4
5
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
5
6
|
import { translate } from '../../../locales';
|
|
6
7
|
import { Customer, PaymentCurrency, Subscription } from '../../../store/models';
|
|
@@ -8,7 +9,8 @@ import { getMainProductName } from '../../product';
|
|
|
8
9
|
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
9
10
|
import { formatTime } from '../../time';
|
|
10
11
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
11
|
-
import { formatNumber, getCustomerIndexUrl } from '../../util';
|
|
12
|
+
import { formatNumber, getConnectQueryParam, getCustomerIndexUrl } from '../../util';
|
|
13
|
+
import { getRechargePaymentUrl } from '../../currency';
|
|
12
14
|
|
|
13
15
|
export interface CustomerCreditInsufficientEmailTemplateOptions {
|
|
14
16
|
customerId: string;
|
|
@@ -32,6 +34,7 @@ interface CustomerCreditInsufficientEmailTemplateContext {
|
|
|
32
34
|
isExhausted: boolean;
|
|
33
35
|
productName?: string;
|
|
34
36
|
viewSubscriptionLink?: string;
|
|
37
|
+
rechargeUrl: string | null;
|
|
35
38
|
at: string;
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -50,7 +53,7 @@ export class CustomerCreditInsufficientEmailTemplate
|
|
|
50
53
|
throw new Error(`Customer not found: ${this.options.customerId}`);
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
const paymentCurrency = await PaymentCurrency.findByPk(this.options.currencyId);
|
|
56
|
+
const paymentCurrency = await PaymentCurrency.scope('withRechargeConfig').findByPk(this.options.currencyId);
|
|
54
57
|
if (!paymentCurrency) {
|
|
55
58
|
throw new Error(`PaymentCurrency not found: ${this.options.currencyId}`);
|
|
56
59
|
}
|
|
@@ -79,6 +82,11 @@ export class CustomerCreditInsufficientEmailTemplate
|
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
let rechargeUrl: string | null = await getRechargePaymentUrl(paymentCurrency);
|
|
86
|
+
if (rechargeUrl) {
|
|
87
|
+
rechargeUrl = withQuery(rechargeUrl, { ...getConnectQueryParam({ userDid }) });
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
return {
|
|
83
91
|
locale,
|
|
84
92
|
userDid,
|
|
@@ -90,6 +98,7 @@ export class CustomerCreditInsufficientEmailTemplate
|
|
|
90
98
|
isExhausted,
|
|
91
99
|
productName,
|
|
92
100
|
viewSubscriptionLink,
|
|
101
|
+
rechargeUrl,
|
|
93
102
|
at,
|
|
94
103
|
};
|
|
95
104
|
}
|
|
@@ -105,6 +114,7 @@ export class CustomerCreditInsufficientEmailTemplate
|
|
|
105
114
|
isExhausted,
|
|
106
115
|
productName,
|
|
107
116
|
viewSubscriptionLink,
|
|
117
|
+
rechargeUrl,
|
|
108
118
|
} = context;
|
|
109
119
|
|
|
110
120
|
// 构建基础字段
|
|
@@ -204,19 +214,26 @@ export class CustomerCreditInsufficientEmailTemplate
|
|
|
204
214
|
: [];
|
|
205
215
|
|
|
206
216
|
// 构建操作按钮
|
|
217
|
+
const customerIndexUrl = getCustomerIndexUrl({
|
|
218
|
+
locale,
|
|
219
|
+
userDid,
|
|
220
|
+
});
|
|
221
|
+
|
|
207
222
|
const actions = [
|
|
223
|
+
rechargeUrl && {
|
|
224
|
+
name: translate('notification.common.reloadCredits', locale),
|
|
225
|
+
title: translate('notification.common.reloadCredits', locale),
|
|
226
|
+
link: rechargeUrl,
|
|
227
|
+
},
|
|
208
228
|
viewSubscriptionLink && {
|
|
209
229
|
name: translate('notification.common.viewSubscription', locale),
|
|
210
230
|
title: translate('notification.common.viewSubscription', locale),
|
|
211
231
|
link: viewSubscriptionLink,
|
|
212
232
|
},
|
|
213
233
|
{
|
|
214
|
-
name: translate('notification.common.
|
|
215
|
-
title: translate('notification.common.
|
|
216
|
-
link:
|
|
217
|
-
locale,
|
|
218
|
-
userDid,
|
|
219
|
-
}),
|
|
234
|
+
name: translate('notification.common.manageCredit', locale),
|
|
235
|
+
title: translate('notification.common.manageCredit', locale),
|
|
236
|
+
link: customerIndexUrl,
|
|
220
237
|
},
|
|
221
238
|
].filter(Boolean);
|
|
222
239
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/brace-style */
|
|
2
2
|
/* eslint-disable @typescript-eslint/indent */
|
|
3
3
|
import { fromUnitToToken } from '@ocap/util';
|
|
4
|
+
import { withQuery } from 'ufo';
|
|
4
5
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
5
6
|
import { translate } from '../../../locales';
|
|
6
7
|
import { Customer, PaymentCurrency } from '../../../store/models';
|
|
7
8
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
8
|
-
import { formatNumber, getCustomerIndexUrl } from '../../util';
|
|
9
|
+
import { formatNumber, getConnectQueryParam, getCustomerIndexUrl } from '../../util';
|
|
10
|
+
import { getRechargePaymentUrl } from '../../currency';
|
|
9
11
|
|
|
10
12
|
export interface CustomerCreditLowBalanceEmailTemplateOptions {
|
|
11
13
|
customerId: string;
|
|
@@ -20,9 +22,10 @@ interface CustomerCreditLowBalanceEmailTemplateContext {
|
|
|
20
22
|
userDid: string;
|
|
21
23
|
currencySymbol: string;
|
|
22
24
|
availableAmount: string; // formatted with symbol
|
|
23
|
-
|
|
24
|
-
lowBalancePercentage: string; // with %
|
|
25
|
+
lowBalancePercentage: string; // with % or "less than 1%"
|
|
25
26
|
currencyName: string;
|
|
27
|
+
isCritical: boolean; // true if percentage < 1%
|
|
28
|
+
rechargeUrl: string | null;
|
|
26
29
|
}
|
|
27
30
|
export class CustomerCreditLowBalanceEmailTemplate
|
|
28
31
|
implements BaseEmailTemplate<CustomerCreditLowBalanceEmailTemplateContext>
|
|
@@ -34,14 +37,14 @@ export class CustomerCreditLowBalanceEmailTemplate
|
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
async getContext(): Promise<CustomerCreditLowBalanceEmailTemplateContext> {
|
|
37
|
-
const { customerId, currencyId, availableAmount,
|
|
40
|
+
const { customerId, currencyId, availableAmount, percentage } = this.options;
|
|
38
41
|
|
|
39
42
|
const customer = await Customer.findByPk(customerId);
|
|
40
43
|
if (!customer) {
|
|
41
44
|
throw new Error(`Customer not found: ${customerId}`);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
const paymentCurrency = await PaymentCurrency.findByPk(currencyId);
|
|
47
|
+
const paymentCurrency = await PaymentCurrency.scope('withRechargeConfig').findByPk(currencyId);
|
|
45
48
|
if (!paymentCurrency) {
|
|
46
49
|
throw new Error(`PaymentCurrency not found: ${currencyId}`);
|
|
47
50
|
}
|
|
@@ -51,23 +54,41 @@ export class CustomerCreditLowBalanceEmailTemplate
|
|
|
51
54
|
const currencySymbol = paymentCurrency.symbol;
|
|
52
55
|
|
|
53
56
|
const available = formatNumber(fromUnitToToken(availableAmount, paymentCurrency.decimal));
|
|
54
|
-
const
|
|
57
|
+
const percentageNum = parseFloat(percentage);
|
|
58
|
+
const isCritical = percentageNum < 1;
|
|
59
|
+
const lowBalancePercentage = isCritical
|
|
60
|
+
? translate('notification.creditLowBalance.lessThanOnePercent', locale)
|
|
61
|
+
: `${percentage}%`;
|
|
62
|
+
|
|
63
|
+
let rechargeUrl: string | null = await getRechargePaymentUrl(paymentCurrency);
|
|
64
|
+
if (rechargeUrl) {
|
|
65
|
+
rechargeUrl = withQuery(rechargeUrl, { ...getConnectQueryParam({ userDid }) });
|
|
66
|
+
}
|
|
55
67
|
|
|
56
68
|
return {
|
|
57
69
|
locale,
|
|
58
70
|
userDid,
|
|
59
71
|
currencySymbol,
|
|
60
72
|
availableAmount: `${available}`,
|
|
61
|
-
|
|
62
|
-
lowBalancePercentage: `${percentage}%`,
|
|
73
|
+
lowBalancePercentage,
|
|
63
74
|
currencyName: paymentCurrency.name,
|
|
75
|
+
isCritical,
|
|
76
|
+
rechargeUrl,
|
|
64
77
|
};
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
async getTemplate(): Promise<BaseEmailTemplateType> {
|
|
68
81
|
const context = await this.getContext();
|
|
69
|
-
const {
|
|
70
|
-
|
|
82
|
+
const {
|
|
83
|
+
locale,
|
|
84
|
+
userDid,
|
|
85
|
+
availableAmount,
|
|
86
|
+
lowBalancePercentage,
|
|
87
|
+
currencyName,
|
|
88
|
+
currencySymbol,
|
|
89
|
+
isCritical,
|
|
90
|
+
rechargeUrl,
|
|
91
|
+
} = context;
|
|
71
92
|
|
|
72
93
|
const fields = [
|
|
73
94
|
{
|
|
@@ -90,15 +111,15 @@ export class CustomerCreditLowBalanceEmailTemplate
|
|
|
90
111
|
data: {
|
|
91
112
|
type: 'plain',
|
|
92
113
|
color: '#9397A1',
|
|
93
|
-
text: translate('notification.
|
|
114
|
+
text: translate('notification.creditLowBalance.remainingBalance', locale),
|
|
94
115
|
},
|
|
95
116
|
},
|
|
96
117
|
{
|
|
97
118
|
type: 'text',
|
|
98
119
|
data: {
|
|
99
120
|
type: 'plain',
|
|
100
|
-
color: '#FF6600',
|
|
101
|
-
text: `${availableAmount} ${currencySymbol}
|
|
121
|
+
color: isCritical ? '#FF0000' : '#FF6600',
|
|
122
|
+
text: `${availableAmount} ${currencySymbol}`,
|
|
102
123
|
},
|
|
103
124
|
},
|
|
104
125
|
{
|
|
@@ -106,38 +127,42 @@ export class CustomerCreditLowBalanceEmailTemplate
|
|
|
106
127
|
data: {
|
|
107
128
|
type: 'plain',
|
|
108
129
|
color: '#9397A1',
|
|
109
|
-
text: translate('notification.creditLowBalance.
|
|
130
|
+
text: translate('notification.creditLowBalance.status', locale),
|
|
110
131
|
},
|
|
111
132
|
},
|
|
112
133
|
{
|
|
113
134
|
type: 'text',
|
|
114
135
|
data: {
|
|
115
136
|
type: 'plain',
|
|
116
|
-
|
|
137
|
+
color: isCritical ? '#FF0000' : '#FF6600',
|
|
138
|
+
text: lowBalancePercentage,
|
|
117
139
|
},
|
|
118
140
|
},
|
|
119
141
|
];
|
|
120
142
|
|
|
143
|
+
const customerIndexUrl = getCustomerIndexUrl({
|
|
144
|
+
locale,
|
|
145
|
+
userDid,
|
|
146
|
+
});
|
|
147
|
+
|
|
121
148
|
const actions = [
|
|
149
|
+
rechargeUrl && {
|
|
150
|
+
name: translate('notification.common.reloadCredits', locale),
|
|
151
|
+
title: translate('notification.common.reloadCredits', locale),
|
|
152
|
+
link: rechargeUrl,
|
|
153
|
+
},
|
|
122
154
|
{
|
|
123
|
-
name: translate('notification.common.
|
|
124
|
-
title: translate('notification.common.
|
|
125
|
-
link:
|
|
126
|
-
locale,
|
|
127
|
-
userDid,
|
|
128
|
-
}),
|
|
155
|
+
name: translate('notification.common.manageCredit', locale),
|
|
156
|
+
title: translate('notification.common.manageCredit', locale),
|
|
157
|
+
link: customerIndexUrl,
|
|
129
158
|
},
|
|
130
|
-
];
|
|
159
|
+
].filter(Boolean);
|
|
131
160
|
|
|
132
161
|
const template: BaseEmailTemplateType = {
|
|
133
|
-
title: translate('notification.creditLowBalance.title', locale,
|
|
134
|
-
lowBalancePercentage,
|
|
135
|
-
currency: currencyName,
|
|
136
|
-
}),
|
|
162
|
+
title: translate('notification.creditLowBalance.title', locale),
|
|
137
163
|
body: translate('notification.creditLowBalance.body', locale, {
|
|
138
164
|
currency: currencyName,
|
|
139
165
|
availableAmount,
|
|
140
|
-
totalAmount,
|
|
141
166
|
lowBalancePercentage,
|
|
142
167
|
}),
|
|
143
168
|
attachments: [
|
package/api/src/locales/en.ts
CHANGED
|
@@ -49,6 +49,8 @@ export default flat({
|
|
|
49
49
|
shouldPayAmount: 'Should pay amount',
|
|
50
50
|
billedAmount: 'Billed amount',
|
|
51
51
|
viewCreditGrant: 'View Credit Balance',
|
|
52
|
+
manageCredit: 'Manage Credits',
|
|
53
|
+
reloadCredits: 'Reload Credits',
|
|
52
54
|
invoiceNumber: 'Invoice Number',
|
|
53
55
|
payer: 'Payer',
|
|
54
56
|
},
|
|
@@ -253,18 +255,17 @@ export default flat({
|
|
|
253
255
|
creditInsufficient: {
|
|
254
256
|
title: 'Insufficient Credit',
|
|
255
257
|
bodyWithSubscription:
|
|
256
|
-
'Your available credit
|
|
258
|
+
'Your available credit ({availableAmount}) is not enough to cover your subscription to {subscriptionName}. To ensure uninterrupted service, please reload your account.',
|
|
257
259
|
bodyWithoutSubscription:
|
|
258
|
-
'Your available credit
|
|
259
|
-
exhaustedTitle: 'Credit Exhausted – Please
|
|
260
|
+
'Your available credit ({availableAmount}) is not enough to continue using the service. To ensure uninterrupted service, please reload your account.',
|
|
261
|
+
exhaustedTitle: 'Credit Exhausted – Please Reload',
|
|
260
262
|
exhaustedBodyWithSubscription:
|
|
261
|
-
'Your credit is fully exhausted and can no longer cover your subscription to {subscriptionName}.
|
|
263
|
+
'Your credit is fully exhausted and can no longer cover your subscription to {subscriptionName}. To ensure uninterrupted service, please reload your account.',
|
|
262
264
|
exhaustedBodyWithoutSubscription:
|
|
263
|
-
'Your credit is fully exhausted
|
|
265
|
+
'Your credit is fully exhausted. To ensure uninterrupted service, please reload your account.',
|
|
264
266
|
meterEventName: 'Service',
|
|
265
267
|
availableCredit: 'Available Credit Amount',
|
|
266
268
|
requiredCredit: 'Required Credit Amount',
|
|
267
|
-
topUpNow: 'Top Up Now',
|
|
268
269
|
},
|
|
269
270
|
|
|
270
271
|
creditGrantGranted: {
|
|
@@ -277,9 +278,11 @@ export default flat({
|
|
|
277
278
|
},
|
|
278
279
|
|
|
279
280
|
creditLowBalance: {
|
|
280
|
-
title: 'Your
|
|
281
|
-
body: 'Your {currency}
|
|
282
|
-
|
|
281
|
+
title: 'Your credit balance is low',
|
|
282
|
+
body: 'Your {currency} balance has dropped to critical levels ({lowBalancePercentage}). To ensure uninterrupted service, please reload your account.',
|
|
283
|
+
remainingBalance: 'Remaining Balance',
|
|
284
|
+
status: 'Status',
|
|
285
|
+
lessThanOnePercent: 'less than 1%',
|
|
283
286
|
},
|
|
284
287
|
},
|
|
285
288
|
});
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -49,6 +49,8 @@ export default flat({
|
|
|
49
49
|
shouldPayAmount: '应收金额',
|
|
50
50
|
billedAmount: '实缴金额',
|
|
51
51
|
viewCreditGrant: '查看额度',
|
|
52
|
+
reloadCredits: '立即充值',
|
|
53
|
+
manageCredit: '管理额度',
|
|
52
54
|
invoiceNumber: '账单编号',
|
|
53
55
|
payer: '付款方',
|
|
54
56
|
},
|
|
@@ -243,16 +245,15 @@ export default flat({
|
|
|
243
245
|
creditInsufficient: {
|
|
244
246
|
title: '额度不足,服务可能受限',
|
|
245
247
|
bodyWithSubscription:
|
|
246
|
-
'您的信用额度仅剩 {availableAmount},不足以支付您订阅的 {subscriptionName}
|
|
247
|
-
bodyWithoutSubscription: '您的信用额度仅剩 {availableAmount}
|
|
248
|
+
'您的信用额度仅剩 {availableAmount},不足以支付您订阅的 {subscriptionName}。为避免服务中断,请及时充值。',
|
|
249
|
+
bodyWithoutSubscription: '您的信用额度仅剩 {availableAmount},不足以继续使用服务。为避免服务受限,请及时充值。',
|
|
248
250
|
exhaustedTitle: '额度已用尽,请尽快充值',
|
|
249
251
|
exhaustedBodyWithSubscription:
|
|
250
|
-
'您的信用额度已用尽,无法继续支付您订阅的 {subscriptionName}
|
|
251
|
-
exhaustedBodyWithoutSubscription: '
|
|
252
|
+
'您的信用额度已用尽,无法继续支付您订阅的 {subscriptionName}。为避免服务中断,请及时充值。',
|
|
253
|
+
exhaustedBodyWithoutSubscription: '您的信用额度已用尽。为确保服务正常使用,请及时充值。',
|
|
252
254
|
meterEventName: '服务项目',
|
|
253
255
|
availableCredit: '剩余额度',
|
|
254
256
|
requiredCredit: '所需额度',
|
|
255
|
-
topUpNow: '立即充值',
|
|
256
257
|
},
|
|
257
258
|
|
|
258
259
|
creditGrantGranted: {
|
|
@@ -265,9 +266,11 @@ export default flat({
|
|
|
265
266
|
},
|
|
266
267
|
|
|
267
268
|
creditLowBalance: {
|
|
268
|
-
title: '
|
|
269
|
-
body: '您的 {currency}
|
|
270
|
-
|
|
269
|
+
title: '您的信用余额偏低',
|
|
270
|
+
body: '您的 {currency} 余额已降至临界水平({lowBalancePercentage})。为避免服务中断,请及时充值。',
|
|
271
|
+
remainingBalance: '剩余余额',
|
|
272
|
+
status: '状态',
|
|
273
|
+
lessThanOnePercent: '低于 1%',
|
|
271
274
|
},
|
|
272
275
|
},
|
|
273
276
|
});
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import Joi from 'joi';
|
|
3
|
-
import { fromTokenToUnit } from '@ocap/util';
|
|
3
|
+
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
4
4
|
|
|
5
5
|
import { literal, OrderItem } from 'sequelize';
|
|
6
6
|
import pick from 'lodash/pick';
|
|
7
7
|
import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
|
|
8
8
|
import logger from '../libs/logger';
|
|
9
9
|
import { authenticate } from '../libs/security';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AutoRechargeConfig,
|
|
12
|
+
CreditGrant,
|
|
13
|
+
Customer,
|
|
14
|
+
MeterEvent,
|
|
15
|
+
PaymentCurrency,
|
|
16
|
+
PaymentMethod,
|
|
17
|
+
Price,
|
|
18
|
+
Product,
|
|
19
|
+
Subscription,
|
|
20
|
+
} from '../store/models';
|
|
11
21
|
import { createCreditGrant } from '../libs/credit-grant';
|
|
12
22
|
import { getMeterPriceIdsFromSubscription } from '../libs/subscription';
|
|
13
23
|
import { blocklet } from '../libs/auth';
|
|
14
24
|
import { formatMetadata } from '../libs/util';
|
|
25
|
+
import { getPriceUintAmountByCurrency } from '../libs/price';
|
|
26
|
+
import { checkTokenBalance } from '../libs/payment';
|
|
15
27
|
|
|
16
28
|
const router = Router();
|
|
17
29
|
const auth = authenticate<CreditGrant>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -150,6 +162,197 @@ router.get('/summary', authMine, async (req, res) => {
|
|
|
150
162
|
}
|
|
151
163
|
});
|
|
152
164
|
|
|
165
|
+
const checkAutoRechargeSchema = Joi.object({
|
|
166
|
+
customer_id: Joi.string().required(),
|
|
167
|
+
currency_id: Joi.string().required(),
|
|
168
|
+
pending_amount: Joi.string().optional(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
router.get('/verify-availability', authMine, async (req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const { error, value } = checkAutoRechargeSchema.validate(req.query, { stripUnknown: true });
|
|
174
|
+
if (error) {
|
|
175
|
+
return res.status(400).json({ error: error.message });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const { customer_id: customerId, currency_id: currencyId } = value;
|
|
179
|
+
let pendingAmount = value.pending_amount;
|
|
180
|
+
|
|
181
|
+
const customer = await Customer.findByPkOrDid(customerId);
|
|
182
|
+
if (!customer) {
|
|
183
|
+
return res.status(404).json({ error: `Customer ${customerId} not found` });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
187
|
+
if (!currency) {
|
|
188
|
+
return res.status(404).json({ error: `PaymentCurrency ${currencyId} not found` });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const config = (await AutoRechargeConfig.findOne({
|
|
192
|
+
where: {
|
|
193
|
+
customer_id: customer.id,
|
|
194
|
+
currency_id: currencyId,
|
|
195
|
+
enabled: true,
|
|
196
|
+
},
|
|
197
|
+
include: [
|
|
198
|
+
{ model: PaymentCurrency, as: 'rechargeCurrency', required: false },
|
|
199
|
+
{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] },
|
|
200
|
+
{ model: PaymentMethod, as: 'paymentMethod', required: false },
|
|
201
|
+
],
|
|
202
|
+
})) as
|
|
203
|
+
| (AutoRechargeConfig & {
|
|
204
|
+
rechargeCurrency?: PaymentCurrency;
|
|
205
|
+
price?: Price & { product?: Product };
|
|
206
|
+
paymentMethod?: PaymentMethod;
|
|
207
|
+
})
|
|
208
|
+
| null;
|
|
209
|
+
|
|
210
|
+
if (!config) {
|
|
211
|
+
return res.json({
|
|
212
|
+
can_continue: false,
|
|
213
|
+
has_auto_recharge: false,
|
|
214
|
+
reason: 'auto_recharge_config_not_found',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 1. Check config completeness
|
|
219
|
+
if (!config.rechargeCurrency) {
|
|
220
|
+
return res.json({
|
|
221
|
+
can_continue: false,
|
|
222
|
+
reason: 'recharge_currency_not_found',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!config.price) {
|
|
227
|
+
return res.json({
|
|
228
|
+
can_continue: false,
|
|
229
|
+
reason: 'price_not_found',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!config.paymentMethod) {
|
|
234
|
+
return res.json({
|
|
235
|
+
can_continue: false,
|
|
236
|
+
reason: 'payment_method_not_found',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2. Check if stripe (balance check not supported)
|
|
241
|
+
if (config.paymentMethod.type === 'stripe') {
|
|
242
|
+
return res.json({
|
|
243
|
+
can_continue: false,
|
|
244
|
+
reason: 'balance_check_not_supported',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 3. Check price amount
|
|
249
|
+
const priceAmount = await getPriceUintAmountByCurrency(config.price, config.rechargeCurrency.id);
|
|
250
|
+
if (!priceAmount) {
|
|
251
|
+
return res.json({
|
|
252
|
+
can_continue: false,
|
|
253
|
+
reason: 'invalid_price_amount',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 1));
|
|
258
|
+
|
|
259
|
+
// 4. Get pending amount if not provided
|
|
260
|
+
if (!pendingAmount) {
|
|
261
|
+
const [pendingSummary] = await MeterEvent.getPendingAmounts({
|
|
262
|
+
customerId: customer.id,
|
|
263
|
+
currencyId,
|
|
264
|
+
livemode: req.livemode,
|
|
265
|
+
});
|
|
266
|
+
pendingAmount = pendingSummary?.[currencyId] || '0';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 5. Check daily limit
|
|
270
|
+
const pendingAmountBN = new BN(pendingAmount);
|
|
271
|
+
const today = new Date().toISOString().split('T')[0];
|
|
272
|
+
const isNewDay = config.last_recharge_date !== today;
|
|
273
|
+
|
|
274
|
+
// Calculate required recharge times: if pendingAmount > 0, calculate needed times; otherwise check if at least one recharge is possible
|
|
275
|
+
const requiredRechargeTimes = pendingAmountBN.gt(new BN(0))
|
|
276
|
+
? pendingAmountBN.add(totalAmount).sub(new BN(1)).div(totalAmount).toNumber()
|
|
277
|
+
: 1;
|
|
278
|
+
|
|
279
|
+
if (!isNewDay && config.daily_stats && config.daily_limits) {
|
|
280
|
+
// Check attempt limit
|
|
281
|
+
const maxAttempts = Number(config.daily_limits.max_attempts);
|
|
282
|
+
if (maxAttempts > 0) {
|
|
283
|
+
const remainingAttempts = maxAttempts - Number(config.daily_stats.attempt_count);
|
|
284
|
+
if (requiredRechargeTimes > remainingAttempts) {
|
|
285
|
+
return res.json({
|
|
286
|
+
can_continue: false,
|
|
287
|
+
reason: 'daily_limit_reached',
|
|
288
|
+
detail: 'attempt_limit_exceeded',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Check amount limit
|
|
294
|
+
const maxAmount = new BN(config.daily_limits.max_amount || '0');
|
|
295
|
+
if (maxAmount.gt(new BN(0))) {
|
|
296
|
+
const requiredTotalAmount = totalAmount.mul(new BN(requiredRechargeTimes));
|
|
297
|
+
const remainingAmount = maxAmount.sub(new BN(config.daily_stats.total_amount || '0'));
|
|
298
|
+
if (requiredTotalAmount.gt(remainingAmount)) {
|
|
299
|
+
return res.json({
|
|
300
|
+
can_continue: false,
|
|
301
|
+
reason: 'daily_limit_reached',
|
|
302
|
+
detail: 'amount_limit_exceeded',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 6. Check payment account balance
|
|
309
|
+
const payer =
|
|
310
|
+
config.payment_settings?.payment_method_options?.[
|
|
311
|
+
config.paymentMethod.type as keyof typeof config.payment_settings.payment_method_options
|
|
312
|
+
]?.payer || customer.did;
|
|
313
|
+
|
|
314
|
+
if (!payer) {
|
|
315
|
+
return res.json({
|
|
316
|
+
can_continue: false,
|
|
317
|
+
reason: 'payer_not_found',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check token balance: if pendingAmount > 0, check if balance can cover pending; otherwise check if balance can cover at least one recharge
|
|
322
|
+
const amountToCheck = pendingAmountBN.gt(new BN(0)) ? pendingAmount : totalAmount.toString();
|
|
323
|
+
const balanceResult = await checkTokenBalance({
|
|
324
|
+
paymentMethod: config.paymentMethod,
|
|
325
|
+
paymentCurrency: config.rechargeCurrency,
|
|
326
|
+
userDid: payer,
|
|
327
|
+
amount: amountToCheck,
|
|
328
|
+
skipUserCheck: true,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (!balanceResult.sufficient) {
|
|
332
|
+
return res.json({
|
|
333
|
+
can_continue: false,
|
|
334
|
+
reason: 'insufficient_balance',
|
|
335
|
+
payment_account_balance: balanceResult.token?.balance || '0',
|
|
336
|
+
pending_amount: pendingAmount,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return res.json({
|
|
341
|
+
can_continue: true,
|
|
342
|
+
payment_account_sufficient: true,
|
|
343
|
+
payment_account_balance: balanceResult.token?.balance || '0',
|
|
344
|
+
pending_amount: pendingAmount,
|
|
345
|
+
});
|
|
346
|
+
} catch (err: any) {
|
|
347
|
+
logger.error('check auto recharge failed', {
|
|
348
|
+
error: err.message,
|
|
349
|
+
customerId: req.query.customer_id,
|
|
350
|
+
currencyId: req.query.currency_id,
|
|
351
|
+
});
|
|
352
|
+
return res.status(400).json({ error: err.message });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
153
356
|
router.get('/:id', authPortal, async (req, res) => {
|
|
154
357
|
const creditGrant = await CreditGrant.findByPk(req.params.id, {
|
|
155
358
|
include: [
|
|
@@ -4,7 +4,6 @@ import { InferAttributes, Op, WhereOptions } from 'sequelize';
|
|
|
4
4
|
|
|
5
5
|
import Joi from 'joi';
|
|
6
6
|
import pick from 'lodash/pick';
|
|
7
|
-
import { getUrl } from '@blocklet/sdk';
|
|
8
7
|
import { sessionMiddleware } from '@blocklet/sdk/lib/middlewares/session';
|
|
9
8
|
import { fetchErc20Meta } from '../integrations/ethereum/token';
|
|
10
9
|
import logger from '../libs/logger';
|
|
@@ -18,8 +17,8 @@ import { resolveAddressChainTypes } from '../libs/util';
|
|
|
18
17
|
import { depositVaultQueue } from '../queues/payment';
|
|
19
18
|
import { checkDepositVaultAmount } from '../libs/payment';
|
|
20
19
|
import { getTokenSummaryByDid } from '../integrations/arcblock/stake';
|
|
21
|
-
import { createPaymentLink } from './payment-links';
|
|
22
20
|
import { MetadataSchema } from '../libs/api';
|
|
21
|
+
import { getRechargePaymentUrl } from '../libs/currency';
|
|
23
22
|
|
|
24
23
|
const router = Router();
|
|
25
24
|
|
|
@@ -394,6 +393,8 @@ router.get('/:id/recharge-config', user, async (req, res) => {
|
|
|
394
393
|
});
|
|
395
394
|
}
|
|
396
395
|
|
|
396
|
+
const paymentUrl = await getRechargePaymentUrl(currency);
|
|
397
|
+
|
|
397
398
|
let basePrice: (Price & { product: Product }) | null = null;
|
|
398
399
|
if (currency.recharge_config.base_price_id) {
|
|
399
400
|
basePrice = (await Price.findByPk(currency.recharge_config.base_price_id, {
|
|
@@ -401,34 +402,6 @@ router.get('/:id/recharge-config', user, async (req, res) => {
|
|
|
401
402
|
})) as Price & { product: Product };
|
|
402
403
|
}
|
|
403
404
|
|
|
404
|
-
const rechargeConfig = currency.recharge_config;
|
|
405
|
-
let paymentUrl = rechargeConfig.checkout_url;
|
|
406
|
-
if (!paymentUrl && rechargeConfig.payment_link_id) {
|
|
407
|
-
paymentUrl = getUrl(`/checkout/pay/${rechargeConfig.payment_link_id}`);
|
|
408
|
-
}
|
|
409
|
-
if (!paymentUrl && rechargeConfig.base_price_id) {
|
|
410
|
-
const paymentLink = await createPaymentLink({
|
|
411
|
-
livemode: currency.livemode,
|
|
412
|
-
currency_id: basePrice?.currency_id,
|
|
413
|
-
name: basePrice?.product?.name || `${currency.name} Recharge`,
|
|
414
|
-
submit_type: 'pay',
|
|
415
|
-
allow_promotion_codes: true,
|
|
416
|
-
line_items: [
|
|
417
|
-
{
|
|
418
|
-
price_id: rechargeConfig.base_price_id,
|
|
419
|
-
quantity: 1,
|
|
420
|
-
adjustable_quantity: {
|
|
421
|
-
enabled: true,
|
|
422
|
-
minimum: 1,
|
|
423
|
-
maximum: 100000000,
|
|
424
|
-
},
|
|
425
|
-
},
|
|
426
|
-
],
|
|
427
|
-
});
|
|
428
|
-
await currency.update({ recharge_config: { ...rechargeConfig, payment_link_id: paymentLink.id } });
|
|
429
|
-
paymentUrl = getUrl(`/checkout/pay/${paymentLink.id}`);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
405
|
return res.json({
|
|
433
406
|
currency_id: id,
|
|
434
407
|
currency_info: pick(currency, ['id', 'name', 'symbol', 'decimal', 'type']),
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.25",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
|
|
@@ -44,35 +44,35 @@
|
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@abtnode/cron": "^1.17.3
|
|
48
|
-
"@arcblock/did": "^1.27.
|
|
49
|
-
"@arcblock/did-connect-react": "^3.2.
|
|
47
|
+
"@abtnode/cron": "^1.17.3",
|
|
48
|
+
"@arcblock/did": "^1.27.12",
|
|
49
|
+
"@arcblock/did-connect-react": "^3.2.11",
|
|
50
50
|
"@arcblock/did-connect-storage-nedb": "^1.8.0",
|
|
51
|
-
"@arcblock/did-util": "^1.27.
|
|
52
|
-
"@arcblock/jwt": "^1.27.
|
|
53
|
-
"@arcblock/react-hooks": "^3.2.
|
|
54
|
-
"@arcblock/ux": "^3.2.
|
|
55
|
-
"@arcblock/validator": "^1.27.
|
|
56
|
-
"@blocklet/did-space-js": "^1.2.
|
|
51
|
+
"@arcblock/did-util": "^1.27.12",
|
|
52
|
+
"@arcblock/jwt": "^1.27.12",
|
|
53
|
+
"@arcblock/react-hooks": "^3.2.11",
|
|
54
|
+
"@arcblock/ux": "^3.2.11",
|
|
55
|
+
"@arcblock/validator": "^1.27.12",
|
|
56
|
+
"@blocklet/did-space-js": "^1.2.6",
|
|
57
57
|
"@blocklet/error": "^0.3.3",
|
|
58
|
-
"@blocklet/js-sdk": "^1.17.3
|
|
59
|
-
"@blocklet/logger": "^1.17.3
|
|
60
|
-
"@blocklet/payment-broker-client": "1.22.
|
|
61
|
-
"@blocklet/payment-react": "1.22.
|
|
62
|
-
"@blocklet/payment-vendor": "1.22.
|
|
63
|
-
"@blocklet/sdk": "^1.17.3
|
|
64
|
-
"@blocklet/ui-react": "^3.2.
|
|
65
|
-
"@blocklet/uploader": "^0.3.
|
|
66
|
-
"@blocklet/xss": "^0.3.
|
|
58
|
+
"@blocklet/js-sdk": "^1.17.3",
|
|
59
|
+
"@blocklet/logger": "^1.17.3",
|
|
60
|
+
"@blocklet/payment-broker-client": "1.22.25",
|
|
61
|
+
"@blocklet/payment-react": "1.22.25",
|
|
62
|
+
"@blocklet/payment-vendor": "1.22.25",
|
|
63
|
+
"@blocklet/sdk": "^1.17.3",
|
|
64
|
+
"@blocklet/ui-react": "^3.2.11",
|
|
65
|
+
"@blocklet/uploader": "^0.3.13",
|
|
66
|
+
"@blocklet/xss": "^0.3.11",
|
|
67
67
|
"@mui/icons-material": "^7.1.2",
|
|
68
68
|
"@mui/lab": "7.0.0-beta.14",
|
|
69
69
|
"@mui/material": "^7.1.2",
|
|
70
70
|
"@mui/system": "^7.1.1",
|
|
71
|
-
"@ocap/asset": "^1.27.
|
|
72
|
-
"@ocap/client": "^1.27.
|
|
73
|
-
"@ocap/mcrypto": "^1.27.
|
|
74
|
-
"@ocap/util": "^1.27.
|
|
75
|
-
"@ocap/wallet": "^1.27.
|
|
71
|
+
"@ocap/asset": "^1.27.12",
|
|
72
|
+
"@ocap/client": "^1.27.12",
|
|
73
|
+
"@ocap/mcrypto": "^1.27.12",
|
|
74
|
+
"@ocap/util": "^1.27.12",
|
|
75
|
+
"@ocap/wallet": "^1.27.12",
|
|
76
76
|
"@stripe/react-stripe-js": "^2.9.0",
|
|
77
77
|
"@stripe/stripe-js": "^2.4.0",
|
|
78
78
|
"ahooks": "^3.8.5",
|
|
@@ -127,9 +127,9 @@
|
|
|
127
127
|
"web3": "^4.16.0"
|
|
128
128
|
},
|
|
129
129
|
"devDependencies": {
|
|
130
|
-
"@abtnode/types": "^1.17.3
|
|
130
|
+
"@abtnode/types": "^1.17.3",
|
|
131
131
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
132
|
-
"@blocklet/payment-types": "1.22.
|
|
132
|
+
"@blocklet/payment-types": "1.22.25",
|
|
133
133
|
"@types/cookie-parser": "^1.4.9",
|
|
134
134
|
"@types/cors": "^2.8.19",
|
|
135
135
|
"@types/debug": "^4.1.12",
|
|
@@ -176,5 +176,5 @@
|
|
|
176
176
|
"parser": "typescript"
|
|
177
177
|
}
|
|
178
178
|
},
|
|
179
|
-
"gitHead": "
|
|
179
|
+
"gitHead": "59a2dd22163f544db386b492276c8a1da6e3ebe2"
|
|
180
180
|
}
|