payment-kit 1.24.3 → 1.25.0
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/overdue-detection.ts +10 -1
- package/api/src/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +1 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +190 -3
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/meter-events.ts +3 -0
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- package/src/pages/customer/subscription/detail.tsx +599 -419
|
@@ -4,6 +4,7 @@ import { Op } from 'sequelize';
|
|
|
4
4
|
import createQueue from '../libs/queue';
|
|
5
5
|
import {
|
|
6
6
|
AutoRechargeConfig,
|
|
7
|
+
ChainType,
|
|
7
8
|
CreditGrant,
|
|
8
9
|
Customer,
|
|
9
10
|
Invoice,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
PaymentCurrency,
|
|
12
13
|
PaymentMethod,
|
|
13
14
|
Price,
|
|
15
|
+
PriceQuote,
|
|
14
16
|
Product,
|
|
15
17
|
TPriceExpanded,
|
|
16
18
|
} from '../store/models';
|
|
@@ -21,6 +23,11 @@ import { ensureInvoiceAndItems } from '../libs/invoice';
|
|
|
21
23
|
import dayjs from '../libs/dayjs';
|
|
22
24
|
import { invoiceQueue } from './invoice';
|
|
23
25
|
import { getPriceUintAmountByCurrency } from '../libs/price';
|
|
26
|
+
import { getExchangeRateService } from '../libs/exchange-rate/service';
|
|
27
|
+
import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
|
|
28
|
+
import { isRateBelowMinAcceptableRate } from '../libs/slippage';
|
|
29
|
+
import { getQuoteService } from '../libs/quote-service';
|
|
30
|
+
import { events } from '../libs/event';
|
|
24
31
|
import { isDelegationSufficientForPayment } from '../libs/payment';
|
|
25
32
|
|
|
26
33
|
export interface AutoRechargeJobData {
|
|
@@ -136,8 +143,209 @@ export async function processAutoRecharge(job: AutoRechargeJobData) {
|
|
|
136
143
|
return;
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
// 4.
|
|
140
|
-
|
|
146
|
+
// 4. Check dynamic pricing constraints
|
|
147
|
+
const isDynamicPricing = config.price.pricing_type === 'dynamic';
|
|
148
|
+
let rateResult: DynamicPricingCheckResult['rateResult'];
|
|
149
|
+
|
|
150
|
+
if (isDynamicPricing) {
|
|
151
|
+
const dynamicPricingResult = await checkDynamicPricingConstraints({
|
|
152
|
+
customer,
|
|
153
|
+
config,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (!dynamicPricingResult.canProceed) {
|
|
157
|
+
// Emit event for notification
|
|
158
|
+
events.emit('auto_recharge.skipped', {
|
|
159
|
+
config_id: config.id,
|
|
160
|
+
customer_id: customerId,
|
|
161
|
+
reason: dynamicPricingResult.reason,
|
|
162
|
+
current_rate: dynamicPricingResult.currentRate,
|
|
163
|
+
min_acceptable_rate: config.slippage_config?.min_acceptable_rate,
|
|
164
|
+
// Additional context for notification
|
|
165
|
+
payment_currency_symbol: config.rechargeCurrency.symbol,
|
|
166
|
+
payment_currency_name: config.rechargeCurrency.name,
|
|
167
|
+
credit_currency_name: currency.name,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
logger.info('Auto recharge skipped due to dynamic pricing constraints', {
|
|
171
|
+
customerId,
|
|
172
|
+
currencyId,
|
|
173
|
+
reason: dynamicPricingResult.reason,
|
|
174
|
+
currentRate: dynamicPricingResult.currentRate,
|
|
175
|
+
minAcceptableRate: config.slippage_config?.min_acceptable_rate,
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
rateResult = dynamicPricingResult.rateResult;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 5. execute auto recharge
|
|
184
|
+
await executeAutoRecharge(customer, config, currency, rateResult);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface DynamicPricingCheckResult {
|
|
188
|
+
canProceed: boolean;
|
|
189
|
+
reason?: 'slippage_exceeded' | 'exchange_rate_not_supported' | 'exchange_rate_fetch_failed';
|
|
190
|
+
currentRate?: string;
|
|
191
|
+
rateResult?: {
|
|
192
|
+
rate: string;
|
|
193
|
+
provider_id: string;
|
|
194
|
+
provider_name: string;
|
|
195
|
+
timestamp_ms: number;
|
|
196
|
+
degraded: boolean;
|
|
197
|
+
consensus_method?: string;
|
|
198
|
+
providers?: Array<{
|
|
199
|
+
provider_id: string;
|
|
200
|
+
provider_name: string;
|
|
201
|
+
rate: string;
|
|
202
|
+
timestamp_ms: number;
|
|
203
|
+
degraded: boolean;
|
|
204
|
+
}>;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if dynamic pricing constraints are met before creating quotes
|
|
210
|
+
* This only validates exchange rate availability and slippage threshold
|
|
211
|
+
* Quotes are created later when building the invoice
|
|
212
|
+
*/
|
|
213
|
+
async function checkDynamicPricingConstraints({
|
|
214
|
+
customer,
|
|
215
|
+
config,
|
|
216
|
+
}: {
|
|
217
|
+
customer: Customer;
|
|
218
|
+
config: AutoRechargeConfig & {
|
|
219
|
+
paymentMethod: PaymentMethod;
|
|
220
|
+
price: TPriceExpanded;
|
|
221
|
+
rechargeCurrency: PaymentCurrency;
|
|
222
|
+
};
|
|
223
|
+
}): Promise<DynamicPricingCheckResult> {
|
|
224
|
+
const methodType = config.paymentMethod.type as ChainType;
|
|
225
|
+
|
|
226
|
+
// Check if exchange rate is supported
|
|
227
|
+
if (!hasTokenAddress(config.rechargeCurrency.symbol, methodType)) {
|
|
228
|
+
return {
|
|
229
|
+
canProceed: false,
|
|
230
|
+
reason: 'exchange_rate_not_supported',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Get current exchange rate
|
|
235
|
+
let rateResult;
|
|
236
|
+
try {
|
|
237
|
+
const exchangeRateService = getExchangeRateService();
|
|
238
|
+
const rateSymbol = getExchangeRateSymbol(config.rechargeCurrency.symbol, methodType);
|
|
239
|
+
rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
240
|
+
} catch (error: any) {
|
|
241
|
+
logger.warn('Failed to fetch exchange rate for auto-recharge', {
|
|
242
|
+
error: error.message,
|
|
243
|
+
symbol: config.rechargeCurrency.symbol,
|
|
244
|
+
customerId: customer.id,
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
canProceed: false,
|
|
248
|
+
reason: 'exchange_rate_fetch_failed',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Check slippage if config exists
|
|
253
|
+
if (config.slippage_config?.min_acceptable_rate) {
|
|
254
|
+
const rateBelowMin = isRateBelowMinAcceptableRate(rateResult.rate, config.slippage_config.min_acceptable_rate);
|
|
255
|
+
|
|
256
|
+
if (rateBelowMin) {
|
|
257
|
+
return {
|
|
258
|
+
canProceed: false,
|
|
259
|
+
reason: 'slippage_exceeded',
|
|
260
|
+
currentRate: rateResult.rate,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
canProceed: true,
|
|
267
|
+
currentRate: rateResult.rate,
|
|
268
|
+
rateResult,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Create quotes for each line item and enrich them with quote info
|
|
274
|
+
* This follows the same pattern as attachQuotesForInvoice in subscription.ts
|
|
275
|
+
*/
|
|
276
|
+
async function attachQuotesForAutoRecharge(
|
|
277
|
+
lineItems: any[],
|
|
278
|
+
invoiceId: string,
|
|
279
|
+
config: AutoRechargeConfig & {
|
|
280
|
+
rechargeCurrency: PaymentCurrency;
|
|
281
|
+
slippage_config?: { percent?: number; min_acceptable_rate?: string };
|
|
282
|
+
},
|
|
283
|
+
rateResult: DynamicPricingCheckResult['rateResult']
|
|
284
|
+
): Promise<{
|
|
285
|
+
enrichedLineItems: any[];
|
|
286
|
+
quotes: PriceQuote[];
|
|
287
|
+
totalAmount: BN;
|
|
288
|
+
}> {
|
|
289
|
+
const quoteService = getQuoteService();
|
|
290
|
+
const slippagePercent = config.slippage_config?.percent;
|
|
291
|
+
|
|
292
|
+
// Create quotes for each line item
|
|
293
|
+
const quoteResults = await Promise.all(
|
|
294
|
+
lineItems.map(async (item) => {
|
|
295
|
+
const quoteResponse = await quoteService.createQuoteWithRate({
|
|
296
|
+
price_id: item.price_id,
|
|
297
|
+
invoice_id: invoiceId,
|
|
298
|
+
target_currency_id: config.recharge_currency_id!,
|
|
299
|
+
quantity: item.quantity,
|
|
300
|
+
rateResult: rateResult!,
|
|
301
|
+
slippage_percent: slippagePercent,
|
|
302
|
+
});
|
|
303
|
+
return { item, quoteResponse };
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// Build quote map for lookup
|
|
308
|
+
const quoteMap = new Map<string, (typeof quoteResults)[number]>();
|
|
309
|
+
quoteResults.forEach((result) => {
|
|
310
|
+
quoteMap.set(result.item.price_id, result);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Enrich line items with quote info
|
|
314
|
+
// Only store quote_id in metadata - full quote info is attached dynamically when querying
|
|
315
|
+
// See: invoices.ts attachQuoteMetadataToLines()
|
|
316
|
+
const enrichedLineItems = lineItems.map((item) => {
|
|
317
|
+
const hit = quoteMap.get(item.price_id);
|
|
318
|
+
if (!hit) {
|
|
319
|
+
return item;
|
|
320
|
+
}
|
|
321
|
+
const { quoteResponse } = hit;
|
|
322
|
+
return {
|
|
323
|
+
...item,
|
|
324
|
+
quote_id: quoteResponse.quote.id,
|
|
325
|
+
custom_amount: quoteResponse.computed_unit_amount,
|
|
326
|
+
// Only store quote_id in metadata, not the full quote object
|
|
327
|
+
metadata: {
|
|
328
|
+
...(item.metadata || {}),
|
|
329
|
+
quote_id: quoteResponse.quote.id,
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Calculate total amount from quotes
|
|
335
|
+
const totalAmount = quoteResults.reduce((sum, result) => {
|
|
336
|
+
return sum.add(new BN(result.quoteResponse.computed_unit_amount));
|
|
337
|
+
}, new BN(0));
|
|
338
|
+
|
|
339
|
+
const quotes = quoteResults.map((r) => r.quoteResponse.quote);
|
|
340
|
+
|
|
341
|
+
logger.info('Created quotes for auto-recharge line items', {
|
|
342
|
+
configId: config.id,
|
|
343
|
+
quoteIds: quotes.map((q) => q.id),
|
|
344
|
+
totalAmount: totalAmount.toString(),
|
|
345
|
+
slippagePercent,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
return { enrichedLineItems, quotes, totalAmount };
|
|
141
349
|
}
|
|
142
350
|
|
|
143
351
|
async function createInvoiceForAutoRecharge({
|
|
@@ -145,26 +353,31 @@ async function createInvoiceForAutoRecharge({
|
|
|
145
353
|
config,
|
|
146
354
|
currency,
|
|
147
355
|
price,
|
|
148
|
-
totalAmount,
|
|
149
356
|
paymentMethod,
|
|
150
357
|
rechargeCurrency,
|
|
358
|
+
isDynamicPricing,
|
|
359
|
+
rateResult,
|
|
151
360
|
}: {
|
|
152
361
|
customer: Customer;
|
|
153
362
|
config: AutoRechargeConfig;
|
|
154
363
|
currency: PaymentCurrency;
|
|
155
364
|
price: TPriceExpanded;
|
|
156
|
-
totalAmount: BN;
|
|
157
365
|
paymentMethod: PaymentMethod;
|
|
158
366
|
rechargeCurrency: PaymentCurrency;
|
|
367
|
+
isDynamicPricing: boolean;
|
|
368
|
+
rateResult?: DynamicPricingCheckResult['rateResult'];
|
|
159
369
|
}) {
|
|
160
370
|
const now = dayjs().unix();
|
|
161
371
|
const expandedItems = await Price.expand([{ price_id: price.id, quantity: config.quantity }], {
|
|
162
372
|
product: true,
|
|
163
373
|
});
|
|
374
|
+
|
|
164
375
|
let status = 'open';
|
|
165
376
|
if (paymentMethod.type === 'stripe') {
|
|
166
377
|
status = 'draft'; // stripe invoice will be finalized and paid automatically
|
|
167
378
|
}
|
|
379
|
+
|
|
380
|
+
// Check for existing invoice
|
|
168
381
|
const existInvoice = await Invoice.findOne({
|
|
169
382
|
where: {
|
|
170
383
|
customer_id: customer.id,
|
|
@@ -180,14 +393,55 @@ async function createInvoiceForAutoRecharge({
|
|
|
180
393
|
customerId: customer.id,
|
|
181
394
|
configId: config.id,
|
|
182
395
|
});
|
|
183
|
-
return existInvoice;
|
|
396
|
+
return { invoice: existInvoice, quotes: [] };
|
|
184
397
|
}
|
|
398
|
+
|
|
399
|
+
// For dynamic pricing, create quotes for each line item
|
|
400
|
+
let lineItems = expandedItems;
|
|
401
|
+
let totalAmount: BN;
|
|
402
|
+
let quotes: PriceQuote[] = [];
|
|
403
|
+
|
|
404
|
+
if (isDynamicPricing && rateResult) {
|
|
405
|
+
// Generate a temporary invoice ID for quote creation
|
|
406
|
+
// We'll update the quotes with the actual invoice ID after creation
|
|
407
|
+
const tempInvoiceId = `temp-auto-recharge-${config.id}-${Date.now()}`;
|
|
408
|
+
const quoteResult = await attachQuotesForAutoRecharge(expandedItems, tempInvoiceId, config as any, rateResult);
|
|
409
|
+
lineItems = quoteResult.enrichedLineItems;
|
|
410
|
+
totalAmount = quoteResult.totalAmount;
|
|
411
|
+
quotes = quoteResult.quotes;
|
|
412
|
+
} else {
|
|
413
|
+
// Fixed pricing: calculate from price config
|
|
414
|
+
const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
|
|
415
|
+
totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 1));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Build metadata
|
|
419
|
+
const invoiceMetadata: Record<string, any> = {
|
|
420
|
+
auto_recharge: {
|
|
421
|
+
config_id: config.id,
|
|
422
|
+
currency_id: rechargeCurrency.id,
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const firstQuote = quotes.length > 0 ? quotes[0] : undefined;
|
|
427
|
+
if (firstQuote) {
|
|
428
|
+
invoiceMetadata.quote_ids = quotes.map((q) => q.id);
|
|
429
|
+
// Also store the first quote's info for backwards compatibility
|
|
430
|
+
invoiceMetadata.quote = {
|
|
431
|
+
quote_id: firstQuote.id,
|
|
432
|
+
exchange_rate: firstQuote.exchange_rate,
|
|
433
|
+
quoted_amount: firstQuote.quoted_amount,
|
|
434
|
+
max_payable_token: firstQuote.max_payable_token,
|
|
435
|
+
slippage_percent: firstQuote.slippage_percent,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
185
439
|
const { invoice } = await ensureInvoiceAndItems({
|
|
186
440
|
customer,
|
|
187
441
|
currency: rechargeCurrency,
|
|
188
442
|
trialing: false,
|
|
189
443
|
metered: false,
|
|
190
|
-
lineItems
|
|
444
|
+
lineItems,
|
|
191
445
|
applyCredit: false,
|
|
192
446
|
props: {
|
|
193
447
|
status,
|
|
@@ -204,19 +458,26 @@ async function createInvoiceForAutoRecharge({
|
|
|
204
458
|
custom_fields: [],
|
|
205
459
|
footer: '',
|
|
206
460
|
payment_settings: config.payment_settings,
|
|
207
|
-
metadata:
|
|
208
|
-
auto_recharge: {
|
|
209
|
-
config_id: config.id,
|
|
210
|
-
currency_id: rechargeCurrency.id,
|
|
211
|
-
},
|
|
212
|
-
},
|
|
461
|
+
metadata: invoiceMetadata,
|
|
213
462
|
} as unknown as Invoice,
|
|
214
463
|
});
|
|
464
|
+
|
|
465
|
+
// Link all quotes to the actual invoice
|
|
466
|
+
if (quotes.length > 0) {
|
|
467
|
+
await Promise.all(quotes.map((q) => q.update({ invoice_id: invoice.id })));
|
|
468
|
+
logger.info('Quotes linked to auto recharge invoice', {
|
|
469
|
+
quoteIds: quotes.map((q) => q.id),
|
|
470
|
+
invoiceId: invoice.id,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
215
474
|
logger.info('New invoice created for auto recharge', {
|
|
216
475
|
customerId: customer.id,
|
|
217
476
|
configId: config.id,
|
|
218
477
|
invoiceId: invoice.id,
|
|
219
|
-
amount: totalAmount,
|
|
478
|
+
amount: totalAmount.toString(),
|
|
479
|
+
quoteIds: quotes.map((q) => q.id),
|
|
480
|
+
isDynamicPricing: quotes.length > 0,
|
|
220
481
|
});
|
|
221
482
|
if (status !== 'draft') {
|
|
222
483
|
invoiceQueue.push({
|
|
@@ -225,7 +486,7 @@ async function createInvoiceForAutoRecharge({
|
|
|
225
486
|
});
|
|
226
487
|
logger.info('Invoice job scheduled for auto recharge', { invoice: invoice.id, customerId: customer.id });
|
|
227
488
|
}
|
|
228
|
-
return invoice;
|
|
489
|
+
return { invoice, quotes };
|
|
229
490
|
}
|
|
230
491
|
|
|
231
492
|
async function executeAutoRecharge(
|
|
@@ -235,15 +496,15 @@ async function executeAutoRecharge(
|
|
|
235
496
|
price: TPriceExpanded;
|
|
236
497
|
rechargeCurrency: PaymentCurrency;
|
|
237
498
|
},
|
|
238
|
-
currency: PaymentCurrency
|
|
499
|
+
currency: PaymentCurrency,
|
|
500
|
+
rateResult?: DynamicPricingCheckResult['rateResult']
|
|
239
501
|
) {
|
|
240
502
|
const paymentMethod = config.paymentMethod!;
|
|
241
503
|
const price = config.price! as TPriceExpanded;
|
|
242
504
|
const rechargeCurrency = config.rechargeCurrency!;
|
|
505
|
+
const isDynamicPricing = price.pricing_type === 'dynamic';
|
|
243
506
|
|
|
244
507
|
try {
|
|
245
|
-
const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
|
|
246
|
-
const totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 0));
|
|
247
508
|
const paymentSettings = config.payment_settings;
|
|
248
509
|
if (!paymentSettings) {
|
|
249
510
|
throw new Error('No payment settings found for auto recharge');
|
|
@@ -253,12 +514,20 @@ async function executeAutoRecharge(
|
|
|
253
514
|
throw new Error('No payer found for auto recharge');
|
|
254
515
|
}
|
|
255
516
|
|
|
517
|
+
// Calculate totalAmount for balance check
|
|
518
|
+
const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
|
|
519
|
+
if (!priceAmount) {
|
|
520
|
+
throw new Error('Price amount is not valid');
|
|
521
|
+
}
|
|
522
|
+
const totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 1)).toString();
|
|
523
|
+
|
|
524
|
+
// Check delegation balance for non-stripe payment methods
|
|
256
525
|
if (paymentMethod.type !== 'stripe') {
|
|
257
526
|
const balanceCheck = await isDelegationSufficientForPayment({
|
|
258
527
|
paymentMethod,
|
|
259
528
|
paymentCurrency: rechargeCurrency,
|
|
260
529
|
userDid: payer,
|
|
261
|
-
amount: totalAmount
|
|
530
|
+
amount: totalAmount,
|
|
262
531
|
});
|
|
263
532
|
|
|
264
533
|
if (!balanceCheck.sufficient) {
|
|
@@ -266,22 +535,27 @@ async function executeAutoRecharge(
|
|
|
266
535
|
customerId: customer.id,
|
|
267
536
|
configId: config.id,
|
|
268
537
|
reason: balanceCheck.reason,
|
|
269
|
-
totalAmount
|
|
538
|
+
totalAmount,
|
|
270
539
|
payer,
|
|
271
540
|
});
|
|
272
541
|
throw new Error(`Insufficient balance: ${balanceCheck.reason}`);
|
|
273
542
|
}
|
|
274
543
|
}
|
|
275
544
|
|
|
276
|
-
const
|
|
545
|
+
const result = await createInvoiceForAutoRecharge({
|
|
277
546
|
customer,
|
|
278
547
|
config,
|
|
279
548
|
currency,
|
|
280
549
|
price,
|
|
281
|
-
totalAmount,
|
|
282
550
|
paymentMethod,
|
|
283
551
|
rechargeCurrency,
|
|
552
|
+
isDynamicPricing,
|
|
553
|
+
rateResult,
|
|
284
554
|
});
|
|
555
|
+
|
|
556
|
+
// Handle both return types (existing invoice or new invoice)
|
|
557
|
+
const invoice = 'invoice' in result ? result.invoice : result;
|
|
558
|
+
|
|
285
559
|
if (paymentMethod.type === 'stripe') {
|
|
286
560
|
await createStripeInvoiceForAutoRecharge({
|
|
287
561
|
autoRechargeConfig: config,
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import createQueue from '../libs/queue';
|
|
2
|
+
import logger from '../libs/logger';
|
|
3
|
+
import { ExchangeRateProvider } from '../store/models/exchange-rate-provider';
|
|
4
|
+
import { getExchangeRateService } from '../libs/exchange-rate';
|
|
5
|
+
import { createEvent } from '../libs/audit';
|
|
6
|
+
|
|
7
|
+
// Representative tokens to test for health checks
|
|
8
|
+
const REPRESENTATIVE_TOKENS = ['ABT', 'ETH', 'USDC'];
|
|
9
|
+
|
|
10
|
+
interface HealthCheckJob {
|
|
11
|
+
type: 'health_check';
|
|
12
|
+
timestamp: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface HealthCheckResult {
|
|
16
|
+
providerId: string;
|
|
17
|
+
providerName: string;
|
|
18
|
+
success: boolean;
|
|
19
|
+
testedTokens: string[];
|
|
20
|
+
failedTokens: string[];
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Perform health check on all active exchange rate providers
|
|
26
|
+
*/
|
|
27
|
+
async function performHealthCheck(): Promise<HealthCheckResult[]> {
|
|
28
|
+
logger.info('Starting exchange rate provider health check');
|
|
29
|
+
|
|
30
|
+
const providers = await ExchangeRateProvider.findAll({
|
|
31
|
+
where: { enabled: true },
|
|
32
|
+
order: [['priority', 'ASC']],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (providers.length === 0) {
|
|
36
|
+
logger.warn('No enabled exchange rate providers found');
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const results: HealthCheckResult[] = [];
|
|
41
|
+
const exchangeRateService = getExchangeRateService();
|
|
42
|
+
|
|
43
|
+
// Note: Sequential provider testing is intentional for health check isolation
|
|
44
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
45
|
+
for (const provider of providers) {
|
|
46
|
+
const result: HealthCheckResult = {
|
|
47
|
+
providerId: provider.id,
|
|
48
|
+
providerName: provider.name,
|
|
49
|
+
success: true,
|
|
50
|
+
testedTokens: [],
|
|
51
|
+
failedTokens: [],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let hasSuccess = false;
|
|
55
|
+
|
|
56
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
57
|
+
for (const symbol of REPRESENTATIVE_TOKENS) {
|
|
58
|
+
try {
|
|
59
|
+
// Clear cache to force fresh fetch
|
|
60
|
+
exchangeRateService.clearCache(symbol);
|
|
61
|
+
|
|
62
|
+
// Test fetching rate
|
|
63
|
+
// eslint-disable-next-line no-await-in-loop
|
|
64
|
+
await exchangeRateService.getRate(symbol);
|
|
65
|
+
|
|
66
|
+
result.testedTokens.push(symbol);
|
|
67
|
+
hasSuccess = true;
|
|
68
|
+
|
|
69
|
+
logger.debug('Health check success', {
|
|
70
|
+
provider: provider.name,
|
|
71
|
+
symbol,
|
|
72
|
+
});
|
|
73
|
+
} catch (error: any) {
|
|
74
|
+
result.failedTokens.push(symbol);
|
|
75
|
+
|
|
76
|
+
logger.warn('Health check failed for token', {
|
|
77
|
+
provider: provider.name,
|
|
78
|
+
symbol,
|
|
79
|
+
error: error.message,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
result.success = hasSuccess;
|
|
85
|
+
if (!hasSuccess) {
|
|
86
|
+
result.error = `Failed to fetch rates for all representative tokens: ${REPRESENTATIVE_TOKENS.join(', ')}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
results.push(result);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Send alert notification when all providers are unavailable
|
|
97
|
+
*/
|
|
98
|
+
async function sendProviderAlert(results: HealthCheckResult[]): Promise<void> {
|
|
99
|
+
const allProvidersFailed = results.every((r) => !r.success);
|
|
100
|
+
|
|
101
|
+
if (!allProvidersFailed) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const alertData = {
|
|
106
|
+
severity: 'critical',
|
|
107
|
+
title: 'All Exchange Rate Providers Unavailable',
|
|
108
|
+
message: 'All enabled exchange rate providers have failed health checks. Dynamic pricing is currently unavailable.',
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
providers: results.map((r) => ({
|
|
111
|
+
id: r.providerId,
|
|
112
|
+
name: r.providerName,
|
|
113
|
+
failedTokens: r.failedTokens,
|
|
114
|
+
error: r.error,
|
|
115
|
+
})),
|
|
116
|
+
impact: 'Users cannot create or view dynamic pricing quotes',
|
|
117
|
+
action: 'Please check provider configurations and network connectivity',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Log critical alert
|
|
121
|
+
logger.error('CRITICAL: All exchange rate providers unavailable', alertData);
|
|
122
|
+
|
|
123
|
+
// Create audit event - this will trigger webhooks and emit event internally
|
|
124
|
+
try {
|
|
125
|
+
await createEvent('System', 'exchange_rate.providers_unavailable', alertData, {});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.error('Failed to create audit event for provider alert', { error: err });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Log health check results
|
|
133
|
+
*
|
|
134
|
+
* Note: We don't call recordFailure/recordSuccess here because
|
|
135
|
+
* exchangeRateService.getRate() already handles provider status updates internally.
|
|
136
|
+
* Calling them here would cause duplicate updates (failure_count += 2 instead of 1).
|
|
137
|
+
*/
|
|
138
|
+
function logHealthCheckResults(results: HealthCheckResult[]): void {
|
|
139
|
+
for (const result of results) {
|
|
140
|
+
if (result.success) {
|
|
141
|
+
logger.info('Provider health check passed', {
|
|
142
|
+
provider: result.providerName,
|
|
143
|
+
testedTokens: result.testedTokens,
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
logger.warn('Provider health check failed', {
|
|
147
|
+
provider: result.providerName,
|
|
148
|
+
failedTokens: result.failedTokens,
|
|
149
|
+
error: result.error,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Main health check job handler
|
|
157
|
+
*/
|
|
158
|
+
async function handleHealthCheckJob(job: HealthCheckJob): Promise<void> {
|
|
159
|
+
logger.info('Executing exchange rate provider health check job', { job });
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const results = await performHealthCheck();
|
|
163
|
+
|
|
164
|
+
// Log health check results (provider status already updated by service.getRate())
|
|
165
|
+
logHealthCheckResults(results);
|
|
166
|
+
|
|
167
|
+
// Send alert if all providers failed
|
|
168
|
+
await sendProviderAlert(results);
|
|
169
|
+
|
|
170
|
+
const failedCount = results.filter((r) => !r.success).length;
|
|
171
|
+
const successCount = results.length - failedCount;
|
|
172
|
+
|
|
173
|
+
logger.info('Health check completed', {
|
|
174
|
+
total: results.length,
|
|
175
|
+
success: successCount,
|
|
176
|
+
failed: failedCount,
|
|
177
|
+
timestamp: job.timestamp,
|
|
178
|
+
});
|
|
179
|
+
} catch (error: any) {
|
|
180
|
+
logger.error('Health check job failed', {
|
|
181
|
+
error: error.message,
|
|
182
|
+
stack: error.stack,
|
|
183
|
+
});
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Create the queue with scheduled job support
|
|
189
|
+
export const exchangeRateHealthQueue = createQueue<HealthCheckJob>({
|
|
190
|
+
name: 'exchange-rate-health',
|
|
191
|
+
onJob: handleHealthCheckJob,
|
|
192
|
+
options: {
|
|
193
|
+
concurrency: 1,
|
|
194
|
+
maxRetries: 3,
|
|
195
|
+
retryDelay: 60 * 1000, // 1 minute
|
|
196
|
+
enableScheduledJob: true,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Schedule health checks every 6 hours
|
|
201
|
+
const HEALTH_CHECK_INTERVAL = 6 * 60 * 60; // 6 hours in seconds
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Initialize and schedule periodic health checks
|
|
205
|
+
*/
|
|
206
|
+
export function scheduleHealthChecks(): void {
|
|
207
|
+
const scheduleNext = () => {
|
|
208
|
+
const now = Math.floor(Date.now() / 1000);
|
|
209
|
+
const nextRun = now + HEALTH_CHECK_INTERVAL;
|
|
210
|
+
|
|
211
|
+
exchangeRateHealthQueue.push({
|
|
212
|
+
job: {
|
|
213
|
+
type: 'health_check',
|
|
214
|
+
timestamp: Date.now(),
|
|
215
|
+
},
|
|
216
|
+
runAt: nextRun,
|
|
217
|
+
persist: true,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
logger.info('Scheduled next exchange rate health check', {
|
|
221
|
+
nextRunAt: new Date(nextRun * 1000).toISOString(),
|
|
222
|
+
intervalHours: HEALTH_CHECK_INTERVAL / 3600,
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Schedule initial check
|
|
227
|
+
scheduleNext();
|
|
228
|
+
|
|
229
|
+
// Listen for completed checks and schedule next one
|
|
230
|
+
exchangeRateHealthQueue.on('finished', () => {
|
|
231
|
+
scheduleNext();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
logger.info('Exchange rate health check scheduling initialized', {
|
|
235
|
+
intervalHours: HEALTH_CHECK_INTERVAL / 3600,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Export for explicit initialization in index.ts
|
|
240
|
+
export function startExchangeRateHealthQueue(): void {
|
|
241
|
+
scheduleHealthChecks();
|
|
242
|
+
}
|