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
|
@@ -5,11 +5,13 @@ import { createEvent } from '../libs/audit';
|
|
|
5
5
|
import dayjs from '../libs/dayjs';
|
|
6
6
|
import logger from '../libs/logger';
|
|
7
7
|
import createQueue from '../libs/queue';
|
|
8
|
-
import { PaymentMethod, Invoice } from '../store/models';
|
|
8
|
+
import { PaymentMethod, Invoice, InvoiceItem } from '../store/models';
|
|
9
9
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
10
10
|
import { PaymentIntent } from '../store/models/payment-intent';
|
|
11
11
|
import { Subscription } from '../store/models/subscription';
|
|
12
|
+
import { PriceQuote } from '../store/models/price-quote';
|
|
12
13
|
import { paymentQueue } from './payment';
|
|
14
|
+
import { getQuoteService } from '../libs/quote-service';
|
|
13
15
|
|
|
14
16
|
import { getLock } from '../libs/lock';
|
|
15
17
|
import { events } from '../libs/event';
|
|
@@ -280,6 +282,51 @@ events.on('invoice.paid', async ({ id: invoiceId }) => {
|
|
|
280
282
|
await handleOverdraftProtectionInvoiceAfterPayment(invoice);
|
|
281
283
|
});
|
|
282
284
|
|
|
285
|
+
// Separate listener to mark quotes as paid - independent of other invoice.paid handlers
|
|
286
|
+
events.on('invoice.paid', async ({ id: invoiceId }) => {
|
|
287
|
+
try {
|
|
288
|
+
const invoiceItems = await InvoiceItem.findAll({
|
|
289
|
+
where: { invoice_id: invoiceId },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const quoteIds = invoiceItems
|
|
293
|
+
.map((item) => item.metadata?.quote_id)
|
|
294
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
295
|
+
|
|
296
|
+
if (quoteIds.length === 0) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const quoteService = getQuoteService();
|
|
301
|
+
const markedQuoteIds: string[] = [];
|
|
302
|
+
|
|
303
|
+
await Promise.all(
|
|
304
|
+
quoteIds.map(async (quoteId) => {
|
|
305
|
+
try {
|
|
306
|
+
const quote = await PriceQuote.findByPk(quoteId);
|
|
307
|
+
if (quote && quote.status !== 'paid') {
|
|
308
|
+
await quote.update({ invoice_id: invoiceId });
|
|
309
|
+
await quoteService.markAsPaid(quoteId);
|
|
310
|
+
markedQuoteIds.push(quoteId);
|
|
311
|
+
}
|
|
312
|
+
} catch (err: any) {
|
|
313
|
+
logger.warn('Failed to mark quote as paid', { quoteId, invoiceId, error: err.message });
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (markedQuoteIds.length > 0) {
|
|
319
|
+
logger.info('Marked quotes as paid for invoice', {
|
|
320
|
+
invoiceId,
|
|
321
|
+
quoteIds: markedQuoteIds,
|
|
322
|
+
totalQuotes: quoteIds.length,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
} catch (err: any) {
|
|
326
|
+
logger.error('Failed to process quotes for paid invoice', { invoiceId, error: err.message });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
283
330
|
events.on('invoice.queued', async (id, job, args = {}) => {
|
|
284
331
|
const { sync, ...extraArgs } = args;
|
|
285
332
|
if (sync) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Op } from 'sequelize';
|
|
2
|
+
import debounce from 'lodash/debounce';
|
|
2
3
|
/* eslint-disable @typescript-eslint/indent */
|
|
3
4
|
import { events } from '../libs/event';
|
|
4
5
|
import logger from '../libs/logger';
|
|
@@ -118,6 +119,18 @@ import {
|
|
|
118
119
|
WebhookEndpointFailedEmailTemplate,
|
|
119
120
|
WebhookEndpointFailedEmailTemplateOptions,
|
|
120
121
|
} from '../libs/notification/template/webhook-endpoint-failed';
|
|
122
|
+
import {
|
|
123
|
+
SubscriptionSlippageWarningEmailTemplate,
|
|
124
|
+
SubscriptionSlippageWarningEmailTemplateOptions,
|
|
125
|
+
} from '../libs/notification/template/subscription-slippage-warning';
|
|
126
|
+
import {
|
|
127
|
+
SubscriptionSlippageExceededEmailTemplate,
|
|
128
|
+
SubscriptionSlippageExceededEmailTemplateOptions,
|
|
129
|
+
} from '../libs/notification/template/subscription-slippage-exceeded';
|
|
130
|
+
import {
|
|
131
|
+
ExchangeRateAlertEmailTemplate,
|
|
132
|
+
ExchangeRateAlertEmailTemplateOptions,
|
|
133
|
+
} from '../libs/notification/template/exchange-rate-alert';
|
|
121
134
|
import type { TJob } from '../store/models/job';
|
|
122
135
|
|
|
123
136
|
export type NotificationQueueJobOptions = any;
|
|
@@ -144,7 +157,11 @@ export type NotificationQueueJobType =
|
|
|
144
157
|
| 'customer.credit.low_balance'
|
|
145
158
|
| 'customer.auto_recharge.failed'
|
|
146
159
|
| 'customer.auto_recharge.daily_limit_exceeded'
|
|
147
|
-
| 'webhook.endpoint.failed'
|
|
160
|
+
| 'webhook.endpoint.failed'
|
|
161
|
+
| 'exchange_rate.providers_unavailable'
|
|
162
|
+
| 'exchange_rate.spread_exceeded'
|
|
163
|
+
| 'subscription.slippage_warning'
|
|
164
|
+
| 'subscription.slippage_exceeded';
|
|
148
165
|
|
|
149
166
|
export type NotificationQueueJob = {
|
|
150
167
|
type: NotificationQueueJobType;
|
|
@@ -300,6 +317,21 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
|
|
|
300
317
|
return new WebhookEndpointFailedEmailTemplate(job.options as WebhookEndpointFailedEmailTemplateOptions);
|
|
301
318
|
}
|
|
302
319
|
|
|
320
|
+
if (job.type === 'subscription.slippage_warning') {
|
|
321
|
+
return new SubscriptionSlippageWarningEmailTemplate(job.options as SubscriptionSlippageWarningEmailTemplateOptions);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (job.type === 'subscription.slippage_exceeded') {
|
|
325
|
+
return new SubscriptionSlippageExceededEmailTemplate(
|
|
326
|
+
job.options as SubscriptionSlippageExceededEmailTemplateOptions
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Exchange rate alerts (admin notifications)
|
|
331
|
+
if (job.type === 'exchange_rate.providers_unavailable' || job.type === 'exchange_rate.spread_exceeded') {
|
|
332
|
+
return new ExchangeRateAlertEmailTemplate(job.options as ExchangeRateAlertEmailTemplateOptions);
|
|
333
|
+
}
|
|
334
|
+
|
|
303
335
|
throw new Error(`Unknown job type: ${job.type}`);
|
|
304
336
|
}
|
|
305
337
|
|
|
@@ -337,6 +369,8 @@ interface NotificationItem<T = Record<string, any>> {
|
|
|
337
369
|
|
|
338
370
|
// 内存缓存,记录最近发送的通知
|
|
339
371
|
const notificationCache = new Map<string, number>();
|
|
372
|
+
// 去抖函数(按 key 去重)
|
|
373
|
+
const debounceHandlers = new Map<string, ReturnType<typeof debounce>>();
|
|
340
374
|
|
|
341
375
|
// 清理过期缓存的定时器
|
|
342
376
|
setInterval(
|
|
@@ -738,11 +772,164 @@ export async function startNotificationQueue() {
|
|
|
738
772
|
}
|
|
739
773
|
);
|
|
740
774
|
|
|
775
|
+
// System events - Exchange rate alerts (admin notifications)
|
|
776
|
+
events.on('exchange_rate.providers_unavailable', (alertData: Record<string, any>) => {
|
|
777
|
+
logger.info('Exchange rate providers unavailable event received', { alertData });
|
|
778
|
+
addNotificationJob(
|
|
779
|
+
'exchange_rate.providers_unavailable',
|
|
780
|
+
alertData,
|
|
781
|
+
['exchange_rate.providers_unavailable', alertData.symbol],
|
|
782
|
+
true, // Prevent duplicate
|
|
783
|
+
30 * 60 // 30 minutes window
|
|
784
|
+
);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
events.on('exchange_rate.spread_exceeded', (alertData: Record<string, any>) => {
|
|
788
|
+
logger.info('Exchange rate spread exceeded event received', { alertData });
|
|
789
|
+
addNotificationJob(
|
|
790
|
+
'exchange_rate.spread_exceeded',
|
|
791
|
+
alertData,
|
|
792
|
+
['exchange_rate.spread_exceeded', alertData.symbol],
|
|
793
|
+
true, // Prevent duplicate
|
|
794
|
+
30 * 60 // 30 minutes window (matches service rate limit)
|
|
795
|
+
);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// Auto-recharge skipped due to dynamic pricing constraints
|
|
799
|
+
events.on(
|
|
800
|
+
'auto_recharge.skipped',
|
|
801
|
+
async (data: {
|
|
802
|
+
config_id: string;
|
|
803
|
+
customer_id: string;
|
|
804
|
+
reason: 'slippage_exceeded' | 'exchange_rate_not_supported' | 'exchange_rate_fetch_failed';
|
|
805
|
+
current_rate?: string;
|
|
806
|
+
min_acceptable_rate?: string;
|
|
807
|
+
payment_currency_symbol?: string;
|
|
808
|
+
payment_currency_name?: string;
|
|
809
|
+
credit_currency_name?: string;
|
|
810
|
+
}) => {
|
|
811
|
+
logger.info('Auto recharge skipped event received', {
|
|
812
|
+
configId: data.config_id,
|
|
813
|
+
customerId: data.customer_id,
|
|
814
|
+
reason: data.reason,
|
|
815
|
+
currentRate: data.current_rate,
|
|
816
|
+
minAcceptableRate: data.min_acceptable_rate,
|
|
817
|
+
paymentCurrencySymbol: data.payment_currency_symbol,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// Reuse customer.auto_recharge.failed notification with skipped reason
|
|
821
|
+
// This ensures users are notified when auto-recharge cannot proceed
|
|
822
|
+
const customer = await Customer.findByPk(data.customer_id);
|
|
823
|
+
if (customer) {
|
|
824
|
+
addNotificationJob(
|
|
825
|
+
'customer.auto_recharge.failed',
|
|
826
|
+
{
|
|
827
|
+
customerId: data.customer_id,
|
|
828
|
+
autoRechargeConfigId: data.config_id,
|
|
829
|
+
result: {
|
|
830
|
+
sufficient: false,
|
|
831
|
+
reason: data.reason,
|
|
832
|
+
currentRate: data.current_rate,
|
|
833
|
+
minAcceptableRate: data.min_acceptable_rate,
|
|
834
|
+
paymentCurrencySymbol: data.payment_currency_symbol,
|
|
835
|
+
paymentCurrencyName: data.payment_currency_name,
|
|
836
|
+
creditCurrencyName: data.credit_currency_name,
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
[data.customer_id, data.config_id, data.reason],
|
|
840
|
+
true, // Prevent duplicate notifications
|
|
841
|
+
6 * 3600 // 6 hours window - avoid spamming if rate stays bad
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
// Slippage exceeded notification
|
|
848
|
+
events.on(
|
|
849
|
+
'subscription.slippage_exceeded',
|
|
850
|
+
(
|
|
851
|
+
subscription: Subscription,
|
|
852
|
+
{
|
|
853
|
+
invoiceId,
|
|
854
|
+
paymentIntentId,
|
|
855
|
+
currentRate,
|
|
856
|
+
minAcceptableRate,
|
|
857
|
+
}: {
|
|
858
|
+
invoiceId: string;
|
|
859
|
+
paymentIntentId: string;
|
|
860
|
+
currentRate: string;
|
|
861
|
+
minAcceptableRate: string;
|
|
862
|
+
}
|
|
863
|
+
) => {
|
|
864
|
+
addNotificationJob(
|
|
865
|
+
'subscription.slippage_exceeded',
|
|
866
|
+
{
|
|
867
|
+
subscriptionId: subscription.id,
|
|
868
|
+
invoiceId,
|
|
869
|
+
paymentIntentId,
|
|
870
|
+
currentRate,
|
|
871
|
+
minAcceptableRate,
|
|
872
|
+
},
|
|
873
|
+
[subscription.id, invoiceId],
|
|
874
|
+
true, // Prevent duplicate
|
|
875
|
+
24 * 3600 // 24 hours window
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
// Slippage warning notification (1 hour before renewal)
|
|
881
|
+
events.on(
|
|
882
|
+
'subscription.slippage_warning',
|
|
883
|
+
(
|
|
884
|
+
subscription: Subscription,
|
|
885
|
+
{
|
|
886
|
+
currentRate,
|
|
887
|
+
minAcceptableRate,
|
|
888
|
+
renewalTime,
|
|
889
|
+
}: {
|
|
890
|
+
currentRate: string;
|
|
891
|
+
minAcceptableRate: string;
|
|
892
|
+
renewalTime: number;
|
|
893
|
+
}
|
|
894
|
+
) => {
|
|
895
|
+
addNotificationJob(
|
|
896
|
+
'subscription.slippage_warning',
|
|
897
|
+
{
|
|
898
|
+
subscriptionId: subscription.id,
|
|
899
|
+
currentRate,
|
|
900
|
+
minAcceptableRate,
|
|
901
|
+
renewalTime,
|
|
902
|
+
},
|
|
903
|
+
[subscription.id],
|
|
904
|
+
true, // Prevent duplicate
|
|
905
|
+
3600 // 1 hour window - avoid duplicate warnings
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
);
|
|
909
|
+
|
|
741
910
|
// Clear credit notification cache when customer recharges
|
|
742
911
|
// This allows customer to receive insufficient/low_balance notifications again
|
|
743
912
|
events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
|
|
744
|
-
|
|
745
|
-
|
|
913
|
+
const cacheKeyBase = `${creditGrant.customer_id}.${creditGrant.currency_id}`;
|
|
914
|
+
const debounceKey = `credit_grant_clear.${cacheKeyBase}`;
|
|
915
|
+
const delayMs = 5 * 60 * 1000;
|
|
916
|
+
|
|
917
|
+
let handler = debounceHandlers.get(debounceKey);
|
|
918
|
+
if (!handler) {
|
|
919
|
+
handler = debounce(() => {
|
|
920
|
+
clearNotificationCache(`customer.credit.insufficient.${cacheKeyBase}`);
|
|
921
|
+
clearNotificationCache(`customer.credit.low_balance.${cacheKeyBase}`);
|
|
922
|
+
debounceHandlers.delete(debounceKey);
|
|
923
|
+
logger.info('Notification cache cleared after delay', {
|
|
924
|
+
customerId: creditGrant.customer_id,
|
|
925
|
+
currencyId: creditGrant.currency_id,
|
|
926
|
+
delayMs,
|
|
927
|
+
});
|
|
928
|
+
}, delayMs);
|
|
929
|
+
debounceHandlers.set(debounceKey, handler);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
handler();
|
|
746
933
|
});
|
|
747
934
|
}
|
|
748
935
|
|
|
@@ -11,6 +11,8 @@ import CustomError from '../libs/error';
|
|
|
11
11
|
import { events } from '../libs/event';
|
|
12
12
|
import logger from '../libs/logger';
|
|
13
13
|
import { getGasPayerExtra, isDelegationSufficientForPayment, checkDepositVaultAmount } from '../libs/payment';
|
|
14
|
+
import { validateQuoteForPayment, handleExpiredQuotePayment } from '../libs/quote-validation';
|
|
15
|
+
import { getQuoteService } from '../libs/quote-service';
|
|
14
16
|
import {
|
|
15
17
|
checkRemainingStake,
|
|
16
18
|
getDaysUntilCancel,
|
|
@@ -42,7 +44,7 @@ import { AutoRechargeConfig, Lock, MeterEvent } from '../store/models';
|
|
|
42
44
|
import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
|
|
43
45
|
import createQueue from '../libs/queue';
|
|
44
46
|
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../libs/constants';
|
|
45
|
-
import { getCheckoutSessionSubscriptionIds, getSubscriptionCreateSetup } from '../libs/session';
|
|
47
|
+
import { getCheckoutSessionSubscriptionIds, getSubscriptionCreateSetup, SlippageOptions } from '../libs/session';
|
|
46
48
|
import { syncStripeSubscriptionAfterRecovery } from '../integrations/stripe/handlers/subscription';
|
|
47
49
|
import { getLock } from '../libs/lock';
|
|
48
50
|
|
|
@@ -214,7 +216,15 @@ export async function handlePastDueSubscriptionRecovery(
|
|
|
214
216
|
// Reset billing cycle
|
|
215
217
|
const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
216
218
|
const lineItems = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
|
|
217
|
-
|
|
219
|
+
// Use the subscription's slippage config if available, otherwise use default 0.5%
|
|
220
|
+
const slippageConfig = subscription.slippage_config;
|
|
221
|
+
const currency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
222
|
+
const slippageOptions: SlippageOptions = {
|
|
223
|
+
percent: slippageConfig?.percent ?? 0.5,
|
|
224
|
+
minAcceptableRate: slippageConfig?.min_acceptable_rate,
|
|
225
|
+
currencyDecimal: currency?.decimal,
|
|
226
|
+
};
|
|
227
|
+
const setup = getSubscriptionCreateSetup(lineItems, subscription.currency_id, 0, 0, slippageOptions);
|
|
218
228
|
await subscription.update({
|
|
219
229
|
status: 'active',
|
|
220
230
|
pending_invoice_item_interval: setup.recurring,
|
|
@@ -403,6 +413,18 @@ export const handlePaymentSucceed = async (
|
|
|
403
413
|
logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`);
|
|
404
414
|
}
|
|
405
415
|
|
|
416
|
+
// Mark quotes as paid after successful payment
|
|
417
|
+
// Query quotes by invoiceId - more reliable than depending on metadata
|
|
418
|
+
if (invoice?.id) {
|
|
419
|
+
const quoteService = getQuoteService();
|
|
420
|
+
const markedIds = await quoteService.markQuotesAsPaidByInvoice(invoice.id);
|
|
421
|
+
if (markedIds.length > 0) {
|
|
422
|
+
logger.info('Marked quotes as paid after successful payment', {
|
|
423
|
+
invoiceId: invoice.id,
|
|
424
|
+
quoteIds: markedIds,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
406
428
|
// Update subscription status
|
|
407
429
|
if (invoice && invoice.subscription_id && !slashStake) {
|
|
408
430
|
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
@@ -919,6 +941,55 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
919
941
|
return;
|
|
920
942
|
}
|
|
921
943
|
|
|
944
|
+
// Queue delay monitoring - critical for quote lock window observability
|
|
945
|
+
const queueDelayMs = Date.now() - new Date(paymentIntent.created_at).getTime();
|
|
946
|
+
const QUOTE_LOCK_WINDOW_MS = 180000; // 180 seconds
|
|
947
|
+
|
|
948
|
+
logger.info('Queue processing payment intent', {
|
|
949
|
+
paymentIntentId: paymentIntent.id,
|
|
950
|
+
queueDelayMs,
|
|
951
|
+
delaySeconds: Math.round(queueDelayMs / 1000),
|
|
952
|
+
lockWindowSeconds: QUOTE_LOCK_WINDOW_MS / 1000,
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
if (queueDelayMs > QUOTE_LOCK_WINDOW_MS) {
|
|
956
|
+
logger.warn('CRITICAL: Queue delay exceeds quote lock window', {
|
|
957
|
+
paymentIntentId: paymentIntent.id,
|
|
958
|
+
invoiceId: paymentIntent.invoice_id,
|
|
959
|
+
queueDelayMs,
|
|
960
|
+
delaySeconds: Math.round(queueDelayMs / 1000),
|
|
961
|
+
lockWindowSeconds: QUOTE_LOCK_WINDOW_MS / 1000,
|
|
962
|
+
riskLevel: 'HIGH - Quote lock expired, slippage protection critical',
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const preQuoteValidation = await validateQuoteForPayment({
|
|
967
|
+
checkoutSessionId: invoice?.checkout_session_id,
|
|
968
|
+
invoiceId: paymentIntent.invoice_id,
|
|
969
|
+
amount: paymentIntent.amount,
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
if (!preQuoteValidation.valid) {
|
|
973
|
+
// Enhanced error logging with queue delay context
|
|
974
|
+
let failureReason = 'QUOTE_VALIDATION_FAILED';
|
|
975
|
+
if (preQuoteValidation.reason?.includes('QUOTE_MAX_PAYABLE_EXCEEDED')) {
|
|
976
|
+
failureReason = 'SLIPPAGE_EXCEEDED';
|
|
977
|
+
} else if (preQuoteValidation.reason?.includes('expired')) {
|
|
978
|
+
failureReason = 'QUOTE_EXPIRED';
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
logger.error('Payment aborted due to quote validation before capture', {
|
|
982
|
+
paymentIntentId: paymentIntent.id,
|
|
983
|
+
invoiceId: paymentIntent.invoice_id,
|
|
984
|
+
quoteId: preQuoteValidation.quoteId,
|
|
985
|
+
reason: preQuoteValidation.reason,
|
|
986
|
+
failureReason,
|
|
987
|
+
queueDelayMs,
|
|
988
|
+
delaySeconds: Math.round(queueDelayMs / 1000),
|
|
989
|
+
});
|
|
990
|
+
throw new CustomError(failureReason, `Payment validation failed: ${preQuoteValidation.reason || 'unknown'}`);
|
|
991
|
+
}
|
|
992
|
+
|
|
922
993
|
await paymentIntent.update({ status: 'processing', last_payment_error: null });
|
|
923
994
|
|
|
924
995
|
if (paymentMethod.type === 'arcblock') {
|
|
@@ -938,11 +1009,15 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
938
1009
|
});
|
|
939
1010
|
|
|
940
1011
|
if (result.sufficient === false) {
|
|
941
|
-
logger.error('PaymentIntent capture aborted
|
|
942
|
-
|
|
1012
|
+
logger.error('PaymentIntent capture aborted - insufficient balance/delegation', {
|
|
1013
|
+
paymentIntentId: paymentIntent.id,
|
|
1014
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1015
|
+
failureReason: 'INSUFFICIENT_BALANCE',
|
|
943
1016
|
result,
|
|
1017
|
+
queueDelayMs,
|
|
1018
|
+
delaySeconds: Math.round(queueDelayMs / 1000),
|
|
944
1019
|
});
|
|
945
|
-
throw new CustomError(
|
|
1020
|
+
throw new CustomError('INSUFFICIENT_BALANCE', 'Payer balance or delegation not sufficient for this payment');
|
|
946
1021
|
}
|
|
947
1022
|
|
|
948
1023
|
const signed = await client.signTransferV2Tx({
|
|
@@ -978,6 +1053,50 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
978
1053
|
logger.info('PaymentIntent txHash', { txHash });
|
|
979
1054
|
logger.info('PaymentIntent capture done', { id: paymentIntent.id, txHash });
|
|
980
1055
|
|
|
1056
|
+
// CRITICAL: Validate quote before marking payment as succeeded
|
|
1057
|
+
const quoteValidation = await validateQuoteForPayment({
|
|
1058
|
+
checkoutSessionId: undefined, // Will be found through invoice if exists
|
|
1059
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1060
|
+
amount: paymentIntent.amount,
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
if (!quoteValidation.valid) {
|
|
1064
|
+
if (quoteValidation.action === 'reject') {
|
|
1065
|
+
// Reject the payment - mark as failed
|
|
1066
|
+
logger.error('Automated payment rejected due to quote validation', {
|
|
1067
|
+
paymentIntentId: paymentIntent.id,
|
|
1068
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1069
|
+
quoteId: quoteValidation.quoteId,
|
|
1070
|
+
reason: quoteValidation.reason,
|
|
1071
|
+
txHash,
|
|
1072
|
+
});
|
|
1073
|
+
throw new CustomError('quote_validation_failed', `Payment validation failed: ${quoteValidation.reason}`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (quoteValidation.action === 'require_manual_review') {
|
|
1077
|
+
// Hold for manual review
|
|
1078
|
+
logger.error('Automated payment requires manual review due to expired quote', {
|
|
1079
|
+
paymentIntentId: paymentIntent.id,
|
|
1080
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1081
|
+
quoteId: quoteValidation.quoteId,
|
|
1082
|
+
reason: quoteValidation.reason,
|
|
1083
|
+
txHash,
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
await handleExpiredQuotePayment({
|
|
1087
|
+
quoteId: quoteValidation.quoteId!,
|
|
1088
|
+
checkoutSessionId: undefined,
|
|
1089
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1090
|
+
paymentIntentId: paymentIntent.id,
|
|
1091
|
+
amount: paymentIntent.amount,
|
|
1092
|
+
currencyId: paymentIntent.currency_id,
|
|
1093
|
+
txHash,
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
throw new CustomError('quote_expired', `Payment held for manual review: ${quoteValidation.reason}`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
981
1100
|
await paymentIntent.update({
|
|
982
1101
|
status: 'succeeded',
|
|
983
1102
|
last_payment_error: null,
|
|
@@ -1011,14 +1130,65 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
1011
1130
|
amount: paymentIntent.amount,
|
|
1012
1131
|
});
|
|
1013
1132
|
if (result.sufficient === false) {
|
|
1014
|
-
logger.error('PaymentIntent capture aborted
|
|
1015
|
-
|
|
1133
|
+
logger.error('PaymentIntent capture aborted - insufficient balance/delegation', {
|
|
1134
|
+
paymentIntentId: paymentIntent.id,
|
|
1135
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1136
|
+
failureReason: 'INSUFFICIENT_BALANCE',
|
|
1137
|
+
result,
|
|
1138
|
+
queueDelayMs,
|
|
1139
|
+
delaySeconds: Math.round(queueDelayMs / 1000),
|
|
1140
|
+
});
|
|
1141
|
+
throw new CustomError('INSUFFICIENT_BALANCE', 'Payer balance or delegation not sufficient for this payment');
|
|
1016
1142
|
}
|
|
1017
1143
|
|
|
1018
1144
|
// do the capture
|
|
1019
1145
|
const receipt = await transferErc20FromUser(client, paymentCurrency.contract, payer, paymentIntent.amount);
|
|
1020
1146
|
logger.info('PaymentIntent capture done', { id: paymentIntent.id, txHash: receipt.hash });
|
|
1021
1147
|
|
|
1148
|
+
// CRITICAL: Validate quote before marking payment as succeeded
|
|
1149
|
+
const quoteValidation = await validateQuoteForPayment({
|
|
1150
|
+
checkoutSessionId: undefined, // Will be found through invoice if exists
|
|
1151
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1152
|
+
amount: paymentIntent.amount,
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
if (!quoteValidation.valid) {
|
|
1156
|
+
if (quoteValidation.action === 'reject') {
|
|
1157
|
+
// Reject the payment - mark as failed
|
|
1158
|
+
logger.error('Automated EVM payment rejected due to quote validation', {
|
|
1159
|
+
paymentIntentId: paymentIntent.id,
|
|
1160
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1161
|
+
quoteId: quoteValidation.quoteId,
|
|
1162
|
+
reason: quoteValidation.reason,
|
|
1163
|
+
txHash: receipt.hash,
|
|
1164
|
+
});
|
|
1165
|
+
throw new CustomError('quote_validation_failed', `Payment validation failed: ${quoteValidation.reason}`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (quoteValidation.action === 'require_manual_review') {
|
|
1169
|
+
// Hold for manual review
|
|
1170
|
+
logger.error('Automated EVM payment requires manual review due to expired quote', {
|
|
1171
|
+
paymentIntentId: paymentIntent.id,
|
|
1172
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1173
|
+
quoteId: quoteValidation.quoteId,
|
|
1174
|
+
reason: quoteValidation.reason,
|
|
1175
|
+
txHash: receipt.hash,
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
await handleExpiredQuotePayment({
|
|
1179
|
+
quoteId: quoteValidation.quoteId!,
|
|
1180
|
+
checkoutSessionId: undefined,
|
|
1181
|
+
invoiceId: paymentIntent.invoice_id,
|
|
1182
|
+
paymentIntentId: paymentIntent.id,
|
|
1183
|
+
amount: paymentIntent.amount,
|
|
1184
|
+
currencyId: paymentIntent.currency_id,
|
|
1185
|
+
txHash: receipt.hash,
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
throw new CustomError('quote_expired', `Payment held for manual review: ${quoteValidation.reason}`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1022
1192
|
await paymentIntent.update({
|
|
1023
1193
|
status: 'succeeded',
|
|
1024
1194
|
last_payment_error: null,
|