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.
Files changed (116) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +3 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/refund.ts +41 -9
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/payment-links.ts +13 -0
  66. package/api/src/routes/prices.ts +84 -2
  67. package/api/src/routes/subscriptions.ts +526 -15
  68. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  69. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  70. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  71. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  72. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  73. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  74. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  75. package/api/src/store/models/auto-recharge-config.ts +12 -0
  76. package/api/src/store/models/checkout-session.ts +7 -0
  77. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  78. package/api/src/store/models/index.ts +6 -0
  79. package/api/src/store/models/payment-intent.ts +6 -0
  80. package/api/src/store/models/price-quote.ts +284 -0
  81. package/api/src/store/models/price.ts +53 -5
  82. package/api/src/store/models/subscription.ts +11 -0
  83. package/api/src/store/models/types.ts +61 -1
  84. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  85. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  86. package/api/tests/libs/quote-service.spec.ts +199 -0
  87. package/api/tests/libs/session.spec.ts +464 -0
  88. package/api/tests/libs/slippage.spec.ts +109 -0
  89. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  90. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  91. package/api/tests/models/price-dynamic.spec.ts +100 -0
  92. package/api/tests/models/price-quote.spec.ts +112 -0
  93. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  94. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  95. package/blocklet.yml +1 -1
  96. package/package.json +7 -6
  97. package/src/components/customer/credit-overview.tsx +14 -0
  98. package/src/components/discount/discount-info.tsx +8 -2
  99. package/src/components/invoice/list.tsx +146 -16
  100. package/src/components/invoice/table.tsx +276 -71
  101. package/src/components/invoice-pdf/template.tsx +3 -7
  102. package/src/components/metadata/form.tsx +6 -8
  103. package/src/components/price/form.tsx +519 -149
  104. package/src/components/promotion/active-redemptions.tsx +5 -3
  105. package/src/components/quote/info.tsx +234 -0
  106. package/src/hooks/subscription.ts +132 -2
  107. package/src/locales/en.tsx +145 -0
  108. package/src/locales/zh.tsx +143 -1
  109. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  110. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  111. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  112. package/src/pages/admin/products/index.tsx +12 -1
  113. package/src/pages/customer/invoice/detail.tsx +36 -12
  114. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  115. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  116. 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
+ }