payment-kit 1.24.4 → 1.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/index.ts +3 -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 +3 -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 +167 -1
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/refund.ts +41 -9
- 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/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) {
|
|
@@ -119,6 +119,18 @@ import {
|
|
|
119
119
|
WebhookEndpointFailedEmailTemplate,
|
|
120
120
|
WebhookEndpointFailedEmailTemplateOptions,
|
|
121
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';
|
|
122
134
|
import type { TJob } from '../store/models/job';
|
|
123
135
|
|
|
124
136
|
export type NotificationQueueJobOptions = any;
|
|
@@ -145,7 +157,11 @@ export type NotificationQueueJobType =
|
|
|
145
157
|
| 'customer.credit.low_balance'
|
|
146
158
|
| 'customer.auto_recharge.failed'
|
|
147
159
|
| 'customer.auto_recharge.daily_limit_exceeded'
|
|
148
|
-
| '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';
|
|
149
165
|
|
|
150
166
|
export type NotificationQueueJob = {
|
|
151
167
|
type: NotificationQueueJobType;
|
|
@@ -301,6 +317,21 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
|
|
|
301
317
|
return new WebhookEndpointFailedEmailTemplate(job.options as WebhookEndpointFailedEmailTemplateOptions);
|
|
302
318
|
}
|
|
303
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
|
+
|
|
304
335
|
throw new Error(`Unknown job type: ${job.type}`);
|
|
305
336
|
}
|
|
306
337
|
|
|
@@ -741,6 +772,141 @@ export async function startNotificationQueue() {
|
|
|
741
772
|
}
|
|
742
773
|
);
|
|
743
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
|
+
|
|
744
910
|
// Clear credit notification cache when customer recharges
|
|
745
911
|
// This allows customer to receive insufficient/low_balance notifications again
|
|
746
912
|
events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
|
|
@@ -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: result.reason,
|
|
943
1016
|
result,
|
|
1017
|
+
queueDelayMs,
|
|
1018
|
+
delaySeconds: Math.round(queueDelayMs / 1000),
|
|
944
1019
|
});
|
|
945
|
-
throw new CustomError(result.reason, '
|
|
1020
|
+
throw new CustomError(result.reason, '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,
|
package/api/src/queues/refund.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isRefundReasonSupportedByStripe } from '../libs/refund';
|
|
|
3
3
|
import { checkRemainingStake, getSubscriptionStakeAddress } from '../libs/subscription';
|
|
4
4
|
import { sendErc20ToUser } from '../integrations/ethereum/token';
|
|
5
5
|
import { wallet } from '../libs/auth';
|
|
6
|
-
import CustomError from '../libs/error';
|
|
6
|
+
import CustomError, { NonRetryableError } from '../libs/error';
|
|
7
7
|
import { events } from '../libs/event';
|
|
8
8
|
import logger from '../libs/logger';
|
|
9
9
|
import { getGasPayerExtra, isBalanceSufficientForRefund } from '../libs/payment';
|
|
@@ -35,6 +35,21 @@ type Updates = {
|
|
|
35
35
|
};
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
const markRefundNonRetryable = async (refund: Refund, code: string, message: string, paymentMethod?: PaymentMethod) => {
|
|
39
|
+
await refund.update({
|
|
40
|
+
status: 'requires_action',
|
|
41
|
+
last_attempt_error: {
|
|
42
|
+
type: 'invalid_request_error',
|
|
43
|
+
code,
|
|
44
|
+
message,
|
|
45
|
+
payment_method_id: paymentMethod?.id,
|
|
46
|
+
payment_method_type: paymentMethod?.type,
|
|
47
|
+
},
|
|
48
|
+
attempt_count: refund.attempt_count + 1,
|
|
49
|
+
attempted: true,
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
38
53
|
export const handleRefundFailed = (refund: Refund, error: PaymentError) => {
|
|
39
54
|
const attemptCount = refund.attempt_count + 1;
|
|
40
55
|
const updates: Updates = {
|
|
@@ -94,24 +109,27 @@ export const handleRefund = async (job: RefundJob) => {
|
|
|
94
109
|
const paymentCurrency = await PaymentCurrency.findByPk(refund.currency_id);
|
|
95
110
|
if (!paymentCurrency) {
|
|
96
111
|
logger.warn(`PaymentCurrency not found: ${refund.currency_id}`);
|
|
112
|
+
await markRefundNonRetryable(refund, 'CURRENCY_NOT_FOUND', 'Payment currency not found');
|
|
97
113
|
return;
|
|
98
114
|
}
|
|
99
115
|
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
100
116
|
if (!paymentMethod) {
|
|
101
117
|
logger.warn(`PaymentMethod not found: ${paymentCurrency.payment_method_id}`);
|
|
118
|
+
await markRefundNonRetryable(refund, 'PAYMENT_METHOD_NOT_FOUND', 'Payment method not found');
|
|
102
119
|
return;
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
const customer = await Customer.findByPk(refund.customer_id);
|
|
106
123
|
if (!customer) {
|
|
107
124
|
logger.warn(`Customer not found: ${refund.customer_id}`);
|
|
125
|
+
await markRefundNonRetryable(refund, 'CUSTOMER_NOT_FOUND', 'Customer not found', paymentMethod);
|
|
108
126
|
return;
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
if (refund?.type === 'stake_return') {
|
|
112
|
-
handleStakeReturnJob(job, refund, paymentCurrency, paymentMethod, customer);
|
|
130
|
+
await handleStakeReturnJob(job, refund, paymentCurrency, paymentMethod, customer);
|
|
113
131
|
} else if (paymentMethod) {
|
|
114
|
-
handleRefundJob(job, refund, paymentCurrency, paymentMethod, customer);
|
|
132
|
+
await handleRefundJob(job, refund, paymentCurrency, paymentMethod, customer);
|
|
115
133
|
}
|
|
116
134
|
};
|
|
117
135
|
|
|
@@ -164,18 +182,26 @@ const handleRefundJob = async (
|
|
|
164
182
|
const refundSupport = ['arcblock', 'ethereum', 'stripe', 'base'].includes(paymentMethod.type);
|
|
165
183
|
if (refundSupport === false) {
|
|
166
184
|
logger.warn(`PaymentMethod does not support auto charge: ${paymentCurrency.payment_method_id}`);
|
|
185
|
+
await markRefundNonRetryable(
|
|
186
|
+
refund,
|
|
187
|
+
'PAYMENT_METHOD_NOT_SUPPORTED',
|
|
188
|
+
'Payment method does not support refund',
|
|
189
|
+
paymentMethod
|
|
190
|
+
);
|
|
167
191
|
return;
|
|
168
192
|
}
|
|
169
193
|
|
|
170
194
|
// try refund transfer and reschedule on error
|
|
171
195
|
logger.info('Refund transfer attempt', { id: refund.id, attempt: refund.attempt_count });
|
|
172
|
-
let result;
|
|
173
|
-
const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
|
|
174
|
-
if (!paymentIntent) {
|
|
175
|
-
throw new Error('PaymentIntent not found');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
196
|
try {
|
|
197
|
+
if (!refund.payment_intent_id) {
|
|
198
|
+
throw new NonRetryableError('PAYMENT_INTENT_NOT_FOUND', 'payment_intent_id is missing for refund');
|
|
199
|
+
}
|
|
200
|
+
const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
|
|
201
|
+
if (!paymentIntent) {
|
|
202
|
+
throw new NonRetryableError('PAYMENT_INTENT_NOT_FOUND', 'PaymentIntent not found');
|
|
203
|
+
}
|
|
204
|
+
let result;
|
|
179
205
|
if (paymentMethod.type === 'arcblock') {
|
|
180
206
|
const client = paymentMethod.getOcapClient();
|
|
181
207
|
// check balance before transfer with transaction
|
|
@@ -288,6 +314,12 @@ const handleRefundJob = async (
|
|
|
288
314
|
} catch (err) {
|
|
289
315
|
logger.error('refund transfer failed', { error: err, id: refund.id });
|
|
290
316
|
|
|
317
|
+
if (err instanceof NonRetryableError || err?.nonRetryable === true) {
|
|
318
|
+
await markRefundNonRetryable(refund, err.code, err.message, paymentMethod);
|
|
319
|
+
logger.error('refund transfer aborted: non-retryable error', { id: refund.id, code: err.code });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
291
323
|
const error: PaymentError = {
|
|
292
324
|
type: 'card_error',
|
|
293
325
|
code: err.code,
|