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
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
2
|
+
|
|
3
|
+
import { PriceQuote } from '../store/models/price-quote';
|
|
4
|
+
import { InvoiceItem, Invoice, PaymentIntent, CheckoutSession } from '../store/models';
|
|
5
|
+
import logger from './logger';
|
|
6
|
+
|
|
7
|
+
export interface QuoteValidationResult {
|
|
8
|
+
valid: boolean;
|
|
9
|
+
quoteId?: string;
|
|
10
|
+
reason?: string;
|
|
11
|
+
action: 'allow' | 'require_manual_review' | 'reject';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SLIPPAGE_BPS_BASE = new BN(10000);
|
|
15
|
+
|
|
16
|
+
const normalizeSlippagePercent = (value: unknown): number | null => {
|
|
17
|
+
if (value === null || value === undefined) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const parsed = typeof value === 'string' ? Number(value) : Number(value);
|
|
21
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return parsed;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const resolveQuoteMaxPayableToken = (quote: PriceQuote): string | null => {
|
|
28
|
+
const direct = quote.max_payable_token ?? (quote.metadata as any)?.slippage?.max_payable_token;
|
|
29
|
+
if (direct) {
|
|
30
|
+
return direct;
|
|
31
|
+
}
|
|
32
|
+
if (!quote.quoted_amount) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const slippagePercent = normalizeSlippagePercent(
|
|
36
|
+
quote.slippage_percent ?? (quote.metadata as any)?.slippage?.percent
|
|
37
|
+
);
|
|
38
|
+
if (slippagePercent === null) {
|
|
39
|
+
return quote.quoted_amount;
|
|
40
|
+
}
|
|
41
|
+
const slippageBps = Math.round(slippagePercent * 100);
|
|
42
|
+
const multiplier = SLIPPAGE_BPS_BASE.add(new BN(slippageBps));
|
|
43
|
+
const quotedAmountUnit = new BN(quote.quoted_amount);
|
|
44
|
+
return quotedAmountUnit
|
|
45
|
+
.mul(multiplier)
|
|
46
|
+
.add(SLIPPAGE_BPS_BASE.sub(new BN(1)))
|
|
47
|
+
.div(SLIPPAGE_BPS_BASE)
|
|
48
|
+
.toString();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const resolveMaxPayableTotal = async (params: { invoiceId?: string; quotes: PriceQuote[] }): Promise<BN | null> => {
|
|
52
|
+
const { invoiceId, quotes } = params;
|
|
53
|
+
if (!invoiceId || quotes.length === 0) {
|
|
54
|
+
if (quotes.length === 0) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return quotes.reduce((sum, quote) => {
|
|
58
|
+
const maxPayable = resolveQuoteMaxPayableToken(quote) || quote.quoted_amount || '0';
|
|
59
|
+
return sum.add(new BN(maxPayable));
|
|
60
|
+
}, new BN(0));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: invoiceId } });
|
|
64
|
+
if (!invoiceItems.length) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
|
|
69
|
+
|
|
70
|
+
// Check if ANY invoice items have quote_id linked
|
|
71
|
+
const itemsWithQuoteId = invoiceItems.filter((item) => item.metadata?.quote_id);
|
|
72
|
+
|
|
73
|
+
// If no invoice items have quote_id but quotes exist (linked via invoice_id),
|
|
74
|
+
// use quotes' max_payable_token directly instead of falling back to item amounts.
|
|
75
|
+
// This handles the case where quotes were created for dynamic pricing but
|
|
76
|
+
// invoice items weren't updated with quote_id (e.g., auto-recharge invoices before the fix).
|
|
77
|
+
if (itemsWithQuoteId.length === 0 && quotes.length > 0) {
|
|
78
|
+
logger.info('No invoice items have quote_id, using quotes max_payable_token directly', {
|
|
79
|
+
invoiceId,
|
|
80
|
+
quoteIds: quotes.map((q) => q.id),
|
|
81
|
+
itemCount: invoiceItems.length,
|
|
82
|
+
});
|
|
83
|
+
return quotes.reduce((sum, quote) => {
|
|
84
|
+
const maxPayable = resolveQuoteMaxPayableToken(quote) || quote.quoted_amount || '0';
|
|
85
|
+
return sum.add(new BN(maxPayable));
|
|
86
|
+
}, new BN(0));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Normal case: match invoice items to quotes by quote_id
|
|
90
|
+
let total = new BN(0);
|
|
91
|
+
invoiceItems.forEach((item) => {
|
|
92
|
+
const quoteId = item.metadata?.quote_id;
|
|
93
|
+
const quote = quoteId ? quotesById.get(quoteId) : undefined;
|
|
94
|
+
if (quote) {
|
|
95
|
+
const maxPayable = resolveQuoteMaxPayableToken(quote) || quote.quoted_amount || '0';
|
|
96
|
+
total = total.add(new BN(maxPayable));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (item.amount) {
|
|
100
|
+
total = total.add(new BN(item.amount));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return total;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate quote status before processing on-chain payment
|
|
108
|
+
* This function ensures that payments are only processed for active quotes
|
|
109
|
+
*
|
|
110
|
+
* P0 CRITICAL: This prevents silent success when users pay after quote expiration
|
|
111
|
+
*
|
|
112
|
+
* @param checkoutSessionId - Optional checkout session ID
|
|
113
|
+
* @param invoiceId - Optional invoice ID
|
|
114
|
+
* @returns Validation result with recommended action
|
|
115
|
+
*/
|
|
116
|
+
export async function validateQuoteForPayment(params: {
|
|
117
|
+
checkoutSessionId?: string;
|
|
118
|
+
invoiceId?: string;
|
|
119
|
+
amount?: string;
|
|
120
|
+
skipMaxPayableCheck?: boolean;
|
|
121
|
+
}): Promise<QuoteValidationResult> {
|
|
122
|
+
const { checkoutSessionId, invoiceId, amount, skipMaxPayableCheck = false } = params;
|
|
123
|
+
|
|
124
|
+
// Get quotes associated with this payment
|
|
125
|
+
const quotes = await findRelatedQuotes({ checkoutSessionId, invoiceId });
|
|
126
|
+
|
|
127
|
+
if (quotes.length === 0) {
|
|
128
|
+
// No quotes found - this is a fixed-price payment, allow it
|
|
129
|
+
return {
|
|
130
|
+
valid: true,
|
|
131
|
+
action: 'allow',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check all quotes
|
|
136
|
+
// Final Freeze: Quote statuses are 'used', 'paid', 'payment_failed'
|
|
137
|
+
// Quotes no longer expire by time - they only expire when flow ends
|
|
138
|
+
const usedQuotes: PriceQuote[] = [];
|
|
139
|
+
const paidQuotes: PriceQuote[] = [];
|
|
140
|
+
const failedQuotes: PriceQuote[] = [];
|
|
141
|
+
|
|
142
|
+
for (const quote of quotes) {
|
|
143
|
+
if (quote.status === 'paid') {
|
|
144
|
+
paidQuotes.push(quote);
|
|
145
|
+
} else if (quote.status === 'used') {
|
|
146
|
+
// 'used' quotes are valid for payment - this is the normal state after submit
|
|
147
|
+
usedQuotes.push(quote);
|
|
148
|
+
} else if (quote.status === 'payment_failed') {
|
|
149
|
+
// 'payment_failed' quotes can be retried
|
|
150
|
+
failedQuotes.push(quote);
|
|
151
|
+
}
|
|
152
|
+
// Note: 'active' and 'expired' statuses are deprecated in Final Freeze
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Case 1: All quotes are already paid - check if this is a legitimate duplicate or needs special handling
|
|
156
|
+
if (paidQuotes.length === quotes.length && paidQuotes.length > 0) {
|
|
157
|
+
// Check if the checkout session has already completed successfully
|
|
158
|
+
// If so, this is an idempotent request and we should return success instead of reject
|
|
159
|
+
if (checkoutSessionId) {
|
|
160
|
+
const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
|
|
161
|
+
if (checkoutSession?.status === 'complete' && checkoutSession?.payment_status === 'paid') {
|
|
162
|
+
logger.info('All quotes already paid - checkout session already completed (idempotent success)', {
|
|
163
|
+
checkoutSessionId,
|
|
164
|
+
invoiceId,
|
|
165
|
+
quoteIds: paidQuotes.map((q) => q.id),
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
valid: true,
|
|
169
|
+
quoteId: paidQuotes[0]?.id,
|
|
170
|
+
reason: 'Already completed - idempotent success',
|
|
171
|
+
action: 'allow',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
logger.warn('All quotes already paid - duplicate payment attempt', {
|
|
177
|
+
checkoutSessionId,
|
|
178
|
+
invoiceId,
|
|
179
|
+
quoteIds: paidQuotes.map((q) => q.id),
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
valid: false,
|
|
183
|
+
quoteId: paidQuotes[0]?.id,
|
|
184
|
+
reason: 'All quotes already paid - duplicate payment',
|
|
185
|
+
action: 'reject',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!skipMaxPayableCheck && amount && quotes.length > 0) {
|
|
190
|
+
const maxPayableTotal = await resolveMaxPayableTotal({ invoiceId, quotes });
|
|
191
|
+
if (maxPayableTotal) {
|
|
192
|
+
const amountBN = new BN(amount);
|
|
193
|
+
if (amountBN.gt(maxPayableTotal)) {
|
|
194
|
+
logger.error('Payment exceeds max payable token limit', {
|
|
195
|
+
checkoutSessionId,
|
|
196
|
+
invoiceId,
|
|
197
|
+
amount,
|
|
198
|
+
maxPayableTotal: maxPayableTotal.toString(),
|
|
199
|
+
quoteIds: quotes.map((q) => q.id),
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
valid: false,
|
|
203
|
+
reason: `QUOTE_MAX_PAYABLE_EXCEEDED: amount ${amount} > max ${maxPayableTotal.toString()}`,
|
|
204
|
+
action: 'reject',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Case 2: All quotes are already used/paid - need to check if this is a duplicate payment
|
|
211
|
+
if (usedQuotes.length === quotes.length) {
|
|
212
|
+
// Check if this is a legitimate payment (quotes consumed during submit, but payment not yet completed)
|
|
213
|
+
// or a duplicate payment (payment already succeeded)
|
|
214
|
+
let isLegitimatePayment = true;
|
|
215
|
+
|
|
216
|
+
if (invoiceId) {
|
|
217
|
+
const invoice = await Invoice.findByPk(invoiceId);
|
|
218
|
+
|
|
219
|
+
// If invoice is already paid, this is a duplicate payment
|
|
220
|
+
if (invoice?.status === 'paid') {
|
|
221
|
+
isLegitimatePayment = false;
|
|
222
|
+
} else if (invoice?.payment_intent_id) {
|
|
223
|
+
// Check payment intent status
|
|
224
|
+
const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
225
|
+
// If payment intent already succeeded, this is a duplicate payment
|
|
226
|
+
if (paymentIntent?.status === 'succeeded') {
|
|
227
|
+
isLegitimatePayment = false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!isLegitimatePayment) {
|
|
233
|
+
logger.warn('All quotes already used and payment already completed - duplicate payment', {
|
|
234
|
+
checkoutSessionId,
|
|
235
|
+
invoiceId,
|
|
236
|
+
quoteIds: usedQuotes.map((q) => q.id),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
valid: false,
|
|
241
|
+
reason: 'All associated quotes have already been used and payment completed',
|
|
242
|
+
action: 'reject',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Quotes are 'used' but payment not yet completed - this is the normal submit → pay flow
|
|
247
|
+
logger.info('Quotes are used but payment not completed - allowing payment', {
|
|
248
|
+
checkoutSessionId,
|
|
249
|
+
invoiceId,
|
|
250
|
+
quoteIds: usedQuotes.map((q) => q.id),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Allow the payment to proceed with the used quotes
|
|
254
|
+
return {
|
|
255
|
+
valid: true,
|
|
256
|
+
quoteId: usedQuotes[0]?.id,
|
|
257
|
+
action: 'allow',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Case 3: Some quotes are 'used' and some are 'payment_failed' - allow retry
|
|
262
|
+
if (usedQuotes.length > 0 || failedQuotes.length > 0) {
|
|
263
|
+
const validQuotes = [...usedQuotes, ...failedQuotes];
|
|
264
|
+
logger.info('Payment allowed with used/failed quotes', {
|
|
265
|
+
checkoutSessionId,
|
|
266
|
+
invoiceId,
|
|
267
|
+
usedCount: usedQuotes.length,
|
|
268
|
+
failedCount: failedQuotes.length,
|
|
269
|
+
quoteIds: validQuotes.map((q) => q.id),
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
valid: true,
|
|
273
|
+
quoteId: validQuotes[0]?.id,
|
|
274
|
+
action: 'allow',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Case 4: Unknown state - log and allow (fail-open for edge cases)
|
|
279
|
+
logger.warn('Unknown quote state detected - allowing payment', {
|
|
280
|
+
checkoutSessionId,
|
|
281
|
+
invoiceId,
|
|
282
|
+
quoteCount: quotes.length,
|
|
283
|
+
quoteStatuses: quotes.map((q) => ({ id: q.id, status: q.status })),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
valid: true,
|
|
288
|
+
reason: 'Unknown state - allowed by default',
|
|
289
|
+
action: 'allow',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Find all quotes related to a checkout session or invoice
|
|
295
|
+
*/
|
|
296
|
+
async function findRelatedQuotes(params: { checkoutSessionId?: string; invoiceId?: string }): Promise<PriceQuote[]> {
|
|
297
|
+
const { checkoutSessionId, invoiceId } = params;
|
|
298
|
+
const quotes: PriceQuote[] = [];
|
|
299
|
+
|
|
300
|
+
if (checkoutSessionId) {
|
|
301
|
+
// Find quotes associated with checkout session
|
|
302
|
+
const sessionQuotes = await PriceQuote.findAll({
|
|
303
|
+
where: { session_id: checkoutSessionId },
|
|
304
|
+
});
|
|
305
|
+
quotes.push(...sessionQuotes);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (invoiceId) {
|
|
309
|
+
// Find quotes associated with invoice
|
|
310
|
+
const invoiceQuotes = await PriceQuote.findAll({
|
|
311
|
+
where: { invoice_id: invoiceId },
|
|
312
|
+
});
|
|
313
|
+
quotes.push(...invoiceQuotes);
|
|
314
|
+
|
|
315
|
+
// Also check invoice items metadata for quote references
|
|
316
|
+
const invoiceItems = await InvoiceItem.findAll({
|
|
317
|
+
where: { invoice_id: invoiceId },
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Sequential DB queries needed for quote lookup - parallel would be less efficient for small sets
|
|
321
|
+
// eslint-disable-next-line no-await-in-loop
|
|
322
|
+
for (const item of invoiceItems) {
|
|
323
|
+
const quoteId = item.metadata?.quote_id;
|
|
324
|
+
if (quoteId && typeof quoteId === 'string') {
|
|
325
|
+
// eslint-disable-next-line no-await-in-loop
|
|
326
|
+
const quote = await PriceQuote.findByPk(quoteId);
|
|
327
|
+
if (quote) {
|
|
328
|
+
quotes.push(quote);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Deduplicate quotes
|
|
335
|
+
const uniqueQuotes = Array.from(new Map(quotes.map((q) => [q.id, q])).values());
|
|
336
|
+
|
|
337
|
+
return uniqueQuotes;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Handle payment with expired quote
|
|
342
|
+
* This function should be called when a payment arrives after quote expiration
|
|
343
|
+
*
|
|
344
|
+
* P0 CRITICAL: This ensures funds are not lost and proper procedures are followed
|
|
345
|
+
*
|
|
346
|
+
* @param params - Payment details
|
|
347
|
+
* @returns Action taken
|
|
348
|
+
*/
|
|
349
|
+
export function handleExpiredQuotePayment(params: {
|
|
350
|
+
quoteId: string;
|
|
351
|
+
checkoutSessionId?: string;
|
|
352
|
+
invoiceId?: string;
|
|
353
|
+
paymentIntentId?: string;
|
|
354
|
+
amount: string;
|
|
355
|
+
currencyId: string;
|
|
356
|
+
txHash?: string;
|
|
357
|
+
}): {
|
|
358
|
+
action: 'held_for_review' | 'refund_initiated';
|
|
359
|
+
message: string;
|
|
360
|
+
} {
|
|
361
|
+
const { quoteId, checkoutSessionId, invoiceId, paymentIntentId, amount, currencyId, txHash } = params;
|
|
362
|
+
|
|
363
|
+
logger.error('Payment received with expired quote - initiating review process', {
|
|
364
|
+
quoteId,
|
|
365
|
+
checkoutSessionId,
|
|
366
|
+
invoiceId,
|
|
367
|
+
paymentIntentId,
|
|
368
|
+
amount,
|
|
369
|
+
currencyId,
|
|
370
|
+
txHash,
|
|
371
|
+
timestamp: new Date().toISOString(),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// TODO: Integrate with your manual review / dispute resolution system
|
|
375
|
+
// Options:
|
|
376
|
+
// 1. Hold payment for manual review
|
|
377
|
+
// 2. Automatically refund
|
|
378
|
+
// 3. Create a new quote at current rate and ask customer to confirm
|
|
379
|
+
// 4. Create support ticket
|
|
380
|
+
|
|
381
|
+
// For now, mark payment as requiring manual review
|
|
382
|
+
// This prevents silent success and ensures proper handling
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
action: 'held_for_review',
|
|
386
|
+
message: `Payment held for manual review due to expired quote. Quote ID: ${quoteId}`,
|
|
387
|
+
};
|
|
388
|
+
}
|