payment-kit 1.19.17 → 1.19.19
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/index.ts +3 -1
- package/api/src/integrations/ethereum/tx.ts +11 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +26 -6
- package/api/src/integrations/stripe/handlers/setup-intent.ts +34 -2
- package/api/src/integrations/stripe/resource.ts +185 -1
- package/api/src/libs/invoice.ts +2 -1
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +155 -0
- package/api/src/libs/session.ts +6 -1
- package/api/src/libs/ws.ts +3 -2
- package/api/src/locales/en.ts +6 -6
- package/api/src/locales/zh.ts +4 -4
- package/api/src/queues/auto-recharge.ts +343 -0
- package/api/src/queues/credit-consume.ts +51 -1
- package/api/src/queues/credit-grant.ts +15 -0
- package/api/src/queues/notification.ts +16 -13
- package/api/src/queues/payment.ts +14 -1
- package/api/src/queues/space.ts +1 -0
- package/api/src/routes/auto-recharge-configs.ts +454 -0
- package/api/src/routes/connect/auto-recharge-auth.ts +182 -0
- package/api/src/routes/connect/recharge-account.ts +72 -10
- package/api/src/routes/connect/setup.ts +5 -3
- package/api/src/routes/connect/shared.ts +45 -4
- package/api/src/routes/customers.ts +10 -6
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/invoices.ts +10 -1
- package/api/src/routes/meter-events.ts +1 -1
- package/api/src/routes/meters.ts +1 -1
- package/api/src/routes/payment-currencies.ts +129 -0
- package/api/src/store/migrate.ts +20 -0
- package/api/src/store/migrations/20250821-auto-recharge-config.ts +38 -0
- package/api/src/store/models/auto-recharge-config.ts +225 -0
- package/api/src/store/models/credit-grant.ts +2 -11
- package/api/src/store/models/customer.ts +1 -0
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/invoice.ts +2 -1
- package/api/src/store/models/payment-currency.ts +10 -2
- package/api/src/store/models/types.ts +12 -1
- package/blocklet.yml +3 -3
- package/package.json +18 -18
- package/src/components/currency.tsx +3 -1
- package/src/components/customer/credit-overview.tsx +103 -18
- package/src/components/customer/overdraft-protection.tsx +5 -5
- package/src/components/info-metric.tsx +11 -2
- package/src/components/invoice/recharge.tsx +8 -2
- package/src/components/metadata/form.tsx +29 -27
- package/src/components/meter/form.tsx +1 -2
- package/src/components/price/form.tsx +39 -26
- package/src/components/product/form.tsx +1 -2
- package/src/components/subscription/items/index.tsx +8 -2
- package/src/components/subscription/metrics.tsx +5 -1
- package/src/locales/en.tsx +15 -0
- package/src/locales/zh.tsx +14 -0
- package/src/pages/admin/billing/meters/detail.tsx +18 -0
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +10 -0
- package/src/pages/admin/products/prices/actions.tsx +42 -2
- package/src/pages/admin/products/products/create.tsx +1 -2
- package/src/pages/admin/settings/vault-config/edit-form.tsx +8 -8
- package/src/pages/customer/credit-grant/detail.tsx +9 -1
- package/src/pages/customer/recharge/account.tsx +14 -7
- package/src/pages/customer/recharge/subscription.tsx +4 -4
- package/src/pages/customer/subscription/detail.tsx +6 -1
- package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +0 -151
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
2
|
+
|
|
3
|
+
import { CustomError } from '@blocklet/error';
|
|
4
|
+
import { Op } from 'sequelize';
|
|
5
|
+
import createQueue from '../libs/queue';
|
|
6
|
+
import {
|
|
7
|
+
AutoRechargeConfig,
|
|
8
|
+
CreditGrant,
|
|
9
|
+
Customer,
|
|
10
|
+
Invoice,
|
|
11
|
+
PaymentCurrency,
|
|
12
|
+
PaymentMethod,
|
|
13
|
+
Price,
|
|
14
|
+
Product,
|
|
15
|
+
TPriceExpanded,
|
|
16
|
+
} from '../store/models';
|
|
17
|
+
import logger from '../libs/logger';
|
|
18
|
+
import { getPriceUintAmountByCurrency } from '../libs/session';
|
|
19
|
+
import { isDelegationSufficientForPayment } from '../libs/payment';
|
|
20
|
+
import { createStripeInvoiceForAutoRecharge } from '../integrations/stripe/resource';
|
|
21
|
+
import { ensureInvoiceAndItems } from '../libs/invoice';
|
|
22
|
+
import dayjs from '../libs/dayjs';
|
|
23
|
+
import { invoiceQueue } from './invoice';
|
|
24
|
+
|
|
25
|
+
export interface AutoRechargeJobData {
|
|
26
|
+
customer_id: string;
|
|
27
|
+
currency_id: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AutoRechargeJobResult {
|
|
31
|
+
success: boolean;
|
|
32
|
+
invoice_id?: string;
|
|
33
|
+
error_message?: string;
|
|
34
|
+
recharge_amount?: string;
|
|
35
|
+
credit_amount?: string;
|
|
36
|
+
payment_method_type?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function processAutoRecharge(job: AutoRechargeJobData) {
|
|
40
|
+
logger.info('Processing auto recharge job', { job });
|
|
41
|
+
const { customer_id: customerId, currency_id: currencyId } = job;
|
|
42
|
+
|
|
43
|
+
const customer = await Customer.findByPk(customerId);
|
|
44
|
+
if (!customer) {
|
|
45
|
+
logger.error('Customer not found', { customerId });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
50
|
+
if (!currency) {
|
|
51
|
+
logger.error('Currency not found', { currencyId });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 1. find auto recharge config
|
|
56
|
+
const config = (await AutoRechargeConfig.findOne({
|
|
57
|
+
where: {
|
|
58
|
+
customer_id: customerId,
|
|
59
|
+
currency_id: currencyId,
|
|
60
|
+
},
|
|
61
|
+
include: [
|
|
62
|
+
{ model: PaymentCurrency, as: 'rechargeCurrency', required: false },
|
|
63
|
+
{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] },
|
|
64
|
+
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
65
|
+
],
|
|
66
|
+
})) as AutoRechargeConfig & {
|
|
67
|
+
paymentMethod: PaymentMethod;
|
|
68
|
+
price: TPriceExpanded;
|
|
69
|
+
rechargeCurrency: PaymentCurrency;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (!config || !config.enabled) {
|
|
73
|
+
logger.info('No auto recharge config found', { customerId, currencyId });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!config.rechargeCurrency) {
|
|
78
|
+
logger.error('Recharge currency not found', { customerId, currencyId });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId, [config.price_id]);
|
|
83
|
+
|
|
84
|
+
const totalAvailable = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.remaining_amount)), new BN(0));
|
|
85
|
+
|
|
86
|
+
// 2. check if available balance is above threshold
|
|
87
|
+
if (new BN(totalAvailable).gt(new BN(config.threshold))) {
|
|
88
|
+
logger.info('Available balance is above threshold, skipping auto recharge', {
|
|
89
|
+
customerId,
|
|
90
|
+
currencyId,
|
|
91
|
+
totalAvailable: totalAvailable.toString(),
|
|
92
|
+
threshold: config.threshold,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const priceAmount = await getPriceUintAmountByCurrency(config.price, config.rechargeCurrency.id);
|
|
98
|
+
if (!priceAmount) {
|
|
99
|
+
logger.error('Price amount is not valid', {
|
|
100
|
+
customerId,
|
|
101
|
+
currencyId,
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 0)).toString();
|
|
106
|
+
|
|
107
|
+
// 3. check daily limit
|
|
108
|
+
if (!config.canRechargeToday(totalAmount)) {
|
|
109
|
+
logger.info('Daily recharge limit exceeded, skipping auto recharge', {
|
|
110
|
+
customerId,
|
|
111
|
+
currencyId,
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. execute auto recharge
|
|
117
|
+
await executeAutoRecharge(customer, config, currency);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function createInvoiceForAutoRecharge({
|
|
121
|
+
customer,
|
|
122
|
+
config,
|
|
123
|
+
currency,
|
|
124
|
+
price,
|
|
125
|
+
totalAmount,
|
|
126
|
+
paymentMethod,
|
|
127
|
+
rechargeCurrency,
|
|
128
|
+
}: {
|
|
129
|
+
customer: Customer;
|
|
130
|
+
config: AutoRechargeConfig;
|
|
131
|
+
currency: PaymentCurrency;
|
|
132
|
+
price: TPriceExpanded;
|
|
133
|
+
totalAmount: BN;
|
|
134
|
+
paymentMethod: PaymentMethod;
|
|
135
|
+
rechargeCurrency: PaymentCurrency;
|
|
136
|
+
}) {
|
|
137
|
+
const now = dayjs().unix();
|
|
138
|
+
const expandedItems = await Price.expand([{ price_id: price.id, quantity: config.quantity }], {
|
|
139
|
+
product: true,
|
|
140
|
+
});
|
|
141
|
+
let status = 'open';
|
|
142
|
+
if (paymentMethod.type === 'stripe') {
|
|
143
|
+
status = 'draft'; // stripe invoice will be finalized and paid automatically
|
|
144
|
+
}
|
|
145
|
+
const existInvoice = await Invoice.findOne({
|
|
146
|
+
where: {
|
|
147
|
+
customer_id: customer.id,
|
|
148
|
+
currency_id: rechargeCurrency.id,
|
|
149
|
+
status: {
|
|
150
|
+
[Op.in]: ['open', 'draft'],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
if (existInvoice) {
|
|
155
|
+
logger.info('Auto recharge invoice already exists, skipping', {
|
|
156
|
+
customerId: customer.id,
|
|
157
|
+
configId: config.id,
|
|
158
|
+
});
|
|
159
|
+
return existInvoice;
|
|
160
|
+
}
|
|
161
|
+
const { invoice } = await ensureInvoiceAndItems({
|
|
162
|
+
customer,
|
|
163
|
+
currency: rechargeCurrency,
|
|
164
|
+
trialing: false,
|
|
165
|
+
metered: false,
|
|
166
|
+
lineItems: expandedItems,
|
|
167
|
+
applyCredit: false,
|
|
168
|
+
props: {
|
|
169
|
+
status,
|
|
170
|
+
total: totalAmount.toString(),
|
|
171
|
+
livemode: rechargeCurrency.livemode,
|
|
172
|
+
description: `Auto Top-up for ${currency.name}`,
|
|
173
|
+
statement_descriptor: '',
|
|
174
|
+
period_start: now,
|
|
175
|
+
period_end: now,
|
|
176
|
+
auto_advance: true,
|
|
177
|
+
billing_reason: 'auto_recharge',
|
|
178
|
+
currency_id: rechargeCurrency.id,
|
|
179
|
+
default_payment_method_id: paymentMethod.id,
|
|
180
|
+
custom_fields: [],
|
|
181
|
+
footer: '',
|
|
182
|
+
payment_settings: config.payment_settings,
|
|
183
|
+
metadata: {
|
|
184
|
+
auto_recharge: {
|
|
185
|
+
config_id: config.id,
|
|
186
|
+
currency_id: rechargeCurrency.id,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
} as unknown as Invoice,
|
|
190
|
+
});
|
|
191
|
+
logger.info('New invoice created for auto recharge', {
|
|
192
|
+
customerId: customer.id,
|
|
193
|
+
configId: config.id,
|
|
194
|
+
invoiceId: invoice.id,
|
|
195
|
+
amount: totalAmount,
|
|
196
|
+
});
|
|
197
|
+
if (status !== 'draft') {
|
|
198
|
+
invoiceQueue.push({
|
|
199
|
+
id: invoice.id,
|
|
200
|
+
job: { invoiceId: invoice.id, retryOnError: true },
|
|
201
|
+
});
|
|
202
|
+
logger.info('Invoice job scheduled for auto recharge', { invoice: invoice.id, customerId: customer.id });
|
|
203
|
+
}
|
|
204
|
+
return invoice;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function executeAutoRecharge(
|
|
208
|
+
customer: Customer,
|
|
209
|
+
config: AutoRechargeConfig & {
|
|
210
|
+
paymentMethod: PaymentMethod;
|
|
211
|
+
price: TPriceExpanded;
|
|
212
|
+
rechargeCurrency: PaymentCurrency;
|
|
213
|
+
},
|
|
214
|
+
currency: PaymentCurrency
|
|
215
|
+
) {
|
|
216
|
+
const paymentMethod = config.paymentMethod!;
|
|
217
|
+
const price = config.price! as TPriceExpanded;
|
|
218
|
+
const rechargeCurrency = config.rechargeCurrency!;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
|
|
222
|
+
const totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 0));
|
|
223
|
+
const paymentSettings = config.payment_settings;
|
|
224
|
+
if (!paymentSettings) {
|
|
225
|
+
throw new Error('No payment settings found for auto recharge');
|
|
226
|
+
}
|
|
227
|
+
const payer = (paymentSettings.payment_method_options as any)?.[paymentMethod.type]?.payer;
|
|
228
|
+
if (!payer) {
|
|
229
|
+
throw new Error('No payer found for auto recharge');
|
|
230
|
+
}
|
|
231
|
+
if (paymentMethod.type !== 'stripe') {
|
|
232
|
+
const delegationCheck = await isDelegationSufficientForPayment({
|
|
233
|
+
paymentMethod,
|
|
234
|
+
paymentCurrency: rechargeCurrency,
|
|
235
|
+
userDid: payer,
|
|
236
|
+
amount: totalAmount.toString(),
|
|
237
|
+
});
|
|
238
|
+
if (!delegationCheck.sufficient) {
|
|
239
|
+
throw new CustomError(delegationCheck.reason, 'insufficient delegation or balance');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const invoice = await createInvoiceForAutoRecharge({
|
|
243
|
+
customer,
|
|
244
|
+
config,
|
|
245
|
+
currency,
|
|
246
|
+
price,
|
|
247
|
+
totalAmount,
|
|
248
|
+
paymentMethod,
|
|
249
|
+
rechargeCurrency,
|
|
250
|
+
});
|
|
251
|
+
if (paymentMethod.type === 'stripe') {
|
|
252
|
+
await createStripeInvoiceForAutoRecharge({
|
|
253
|
+
autoRechargeConfig: config,
|
|
254
|
+
customer,
|
|
255
|
+
paymentMethod,
|
|
256
|
+
currency: rechargeCurrency,
|
|
257
|
+
invoice,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
} catch (error: any) {
|
|
261
|
+
logger.error('Auto recharge execution failed', {
|
|
262
|
+
customerId: customer.id,
|
|
263
|
+
configId: config.id,
|
|
264
|
+
paymentMethodType: paymentMethod.type,
|
|
265
|
+
error: error.message,
|
|
266
|
+
});
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 创建自动充值队列
|
|
272
|
+
export const autoRechargeQueue = createQueue<AutoRechargeJobData>({
|
|
273
|
+
name: 'auto-recharge',
|
|
274
|
+
onJob: processAutoRecharge,
|
|
275
|
+
options: {
|
|
276
|
+
concurrency: 5,
|
|
277
|
+
maxRetries: 3,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 检查并触发自动充值 (添加到队列)
|
|
283
|
+
*/
|
|
284
|
+
export async function checkAndTriggerAutoRecharge(
|
|
285
|
+
customer: Customer,
|
|
286
|
+
currencyId: string,
|
|
287
|
+
currentBalance: string
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
// 查找自动充值配置
|
|
291
|
+
const config = await AutoRechargeConfig.findOne({
|
|
292
|
+
where: {
|
|
293
|
+
customer_id: customer.id,
|
|
294
|
+
currency_id: currencyId,
|
|
295
|
+
enabled: true,
|
|
296
|
+
livemode: customer.livemode,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (!config) {
|
|
301
|
+
logger.debug('No auto recharge config found', {
|
|
302
|
+
customerId: customer.id,
|
|
303
|
+
currencyId,
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// check if balance is above threshold
|
|
309
|
+
if (new BN(currentBalance).gte(new BN(config.threshold))) {
|
|
310
|
+
logger.debug('Balance above threshold, skipping auto recharge', {
|
|
311
|
+
customerId: customer.id,
|
|
312
|
+
currentBalance,
|
|
313
|
+
threshold: config.threshold,
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const jobData: AutoRechargeJobData = {
|
|
319
|
+
customer_id: customer.id,
|
|
320
|
+
currency_id: currencyId,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
await autoRechargeQueue.push({
|
|
324
|
+
job: jobData,
|
|
325
|
+
id: `auto-recharge-${customer.id}-${currencyId}}`,
|
|
326
|
+
persist: true,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
logger.info('Added auto recharge job to queue', {
|
|
330
|
+
customerId: customer.id,
|
|
331
|
+
currencyId,
|
|
332
|
+
currentBalance,
|
|
333
|
+
threshold: config.threshold,
|
|
334
|
+
});
|
|
335
|
+
} catch (error: any) {
|
|
336
|
+
logger.error('Failed to trigger auto recharge', {
|
|
337
|
+
customerId: customer.id,
|
|
338
|
+
currencyId,
|
|
339
|
+
currentBalance,
|
|
340
|
+
error: error.message,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -19,6 +19,7 @@ import { MAX_RETRY_COUNT, getNextRetry } from '../libs/util';
|
|
|
19
19
|
import { getDaysUntilCancel, getDueUnit, getMeterPriceIdsFromSubscription } from '../libs/subscription';
|
|
20
20
|
import { events } from '../libs/event';
|
|
21
21
|
import { handlePastDueSubscriptionRecovery } from './payment';
|
|
22
|
+
import { checkAndTriggerAutoRecharge } from './auto-recharge';
|
|
22
23
|
|
|
23
24
|
type CreditConsumptionJob = {
|
|
24
25
|
meterEventId: string;
|
|
@@ -41,6 +42,38 @@ type CreditConsumptionResult = {
|
|
|
41
42
|
fully_consumed: boolean;
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
async function checkLowBalance(
|
|
46
|
+
customerId: string,
|
|
47
|
+
currencyId: string,
|
|
48
|
+
totalCreditAmount: string,
|
|
49
|
+
remainingBalance: string,
|
|
50
|
+
context: CreditConsumptionContext
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const totalCreditAmountBn = new BN(totalCreditAmount);
|
|
54
|
+
if (totalCreditAmountBn.lte(new BN(0))) return;
|
|
55
|
+
const remainingAmountBn = new BN(remainingBalance);
|
|
56
|
+
const threshold = totalCreditAmountBn.mul(new BN(10)).div(new BN(100));
|
|
57
|
+
if (remainingAmountBn.gt(new BN(0)) && remainingAmountBn.lte(threshold)) {
|
|
58
|
+
const percentage = remainingAmountBn.mul(new BN(100)).div(totalCreditAmountBn).toString();
|
|
59
|
+
await createEvent('Customer', 'customer.credit.low_balance', context.customer, {
|
|
60
|
+
metadata: {
|
|
61
|
+
currency_id: currencyId,
|
|
62
|
+
available_amount: remainingAmountBn.toString(),
|
|
63
|
+
total_amount: totalCreditAmountBn.toString(),
|
|
64
|
+
percentage,
|
|
65
|
+
subscription_id: context.subscription?.id,
|
|
66
|
+
},
|
|
67
|
+
}).catch(console.error);
|
|
68
|
+
}
|
|
69
|
+
} catch (error: any) {
|
|
70
|
+
logger.error('Failed to check low balance', {
|
|
71
|
+
customerId,
|
|
72
|
+
currencyId,
|
|
73
|
+
error: error.message,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
44
77
|
async function validateAndLoadData(meterEventId: string): Promise<CreditConsumptionContext | null> {
|
|
45
78
|
const meterEvent = await MeterEvent.findByPk(meterEventId);
|
|
46
79
|
if (!meterEvent) {
|
|
@@ -171,6 +204,8 @@ async function consumeAvailableCredits(
|
|
|
171
204
|
// Get all available grants sorted by priority
|
|
172
205
|
const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId, context.priceIds);
|
|
173
206
|
|
|
207
|
+
const totalCreditAmountBN = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.amount)), new BN(0));
|
|
208
|
+
|
|
174
209
|
// Calculate total available balance
|
|
175
210
|
const totalAvailable = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.remaining_amount)), new BN(0));
|
|
176
211
|
logger.debug('Total available credits calculated', { totalAvailable: totalAvailable.toString() });
|
|
@@ -275,6 +310,8 @@ async function consumeAvailableCredits(
|
|
|
275
310
|
}).catch(console.error);
|
|
276
311
|
}
|
|
277
312
|
|
|
313
|
+
await checkLowBalance(customerId, currencyId, totalCreditAmountBN.toString(), remainingBalance, context);
|
|
314
|
+
|
|
278
315
|
return {
|
|
279
316
|
consumed: totalConsumed.toString(),
|
|
280
317
|
pending: pendingAmount,
|
|
@@ -449,7 +486,20 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
|
|
|
449
486
|
|
|
450
487
|
// Consume available credits (handles existing transactions internally)
|
|
451
488
|
const consumptionResult = await consumeAvailableCredits(context, totalRequiredAmount);
|
|
452
|
-
|
|
489
|
+
// Check for auto recharge after successful consumption
|
|
490
|
+
try {
|
|
491
|
+
await checkAndTriggerAutoRecharge(
|
|
492
|
+
context.customer,
|
|
493
|
+
context.meter.currency_id!,
|
|
494
|
+
consumptionResult.available_balance
|
|
495
|
+
);
|
|
496
|
+
} catch (error: any) {
|
|
497
|
+
logger.warn('Auto recharge check failed after credit consumption', {
|
|
498
|
+
meterEventId,
|
|
499
|
+
customerId,
|
|
500
|
+
error: error.message,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
453
503
|
// Update MeterEvent with consumption details
|
|
454
504
|
await context.meterEvent.update({
|
|
455
505
|
credit_consumed: consumptionResult.consumed,
|
|
@@ -5,6 +5,7 @@ import { BN, fromTokenToUnit } from '@ocap/util';
|
|
|
5
5
|
import dayjs from '../libs/dayjs';
|
|
6
6
|
import createQueue from '../libs/queue';
|
|
7
7
|
import {
|
|
8
|
+
AutoRechargeConfig,
|
|
8
9
|
CreditGrant,
|
|
9
10
|
Customer,
|
|
10
11
|
Invoice,
|
|
@@ -285,6 +286,20 @@ async function handleInvoiceCredit(invoiceId: string) {
|
|
|
285
286
|
return;
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
// Handle auto recharge invoice - use standard processing since we now create proper InvoiceItems
|
|
290
|
+
if (invoice.metadata?.auto_recharge) {
|
|
291
|
+
logger.info('Processing auto recharge invoice with standard logic', {
|
|
292
|
+
invoiceId: invoice.id,
|
|
293
|
+
customerId: invoice.customer.id,
|
|
294
|
+
});
|
|
295
|
+
if (invoice.metadata?.auto_recharge?.config_id) {
|
|
296
|
+
const autoRechargeConfig = await AutoRechargeConfig.findByPk(invoice.metadata?.auto_recharge?.config_id);
|
|
297
|
+
if (autoRechargeConfig) {
|
|
298
|
+
await autoRechargeConfig.updateDailyStats(invoice.total);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
288
303
|
const lineItems = invoice.lines || [];
|
|
289
304
|
|
|
290
305
|
if (!Array.isArray(lineItems) || lineItems.length === 0) {
|
|
@@ -93,10 +93,11 @@ import {
|
|
|
93
93
|
CustomerCreditGrantGrantedEmailTemplate,
|
|
94
94
|
CustomerCreditGrantGrantedEmailTemplateOptions,
|
|
95
95
|
} from '../libs/notification/template/customer-credit-grant-granted';
|
|
96
|
+
|
|
96
97
|
import {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
} from '../libs/notification/template/customer-credit-
|
|
98
|
+
CustomerCreditLowBalanceEmailTemplate,
|
|
99
|
+
CustomerCreditLowBalanceEmailTemplateOptions,
|
|
100
|
+
} from '../libs/notification/template/customer-credit-low-balance';
|
|
100
101
|
import {
|
|
101
102
|
CustomerRevenueSucceededEmailTemplate,
|
|
102
103
|
CustomerRevenueSucceededEmailTemplateOptions,
|
|
@@ -128,7 +129,7 @@ export type NotificationQueueJobType =
|
|
|
128
129
|
| 'subscription.overdraftProtection.exhausted'
|
|
129
130
|
| 'customer.credit.insufficient'
|
|
130
131
|
| 'customer.credit_grant.granted'
|
|
131
|
-
| 'customer.
|
|
132
|
+
| 'customer.credit.low_balance';
|
|
132
133
|
|
|
133
134
|
export type NotificationQueueJob = {
|
|
134
135
|
type: NotificationQueueJobType;
|
|
@@ -266,10 +267,8 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
|
|
|
266
267
|
return new CustomerCreditGrantGrantedEmailTemplate(job.options as CustomerCreditGrantGrantedEmailTemplateOptions);
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
if (job.type === 'customer.
|
|
270
|
-
return new
|
|
271
|
-
job.options as CustomerCreditGrantLowBalanceEmailTemplateOptions
|
|
272
|
-
);
|
|
270
|
+
if (job.type === 'customer.credit.low_balance') {
|
|
271
|
+
return new CustomerCreditLowBalanceEmailTemplate(job.options as CustomerCreditLowBalanceEmailTemplateOptions);
|
|
273
272
|
}
|
|
274
273
|
|
|
275
274
|
throw new Error(`Unknown job type: ${job.type}`);
|
|
@@ -600,15 +599,19 @@ export async function startNotificationQueue() {
|
|
|
600
599
|
);
|
|
601
600
|
});
|
|
602
601
|
|
|
603
|
-
events.on('customer.
|
|
602
|
+
events.on('customer.credit.low_balance', (customer: Customer, { metadata }: { metadata: any }) => {
|
|
604
603
|
addNotificationJob(
|
|
605
|
-
'customer.
|
|
604
|
+
'customer.credit.low_balance',
|
|
606
605
|
{
|
|
607
|
-
|
|
606
|
+
customerId: customer.id,
|
|
607
|
+
currencyId: metadata.currency_id,
|
|
608
|
+
availableAmount: metadata.available_amount,
|
|
609
|
+
totalAmount: metadata.total_amount,
|
|
610
|
+
percentage: metadata.percentage,
|
|
608
611
|
},
|
|
609
|
-
[
|
|
612
|
+
[customer.id, metadata.currency_id],
|
|
610
613
|
true,
|
|
611
|
-
24 * 3600
|
|
614
|
+
24 * 3600
|
|
612
615
|
);
|
|
613
616
|
});
|
|
614
617
|
|
|
@@ -36,7 +36,7 @@ import { SubscriptionItem } from '../store/models/subscription-item';
|
|
|
36
36
|
import type { EVMChainType, PaymentError, PaymentSettings } from '../store/models/types';
|
|
37
37
|
import { notificationQueue } from './notification';
|
|
38
38
|
import { ensureOverdraftProtectionInvoiceAndItems } from '../libs/invoice';
|
|
39
|
-
import { Lock, MeterEvent } from '../store/models';
|
|
39
|
+
import { AutoRechargeConfig, Lock, MeterEvent } from '../store/models';
|
|
40
40
|
import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
|
|
41
41
|
import createQueue from '../libs/queue';
|
|
42
42
|
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../libs/constants';
|
|
@@ -663,6 +663,19 @@ export const handlePaymentFailed = async (
|
|
|
663
663
|
attempt_count: invoice.attempt_count,
|
|
664
664
|
});
|
|
665
665
|
|
|
666
|
+
if (invoice.billing_reason === 'auto_recharge' && invoice.metadata?.recharge_config?.recharge_id) {
|
|
667
|
+
const autoRechargeConfig = await AutoRechargeConfig.findByPk(invoice.metadata?.recharge_config?.recharge_id);
|
|
668
|
+
if (autoRechargeConfig && autoRechargeConfig.enabled) {
|
|
669
|
+
autoRechargeConfig.update({
|
|
670
|
+
enabled: false,
|
|
671
|
+
metadata: {
|
|
672
|
+
...autoRechargeConfig.metadata,
|
|
673
|
+
failReason: 'Payment failed',
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
666
679
|
return updates.terminate;
|
|
667
680
|
}
|
|
668
681
|
|
package/api/src/queues/space.ts
CHANGED