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
|
@@ -6,6 +6,7 @@ import { createEvent } from '../libs/audit';
|
|
|
6
6
|
import { ensurePassportRevoked } from '../integrations/blocklet/passport';
|
|
7
7
|
import { batchHandleStripeSubscriptions } from '../integrations/stripe/resource';
|
|
8
8
|
import { wallet } from '../libs/auth';
|
|
9
|
+
import { getExchangeRateService } from '../libs/exchange-rate';
|
|
9
10
|
import dayjs from '../libs/dayjs';
|
|
10
11
|
import { events } from '../libs/event';
|
|
11
12
|
import { getLock } from '../libs/lock';
|
|
@@ -13,6 +14,7 @@ import logger from '../libs/logger';
|
|
|
13
14
|
import { getGasPayerExtra, isDelegationSufficientForPayment } from '../libs/payment';
|
|
14
15
|
import createQueue from '../libs/queue';
|
|
15
16
|
import { getStatementDescriptor } from '../libs/session';
|
|
17
|
+
import { NonRetryableError } from '../libs/error';
|
|
16
18
|
import {
|
|
17
19
|
checkRemainingStake,
|
|
18
20
|
checkUsageReportEmpty,
|
|
@@ -30,9 +32,11 @@ import {
|
|
|
30
32
|
} from '../libs/subscription';
|
|
31
33
|
import { resetPeriodGrantCounter } from '../libs/credit-schedule';
|
|
32
34
|
import { ensureInvoiceAndItems, migrateSubscriptionPaymentMethodInvoice } from '../libs/invoice';
|
|
35
|
+
import { getQuoteService } from '../libs/quote-service';
|
|
36
|
+
import { isRateBelowMinAcceptableRate } from '../libs/slippage';
|
|
33
37
|
import { PaymentCurrency, PaymentIntent, PaymentMethod, Refund, SetupIntent, UsageRecord } from '../store/models';
|
|
34
38
|
import { Customer } from '../store/models/customer';
|
|
35
|
-
import { Invoice } from '../store/models/invoice';
|
|
39
|
+
import { Invoice, nextInvoiceId } from '../store/models/invoice';
|
|
36
40
|
import { Price } from '../store/models/price';
|
|
37
41
|
import { Subscription } from '../store/models/subscription';
|
|
38
42
|
import { SubscriptionItem } from '../store/models/subscription-item';
|
|
@@ -40,6 +44,8 @@ import { getValidDiscountsForSubscriptionBilling } from '../libs/discount/coupon
|
|
|
40
44
|
import { invoiceQueue } from './invoice';
|
|
41
45
|
import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-canceled';
|
|
42
46
|
import { applySubscriptionDiscount } from '../libs/discount/discount';
|
|
47
|
+
import type { ChainType, TLineItemExpanded } from '../store/models';
|
|
48
|
+
import { getExchangeRateSymbol } from '../libs/exchange-rate/token-address-mapping';
|
|
43
49
|
|
|
44
50
|
type SubscriptionJob = {
|
|
45
51
|
subscriptionId: string;
|
|
@@ -48,6 +54,105 @@ type SubscriptionJob = {
|
|
|
48
54
|
|
|
49
55
|
const EXPECTED_SUBSCRIPTION_STATUS = ['trialing', 'active', 'paused', 'past_due'];
|
|
50
56
|
|
|
57
|
+
async function attachQuotesForInvoice(
|
|
58
|
+
lineItems: TLineItemExpanded[],
|
|
59
|
+
currencyId: string,
|
|
60
|
+
invoiceId: string,
|
|
61
|
+
slippageConfig?: { percent?: number; min_acceptable_rate?: string }
|
|
62
|
+
): Promise<{
|
|
63
|
+
lineItems: TLineItemExpanded[];
|
|
64
|
+
quotes: any[];
|
|
65
|
+
slippageCheck?: { belowThreshold: boolean; currentRate?: string; minAcceptableRate?: string };
|
|
66
|
+
}> {
|
|
67
|
+
// Filter dynamic items, excluding items with zero or negative quantity
|
|
68
|
+
// This matches the logic in invoice-quote.ts:generateQuotesForInvoiceItems
|
|
69
|
+
// to ensure quote creation is consistent with actual payment amount
|
|
70
|
+
const dynamicItems = lineItems.filter((item) => {
|
|
71
|
+
const price = (item as any)?.upsell_price || (item as any)?.price;
|
|
72
|
+
if (price?.pricing_type !== 'dynamic') {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
// Skip items with zero or negative quantity (no usage, no payment needed)
|
|
76
|
+
if (item.quantity <= 0) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
if (!dynamicItems.length) {
|
|
82
|
+
return { lineItems, quotes: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const currency = (await PaymentCurrency.findByPk(currencyId, {
|
|
86
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
87
|
+
})) as PaymentCurrency & { payment_method: PaymentMethod };
|
|
88
|
+
if (!currency) {
|
|
89
|
+
throw new NonRetryableError('CURRENCY_NOT_FOUND', `Currency ${currencyId} not found`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (currency.payment_method?.type === 'stripe') {
|
|
93
|
+
return { lineItems, quotes: [] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const exchangeRateService = getExchangeRateService();
|
|
97
|
+
const quoteService = getQuoteService();
|
|
98
|
+
// For ArcBlock payment method, always use ABT for exchange rate
|
|
99
|
+
const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
|
|
100
|
+
const rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
101
|
+
|
|
102
|
+
const rateBelowSlippage = isRateBelowMinAcceptableRate(rateResult.rate, slippageConfig?.min_acceptable_rate);
|
|
103
|
+
|
|
104
|
+
const quoteResults = await Promise.all(
|
|
105
|
+
dynamicItems.map(async (item) => {
|
|
106
|
+
const targetPrice: any = (item as any).upsell_price || (item as any).price;
|
|
107
|
+
const quoteResponse = await quoteService.createQuoteWithRate({
|
|
108
|
+
price_id: targetPrice.id,
|
|
109
|
+
invoice_id: invoiceId,
|
|
110
|
+
target_currency_id: currencyId,
|
|
111
|
+
quantity: item.quantity,
|
|
112
|
+
rateResult,
|
|
113
|
+
slippage_percent: slippageConfig?.percent,
|
|
114
|
+
min_acceptable_rate: undefined,
|
|
115
|
+
});
|
|
116
|
+
return { item, quoteResponse };
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const quoteMap = new Map<string, (typeof quoteResults)[number]>();
|
|
121
|
+
quoteResults.forEach((result) => {
|
|
122
|
+
const targetPrice: any = (result.item as any).upsell_price || (result.item as any).price;
|
|
123
|
+
quoteMap.set(targetPrice.id, result);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const enriched = lineItems.map((item) => {
|
|
127
|
+
const targetPrice: any = (item as any).upsell_price || (item as any).price;
|
|
128
|
+
const hit = quoteMap.get(targetPrice?.id);
|
|
129
|
+
if (!hit) {
|
|
130
|
+
return item;
|
|
131
|
+
}
|
|
132
|
+
const { quoteResponse } = hit;
|
|
133
|
+
return {
|
|
134
|
+
...item,
|
|
135
|
+
quote_id: quoteResponse.quote.id,
|
|
136
|
+
quoted_amount: quoteResponse.computed_unit_amount,
|
|
137
|
+
expires_at: quoteResponse.expires_at,
|
|
138
|
+
exchange_rate: quoteResponse.quote.exchange_rate,
|
|
139
|
+
rate_provider_name: quoteResponse.quote.rate_provider_name,
|
|
140
|
+
rate_provider_id: quoteResponse.quote.rate_provider_id,
|
|
141
|
+
custom_amount: quoteResponse.computed_unit_amount,
|
|
142
|
+
} as any;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
lineItems: enriched,
|
|
147
|
+
quotes: quoteResults.map((x) => x.quoteResponse.quote),
|
|
148
|
+
slippageCheck: {
|
|
149
|
+
belowThreshold: rateBelowSlippage,
|
|
150
|
+
currentRate: rateResult.rate,
|
|
151
|
+
minAcceptableRate: slippageConfig?.min_acceptable_rate,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
51
156
|
const doHandleSubscriptionInvoice = async ({
|
|
52
157
|
subscription,
|
|
53
158
|
filter,
|
|
@@ -194,6 +299,21 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
194
299
|
return null;
|
|
195
300
|
}
|
|
196
301
|
|
|
302
|
+
const invoiceId = nextInvoiceId();
|
|
303
|
+
// Get slippage config from subscription
|
|
304
|
+
const slippageConfig = subscription.slippage_config as { percent?: number; min_acceptable_rate?: string } | undefined;
|
|
305
|
+
const {
|
|
306
|
+
lineItems: itemsWithQuotes,
|
|
307
|
+
quotes,
|
|
308
|
+
slippageCheck,
|
|
309
|
+
} = await attachQuotesForInvoice(
|
|
310
|
+
expandedItems as TLineItemExpanded[],
|
|
311
|
+
subscription.currency_id,
|
|
312
|
+
invoiceId,
|
|
313
|
+
slippageConfig
|
|
314
|
+
);
|
|
315
|
+
expandedItems = itemsWithQuotes as any[];
|
|
316
|
+
|
|
197
317
|
// Get valid discounts for this subscription billing period
|
|
198
318
|
const { validDiscounts, expiredDiscounts } = await getValidDiscountsForSubscriptionBilling({
|
|
199
319
|
subscriptionId: subscription.id,
|
|
@@ -279,6 +399,9 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
279
399
|
});
|
|
280
400
|
}
|
|
281
401
|
|
|
402
|
+
const shouldMarkOverdue = slippageCheck?.belowThreshold === true;
|
|
403
|
+
const invoiceStatus = shouldMarkOverdue && status === 'open' ? 'uncollectible' : status;
|
|
404
|
+
|
|
282
405
|
const { invoice } = await ensureInvoiceAndItems({
|
|
283
406
|
customer,
|
|
284
407
|
currency,
|
|
@@ -287,15 +410,17 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
287
410
|
metered: true,
|
|
288
411
|
lineItems: enhancedLineItems,
|
|
289
412
|
props: {
|
|
413
|
+
id: invoiceId,
|
|
290
414
|
livemode: subscription.livemode,
|
|
291
415
|
description: `Subscription ${reason}`,
|
|
292
416
|
statement_descriptor: getStatementDescriptor(expandedItems),
|
|
293
417
|
period_start: start,
|
|
294
418
|
period_end: end,
|
|
295
419
|
auto_advance: true,
|
|
296
|
-
status,
|
|
420
|
+
status: invoiceStatus,
|
|
297
421
|
billing_reason: `subscription_${reason}`,
|
|
298
422
|
currency_id: subscription.currency_id,
|
|
423
|
+
quote_id: quotes[0]?.id,
|
|
299
424
|
// Set correct subtotal (original amount) and total (after discount)
|
|
300
425
|
subtotal: baseAmount.total,
|
|
301
426
|
total: safeFinalTotal,
|
|
@@ -306,10 +431,83 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
306
431
|
total_discount_amounts: discountBreakdownForInvoice,
|
|
307
432
|
metadata: {
|
|
308
433
|
...metadata,
|
|
434
|
+
...(slippageCheck
|
|
435
|
+
? {
|
|
436
|
+
slippage: {
|
|
437
|
+
below_threshold: slippageCheck.belowThreshold,
|
|
438
|
+
min_acceptable_rate: slippageCheck.minAcceptableRate,
|
|
439
|
+
rate_at_invoice: slippageCheck.currentRate,
|
|
440
|
+
},
|
|
441
|
+
}
|
|
442
|
+
: {}),
|
|
443
|
+
quote_ids: quotes.map((q) => q.id),
|
|
309
444
|
},
|
|
310
445
|
} as unknown as Invoice,
|
|
311
446
|
});
|
|
312
447
|
|
|
448
|
+
// When slippage exceeded, create PaymentIntent with requires_action status for manual payment
|
|
449
|
+
if (shouldMarkOverdue && invoice) {
|
|
450
|
+
const paymentIntent = await PaymentIntent.create({
|
|
451
|
+
livemode: !!invoice.livemode,
|
|
452
|
+
amount: invoice.amount_remaining || safeFinalTotal,
|
|
453
|
+
amount_received: '0',
|
|
454
|
+
amount_capturable: '0',
|
|
455
|
+
customer_id: invoice.customer_id,
|
|
456
|
+
description: 'Slippage exceeded - manual payment required',
|
|
457
|
+
currency_id: invoice.currency_id,
|
|
458
|
+
payment_method_id: invoice.default_payment_method_id,
|
|
459
|
+
invoice_id: invoice.id,
|
|
460
|
+
status: 'requires_action',
|
|
461
|
+
capture_method: 'manual',
|
|
462
|
+
confirmation_method: 'manual',
|
|
463
|
+
payment_method_types: [],
|
|
464
|
+
receipt_email: customer.email,
|
|
465
|
+
statement_descriptor: invoice.statement_descriptor,
|
|
466
|
+
statement_descriptor_suffix: '',
|
|
467
|
+
setup_future_usage: 'on_session',
|
|
468
|
+
metadata: {
|
|
469
|
+
slippage_exceeded: true,
|
|
470
|
+
current_rate: slippageCheck?.currentRate,
|
|
471
|
+
min_acceptable_rate: slippageCheck?.minAcceptableRate,
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
await invoice.update({ payment_intent_id: paymentIntent.id });
|
|
476
|
+
|
|
477
|
+
logger.info('PaymentIntent created for slippage exceeded invoice', {
|
|
478
|
+
invoiceId: invoice.id,
|
|
479
|
+
paymentIntentId: paymentIntent.id,
|
|
480
|
+
amount: paymentIntent.amount,
|
|
481
|
+
currentRate: slippageCheck?.currentRate,
|
|
482
|
+
minAcceptableRate: slippageCheck?.minAcceptableRate,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Send notification for slippage exceeded
|
|
486
|
+
events.emit('subscription.slippage_exceeded', subscription, {
|
|
487
|
+
invoiceId: invoice.id,
|
|
488
|
+
paymentIntentId: paymentIntent.id,
|
|
489
|
+
currentRate: slippageCheck?.currentRate,
|
|
490
|
+
minAcceptableRate: slippageCheck?.minAcceptableRate,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (shouldMarkOverdue && !['past_due', 'canceled', 'incomplete_expired'].includes(subscription.status)) {
|
|
495
|
+
await subscription.update({
|
|
496
|
+
status: 'past_due',
|
|
497
|
+
cancelation_details: {
|
|
498
|
+
comment: 'past_due',
|
|
499
|
+
feedback: 'other',
|
|
500
|
+
reason: 'slippage_below_threshold',
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
logger.warn('Subscription moved to past_due due to slippage threshold', {
|
|
504
|
+
subscription: subscription.id,
|
|
505
|
+
invoice: invoice.id,
|
|
506
|
+
currentRate: slippageCheck?.currentRate,
|
|
507
|
+
minAcceptableRate: slippageCheck?.minAcceptableRate,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
313
511
|
logger.info('Invoice created for subscription', { invoice: invoice.id, subscription: subscription.id });
|
|
314
512
|
|
|
315
513
|
return invoice;
|
|
@@ -318,9 +516,26 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
318
516
|
export async function handleSubscriptionInvoice(args: Parameters<typeof doHandleSubscriptionInvoice>[0]) {
|
|
319
517
|
const lock = getLock(`${args.subscription.id}-invoice`);
|
|
320
518
|
await lock.acquire();
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
519
|
+
try {
|
|
520
|
+
const result = await doHandleSubscriptionInvoice(args);
|
|
521
|
+
return result;
|
|
522
|
+
} catch (error: any) {
|
|
523
|
+
// Log non-retryable errors with more context
|
|
524
|
+
if (error instanceof NonRetryableError) {
|
|
525
|
+
logger.error('Non-retryable error in subscription invoice generation', {
|
|
526
|
+
subscriptionId: args.subscription.id,
|
|
527
|
+
errorCode: error.code,
|
|
528
|
+
errorMessage: error.message,
|
|
529
|
+
reason: args.reason,
|
|
530
|
+
periodStart: args.start,
|
|
531
|
+
periodEnd: args.end,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
535
|
+
} finally {
|
|
536
|
+
// Always release lock, even if error occurs
|
|
537
|
+
lock.release();
|
|
538
|
+
}
|
|
324
539
|
}
|
|
325
540
|
|
|
326
541
|
const handleSubscriptionBeforeCancel = async (subscription: Subscription) => {
|
|
@@ -1513,11 +1728,24 @@ export async function addSubscriptionJob(
|
|
|
1513
1728
|
}
|
|
1514
1729
|
if (action === 'cycle') {
|
|
1515
1730
|
if (!cycleJob || (replace && cycleJob)) {
|
|
1731
|
+
const renewalTime = runAt || subscription.current_period_end;
|
|
1516
1732
|
await subscriptionQueue[fn]({
|
|
1517
1733
|
id: jobId,
|
|
1518
1734
|
job: { subscriptionId: subscription.id, action },
|
|
1519
|
-
runAt:
|
|
1735
|
+
runAt: renewalTime,
|
|
1520
1736
|
});
|
|
1737
|
+
|
|
1738
|
+
// Schedule slippage pre-check 1 hour before renewal for dynamic pricing subscriptions
|
|
1739
|
+
try {
|
|
1740
|
+
await scheduleSlippagePreCheck(subscription, renewalTime);
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
// Log but don't fail the main job scheduling
|
|
1743
|
+
logger.error('Failed to schedule slippage pre-check', {
|
|
1744
|
+
subscriptionId: subscription.id,
|
|
1745
|
+
renewalTime,
|
|
1746
|
+
error,
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1521
1749
|
}
|
|
1522
1750
|
} else {
|
|
1523
1751
|
await subscriptionQueue.delete(jobId);
|
|
@@ -1526,6 +1754,18 @@ export async function addSubscriptionJob(
|
|
|
1526
1754
|
job: { subscriptionId: subscription.id, action },
|
|
1527
1755
|
runAt: runAt || subscription.current_period_end,
|
|
1528
1756
|
});
|
|
1757
|
+
|
|
1758
|
+
// Cancel slippage pre-check when subscription is being cancelled
|
|
1759
|
+
if (action === 'cancel') {
|
|
1760
|
+
try {
|
|
1761
|
+
await cancelSlippagePreCheck(subscription.id);
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
logger.error('Failed to cancel slippage pre-check', {
|
|
1764
|
+
subscriptionId: subscription.id,
|
|
1765
|
+
error,
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1529
1769
|
}
|
|
1530
1770
|
}
|
|
1531
1771
|
|
|
@@ -1724,3 +1964,193 @@ events.on('setup_intent.succeeded', async (setupIntent: SetupIntent) => {
|
|
|
1724
1964
|
}
|
|
1725
1965
|
}
|
|
1726
1966
|
});
|
|
1967
|
+
|
|
1968
|
+
// ============= Slippage Pre-Check Job =============
|
|
1969
|
+
// Checks slippage 1 hour before subscription renewal and sends warning notification if below threshold
|
|
1970
|
+
|
|
1971
|
+
type SlippagePreCheckJob = {
|
|
1972
|
+
subscriptionId: string;
|
|
1973
|
+
renewalTime: number;
|
|
1974
|
+
};
|
|
1975
|
+
|
|
1976
|
+
/**
|
|
1977
|
+
* Handle slippage pre-check: Check if current exchange rate is below min acceptable rate
|
|
1978
|
+
* before subscription renewal and send warning notification
|
|
1979
|
+
*/
|
|
1980
|
+
async function handleSlippagePreCheck(job: SlippagePreCheckJob): Promise<void> {
|
|
1981
|
+
const { subscriptionId, renewalTime } = job;
|
|
1982
|
+
|
|
1983
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
1984
|
+
if (!subscription) {
|
|
1985
|
+
logger.warn('Slippage pre-check: Subscription not found', { subscriptionId });
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Skip if subscription is not in active status
|
|
1990
|
+
if (!['active', 'trialing'].includes(subscription.status)) {
|
|
1991
|
+
logger.info('Slippage pre-check: Skipping non-active subscription', {
|
|
1992
|
+
subscriptionId,
|
|
1993
|
+
status: subscription.status,
|
|
1994
|
+
});
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// Skip if no slippage config - having slippage_config means it's a dynamic pricing subscription
|
|
1999
|
+
const slippageConfig = subscription.slippage_config;
|
|
2000
|
+
if (!slippageConfig || !slippageConfig.min_acceptable_rate) {
|
|
2001
|
+
logger.info('Slippage pre-check: No slippage config or min_acceptable_rate', { subscriptionId });
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// Get payment currency and payment method
|
|
2006
|
+
const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
2007
|
+
if (!paymentCurrency) {
|
|
2008
|
+
logger.info('Slippage pre-check: Payment currency not found', { subscriptionId });
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
2013
|
+
if (!paymentMethod) {
|
|
2014
|
+
logger.info('Slippage pre-check: Payment method not found', { subscriptionId });
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// Get current exchange rate
|
|
2019
|
+
try {
|
|
2020
|
+
const exchangeRateService = getExchangeRateService();
|
|
2021
|
+
const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, paymentMethod.type as any);
|
|
2022
|
+
|
|
2023
|
+
if (!rateSymbol) {
|
|
2024
|
+
logger.info('Slippage pre-check: No exchange rate symbol for currency', {
|
|
2025
|
+
subscriptionId,
|
|
2026
|
+
currencyId: paymentCurrency.id,
|
|
2027
|
+
symbol: paymentCurrency.symbol,
|
|
2028
|
+
});
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
const rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
2033
|
+
const currentRate = rateResult?.rate;
|
|
2034
|
+
|
|
2035
|
+
if (!currentRate) {
|
|
2036
|
+
logger.warn('Slippage pre-check: Failed to get current exchange rate', {
|
|
2037
|
+
subscriptionId,
|
|
2038
|
+
rateSymbol,
|
|
2039
|
+
});
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const belowThreshold = isRateBelowMinAcceptableRate(String(currentRate), slippageConfig.min_acceptable_rate);
|
|
2044
|
+
|
|
2045
|
+
if (belowThreshold) {
|
|
2046
|
+
// Emit warning notification event
|
|
2047
|
+
events.emit('subscription.slippage_warning', subscription, {
|
|
2048
|
+
currentRate: String(currentRate),
|
|
2049
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
2050
|
+
renewalTime,
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
logger.info('Slippage pre-check: Warning sent due to rate below threshold', {
|
|
2054
|
+
subscriptionId,
|
|
2055
|
+
currentRate,
|
|
2056
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
2057
|
+
renewalTime,
|
|
2058
|
+
});
|
|
2059
|
+
} else {
|
|
2060
|
+
logger.info('Slippage pre-check: Rate is acceptable', {
|
|
2061
|
+
subscriptionId,
|
|
2062
|
+
currentRate,
|
|
2063
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
} catch (error) {
|
|
2067
|
+
logger.error('Slippage pre-check: Failed to check exchange rate', {
|
|
2068
|
+
subscriptionId,
|
|
2069
|
+
error,
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
export const slippagePreCheckQueue = createQueue<SlippagePreCheckJob>({
|
|
2075
|
+
name: 'slippage_pre_check',
|
|
2076
|
+
onJob: handleSlippagePreCheck,
|
|
2077
|
+
options: {
|
|
2078
|
+
concurrency: 10,
|
|
2079
|
+
maxRetries: 3,
|
|
2080
|
+
retryDelay: 60000, // 1 minute
|
|
2081
|
+
maxTimeout: 60000,
|
|
2082
|
+
enableScheduledJob: true,
|
|
2083
|
+
},
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
/**
|
|
2087
|
+
* Schedule slippage pre-check job 1 hour before subscription renewal
|
|
2088
|
+
* Should be called when scheduling the cycle job
|
|
2089
|
+
*/
|
|
2090
|
+
export async function scheduleSlippagePreCheck(subscription: Subscription, renewalTime: number): Promise<void> {
|
|
2091
|
+
// Only schedule for subscriptions with slippage config and min_acceptable_rate
|
|
2092
|
+
// Having slippage_config means it's a dynamic pricing subscription
|
|
2093
|
+
const slippageConfig = subscription.slippage_config;
|
|
2094
|
+
if (!slippageConfig || !slippageConfig.min_acceptable_rate) {
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// Schedule pre-check 1 hour before renewal
|
|
2099
|
+
const preCheckTime = renewalTime - 3600; // 1 hour before
|
|
2100
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2101
|
+
|
|
2102
|
+
// Only schedule if pre-check time is in the future
|
|
2103
|
+
if (preCheckTime <= now) {
|
|
2104
|
+
logger.info('Slippage pre-check: Pre-check time is in the past, skipping', {
|
|
2105
|
+
subscriptionId: subscription.id,
|
|
2106
|
+
renewalTime,
|
|
2107
|
+
preCheckTime,
|
|
2108
|
+
now,
|
|
2109
|
+
});
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
const jobId = `slippage-pre-check-${subscription.id}-${renewalTime}`;
|
|
2114
|
+
|
|
2115
|
+
// Delete existing job if any
|
|
2116
|
+
await slippagePreCheckQueue.delete(jobId);
|
|
2117
|
+
|
|
2118
|
+
await slippagePreCheckQueue.push({
|
|
2119
|
+
id: jobId,
|
|
2120
|
+
job: {
|
|
2121
|
+
subscriptionId: subscription.id,
|
|
2122
|
+
renewalTime,
|
|
2123
|
+
},
|
|
2124
|
+
runAt: preCheckTime,
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
logger.info('Slippage pre-check: Job scheduled', {
|
|
2128
|
+
subscriptionId: subscription.id,
|
|
2129
|
+
renewalTime,
|
|
2130
|
+
preCheckTime,
|
|
2131
|
+
jobId,
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
/**
|
|
2136
|
+
* Cancel slippage pre-check job for a subscription
|
|
2137
|
+
*/
|
|
2138
|
+
export async function cancelSlippagePreCheck(subscriptionId: string, renewalTime?: number): Promise<void> {
|
|
2139
|
+
if (renewalTime) {
|
|
2140
|
+
const jobId = `slippage-pre-check-${subscriptionId}-${renewalTime}`;
|
|
2141
|
+
await slippagePreCheckQueue.delete(jobId);
|
|
2142
|
+
logger.info('Slippage pre-check: Specific job cancelled', { subscriptionId, renewalTime, jobId });
|
|
2143
|
+
} else {
|
|
2144
|
+
// Delete all pre-check jobs for this subscription
|
|
2145
|
+
// Note: This is a fallback that searches through the queue
|
|
2146
|
+
const jobs = await slippagePreCheckQueue.store.findJobs({});
|
|
2147
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2148
|
+
for (const job of jobs) {
|
|
2149
|
+
if (job.job?.subscriptionId === subscriptionId) {
|
|
2150
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2151
|
+
await slippagePreCheckQueue.delete(job.id);
|
|
2152
|
+
logger.info('Slippage pre-check: Job cancelled', { subscriptionId, jobId: job.id });
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
@@ -6,6 +6,7 @@ import { Op } from 'sequelize';
|
|
|
6
6
|
import { sessionMiddleware } from '@blocklet/sdk/lib/middlewares/session';
|
|
7
7
|
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
8
8
|
import { trimDecimals } from '../libs/math-utils';
|
|
9
|
+
import { applySlippageToAmount } from '../libs/slippage';
|
|
9
10
|
import {
|
|
10
11
|
AutoRechargeConfig,
|
|
11
12
|
Customer,
|
|
@@ -25,6 +26,20 @@ import logger from '../libs/logger';
|
|
|
25
26
|
|
|
26
27
|
const router = Router();
|
|
27
28
|
|
|
29
|
+
const slippageConfigSchema = Joi.object({
|
|
30
|
+
mode: Joi.string().valid('percent', 'rate').optional(),
|
|
31
|
+
// In rate mode, percent is calculated from min_acceptable_rate and can exceed 100%
|
|
32
|
+
// In percent mode, percent is user-specified and should be 0-100
|
|
33
|
+
percent: Joi.when('mode', {
|
|
34
|
+
is: 'rate',
|
|
35
|
+
then: Joi.number().min(0).optional(),
|
|
36
|
+
otherwise: Joi.number().min(0).max(100).optional(),
|
|
37
|
+
}),
|
|
38
|
+
min_acceptable_rate: Joi.string().optional(),
|
|
39
|
+
base_currency: Joi.string().optional(),
|
|
40
|
+
updated_at_ms: Joi.number().optional(),
|
|
41
|
+
}).allow(null);
|
|
42
|
+
|
|
28
43
|
const createConfigSchema = Joi.object({
|
|
29
44
|
customer_id: Joi.string().required(),
|
|
30
45
|
enabled: Joi.boolean().default(false),
|
|
@@ -39,6 +54,7 @@ const createConfigSchema = Joi.object({
|
|
|
39
54
|
max_attempts: Joi.number().integer().min(0).default(0).optional(),
|
|
40
55
|
max_amount: Joi.number().min(0).default(0).optional(),
|
|
41
56
|
}).optional(),
|
|
57
|
+
slippage_config: slippageConfigSchema.optional(),
|
|
42
58
|
});
|
|
43
59
|
|
|
44
60
|
const getConfigSchema = Joi.object({
|
|
@@ -80,6 +96,23 @@ async function ensurePaymentMethodExists(paymentMethodId: string): Promise<Payme
|
|
|
80
96
|
return paymentMethod;
|
|
81
97
|
}
|
|
82
98
|
|
|
99
|
+
// Check if slippage_config has changed in a way that requires reauthorization
|
|
100
|
+
function hasSlippageConfigChanged(
|
|
101
|
+
oldConfig: AutoRechargeConfig['slippage_config'],
|
|
102
|
+
newConfig: AutoRechargeConfig['slippage_config']
|
|
103
|
+
): boolean {
|
|
104
|
+
// If both are empty, no change
|
|
105
|
+
if (!oldConfig && !newConfig) return false;
|
|
106
|
+
// If one is empty and the other is not, there's a change
|
|
107
|
+
if (!oldConfig || !newConfig) return true;
|
|
108
|
+
// Compare key fields that affect authorization amount
|
|
109
|
+
return (
|
|
110
|
+
oldConfig.mode !== newConfig.mode ||
|
|
111
|
+
oldConfig.percent !== newConfig.percent ||
|
|
112
|
+
oldConfig.min_acceptable_rate !== newConfig.min_acceptable_rate
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
83
116
|
// Apply balance check results to configuration
|
|
84
117
|
async function applyBalanceCheckResults(
|
|
85
118
|
config: AutoRechargeConfig,
|
|
@@ -199,7 +232,10 @@ router.get('/customer/:customerId', sessionMiddleware({ accessKey: true }), asyn
|
|
|
199
232
|
return res.json({
|
|
200
233
|
...newConfig.toJSON(),
|
|
201
234
|
currency,
|
|
202
|
-
price
|
|
235
|
+
price: {
|
|
236
|
+
...price.toJSON(),
|
|
237
|
+
pricing_type: price.pricing_type || 'fixed',
|
|
238
|
+
},
|
|
203
239
|
threshold: fromUnitToToken(minimumThreshold, currency.decimal),
|
|
204
240
|
customer,
|
|
205
241
|
});
|
|
@@ -221,7 +257,10 @@ router.get('/customer/:customerId', sessionMiddleware({ accessKey: true }), asyn
|
|
|
221
257
|
max_amount: fromUnitToToken(config.daily_limits?.max_amount || '0', config.rechargeCurrency?.decimal || 2),
|
|
222
258
|
},
|
|
223
259
|
currency,
|
|
224
|
-
price
|
|
260
|
+
price: {
|
|
261
|
+
...price.toJSON(),
|
|
262
|
+
pricing_type: price.pricing_type || 'fixed',
|
|
263
|
+
},
|
|
225
264
|
threshold: fromUnitToToken(configThreshold, currency.decimal),
|
|
226
265
|
customer,
|
|
227
266
|
});
|
|
@@ -261,7 +300,20 @@ async function checkSufficientBalance({
|
|
|
261
300
|
forceReauthorize?: boolean;
|
|
262
301
|
}) {
|
|
263
302
|
const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
|
|
264
|
-
|
|
303
|
+
let amount = new BN(priceAmount).mul(new BN(quantity));
|
|
304
|
+
|
|
305
|
+
// Apply slippage buffer for dynamic pricing authorization
|
|
306
|
+
// This ensures the delegation covers the maximum possible payment amount
|
|
307
|
+
const slippagePercent = autoRechargeConfig.slippage_config?.percent;
|
|
308
|
+
if (slippagePercent && slippagePercent > 0) {
|
|
309
|
+
amount = applySlippageToAmount(amount, slippagePercent);
|
|
310
|
+
logger.info('Applied slippage to authorization amount', {
|
|
311
|
+
baseAmount: new BN(priceAmount).mul(new BN(quantity)).toString(),
|
|
312
|
+
slippagePercent,
|
|
313
|
+
authorizedAmount: amount.toString(),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
265
317
|
const paymentMethod = await PaymentMethod.findByPk(rechargeCurrency.payment_method_id);
|
|
266
318
|
|
|
267
319
|
if (!paymentMethod) {
|
|
@@ -425,7 +477,7 @@ router.post('/submit', async (req, res) => {
|
|
|
425
477
|
});
|
|
426
478
|
}
|
|
427
479
|
// if exist, update it
|
|
428
|
-
|
|
480
|
+
const updateData: any = {
|
|
429
481
|
...configData,
|
|
430
482
|
currency_id: value.currency_id,
|
|
431
483
|
recharge_currency_id: value.recharge_currency_id,
|
|
@@ -433,7 +485,19 @@ router.post('/submit', async (req, res) => {
|
|
|
433
485
|
payment_method_id: rechargeCurrency.payment_method_id,
|
|
434
486
|
threshold: threshold ?? existingConfig.threshold,
|
|
435
487
|
daily_limits: dailyLimits,
|
|
436
|
-
}
|
|
488
|
+
};
|
|
489
|
+
// Only update slippage_config if explicitly provided (including null to clear it)
|
|
490
|
+
if ('slippage_config' in value) {
|
|
491
|
+
updateData.slippage_config = value.slippage_config;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Check if slippage_config changed BEFORE updating (to compare old vs new)
|
|
495
|
+
const slippageConfigChanged =
|
|
496
|
+
'slippage_config' in value && hasSlippageConfigChanged(existingConfig.slippage_config, value.slippage_config);
|
|
497
|
+
|
|
498
|
+
await existingConfig.update(updateData);
|
|
499
|
+
|
|
500
|
+
// If slippage_config changed, need to reauthorize because authorization amount may change
|
|
437
501
|
const balanceResult = await checkSufficientBalance({
|
|
438
502
|
price: price as unknown as Price,
|
|
439
503
|
quantity: value.quantity,
|
|
@@ -441,7 +505,7 @@ router.post('/submit', async (req, res) => {
|
|
|
441
505
|
userDid: customer.did,
|
|
442
506
|
customer,
|
|
443
507
|
autoRechargeConfig: existingConfig,
|
|
444
|
-
forceReauthorize: value.change_payment_method,
|
|
508
|
+
forceReauthorize: value.change_payment_method || slippageConfigChanged,
|
|
445
509
|
});
|
|
446
510
|
// Update payment details and settings based on balance check result
|
|
447
511
|
await applyBalanceCheckResults(existingConfig, balanceResult, paymentMethod);
|
|
@@ -464,6 +528,7 @@ router.post('/submit', async (req, res) => {
|
|
|
464
528
|
quantity: Number(configData.quantity || '1'),
|
|
465
529
|
payment_method_id: rechargeCurrency.payment_method_id,
|
|
466
530
|
daily_limits: dailyLimits,
|
|
531
|
+
slippage_config: value.slippage_config,
|
|
467
532
|
});
|
|
468
533
|
|
|
469
534
|
logger.info('Auto recharge config created', {
|