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.
Files changed (117) hide show
  1. package/api/src/crons/overdue-detection.ts +10 -1
  2. package/api/src/index.ts +3 -0
  3. package/api/src/libs/credit-utils.ts +21 -0
  4. package/api/src/libs/discount/discount.ts +13 -0
  5. package/api/src/libs/env.ts +5 -0
  6. package/api/src/libs/error.ts +14 -0
  7. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  8. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  9. package/api/src/libs/exchange-rate/index.ts +5 -0
  10. package/api/src/libs/exchange-rate/service.ts +583 -0
  11. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  12. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  13. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  14. package/api/src/libs/exchange-rate/types.ts +114 -0
  15. package/api/src/libs/exchange-rate/validator.ts +319 -0
  16. package/api/src/libs/invoice-quote.ts +158 -0
  17. package/api/src/libs/invoice.ts +143 -7
  18. package/api/src/libs/math-utils.ts +46 -0
  19. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  20. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  21. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  22. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  23. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  24. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  25. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  26. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  27. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  28. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  29. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  30. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  31. package/api/src/libs/payment.ts +1 -1
  32. package/api/src/libs/price.ts +4 -1
  33. package/api/src/libs/queue/index.ts +8 -0
  34. package/api/src/libs/quote-service.ts +1132 -0
  35. package/api/src/libs/quote-validation.ts +388 -0
  36. package/api/src/libs/session.ts +686 -39
  37. package/api/src/libs/slippage.ts +135 -0
  38. package/api/src/libs/subscription.ts +185 -15
  39. package/api/src/libs/util.ts +64 -3
  40. package/api/src/locales/en.ts +50 -0
  41. package/api/src/locales/zh.ts +48 -0
  42. package/api/src/queues/auto-recharge.ts +295 -21
  43. package/api/src/queues/exchange-rate-health.ts +242 -0
  44. package/api/src/queues/invoice.ts +48 -1
  45. package/api/src/queues/notification.ts +190 -3
  46. package/api/src/queues/payment.ts +177 -7
  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/meter-events.ts +3 -0
  66. package/api/src/routes/payment-links.ts +13 -0
  67. package/api/src/routes/prices.ts +84 -2
  68. package/api/src/routes/subscriptions.ts +526 -15
  69. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  70. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  71. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  72. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  73. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  74. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  75. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  76. package/api/src/store/models/auto-recharge-config.ts +12 -0
  77. package/api/src/store/models/checkout-session.ts +7 -0
  78. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  79. package/api/src/store/models/index.ts +6 -0
  80. package/api/src/store/models/payment-intent.ts +6 -0
  81. package/api/src/store/models/price-quote.ts +284 -0
  82. package/api/src/store/models/price.ts +53 -5
  83. package/api/src/store/models/subscription.ts +11 -0
  84. package/api/src/store/models/types.ts +61 -1
  85. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  86. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  87. package/api/tests/libs/quote-service.spec.ts +199 -0
  88. package/api/tests/libs/session.spec.ts +464 -0
  89. package/api/tests/libs/slippage.spec.ts +109 -0
  90. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  91. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  92. package/api/tests/models/price-dynamic.spec.ts +100 -0
  93. package/api/tests/models/price-quote.spec.ts +112 -0
  94. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  95. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  96. package/blocklet.yml +1 -1
  97. package/package.json +7 -6
  98. package/src/components/customer/credit-overview.tsx +14 -0
  99. package/src/components/discount/discount-info.tsx +8 -2
  100. package/src/components/invoice/list.tsx +146 -16
  101. package/src/components/invoice/table.tsx +276 -71
  102. package/src/components/invoice-pdf/template.tsx +3 -7
  103. package/src/components/metadata/form.tsx +6 -8
  104. package/src/components/price/form.tsx +519 -149
  105. package/src/components/promotion/active-redemptions.tsx +5 -3
  106. package/src/components/quote/info.tsx +234 -0
  107. package/src/hooks/subscription.ts +132 -2
  108. package/src/locales/en.tsx +145 -0
  109. package/src/locales/zh.tsx +143 -1
  110. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  111. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  112. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  113. package/src/pages/admin/products/index.tsx +12 -1
  114. package/src/pages/customer/invoice/detail.tsx +36 -12
  115. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  116. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  117. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -0,0 +1,199 @@
1
+ import { BN } from '@ocap/util';
2
+ import crypto from 'crypto';
3
+
4
+ describe('QuoteService', () => {
5
+ describe('Idempotency key generation', () => {
6
+ it('should generate consistent idempotency key for same params', () => {
7
+ const params = {
8
+ contextId: 'session_123',
9
+ priceId: 'price_456',
10
+ currencyId: 'currency_789',
11
+ quantity: 1,
12
+ baseAmount: '10.00',
13
+ };
14
+
15
+ const key1 = crypto
16
+ .createHash('sha256')
17
+ .update(`${params.contextId}:${params.priceId}:${params.currencyId}:${params.quantity}:${params.baseAmount}`)
18
+ .digest('hex')
19
+ .substring(0, 64);
20
+
21
+ const key2 = crypto
22
+ .createHash('sha256')
23
+ .update(`${params.contextId}:${params.priceId}:${params.currencyId}:${params.quantity}:${params.baseAmount}`)
24
+ .digest('hex')
25
+ .substring(0, 64);
26
+
27
+ expect(key1).toBe(key2);
28
+ });
29
+
30
+ it('should generate different keys for different quantities', () => {
31
+ const params1 = {
32
+ contextId: 'session_123',
33
+ priceId: 'price_456',
34
+ currencyId: 'currency_789',
35
+ quantity: 1,
36
+ baseAmount: '10.00',
37
+ };
38
+
39
+ const params2 = {
40
+ ...params1,
41
+ quantity: 2,
42
+ baseAmount: '20.00',
43
+ };
44
+
45
+ const key1 = crypto
46
+ .createHash('sha256')
47
+ .update(
48
+ `${params1.contextId}:${params1.priceId}:${params1.currencyId}:${params1.quantity}:${params1.baseAmount}`
49
+ )
50
+ .digest('hex')
51
+ .substring(0, 64);
52
+
53
+ const key2 = crypto
54
+ .createHash('sha256')
55
+ .update(
56
+ `${params2.contextId}:${params2.priceId}:${params2.currencyId}:${params2.quantity}:${params2.baseAmount}`
57
+ )
58
+ .digest('hex')
59
+ .substring(0, 64);
60
+
61
+ expect(key1).not.toBe(key2);
62
+ });
63
+ });
64
+
65
+ describe('Quote amount calculation', () => {
66
+ it('should correctly calculate quoted amount with ceiling', () => {
67
+ const baseAmount = 10; // $10 USD
68
+ const rate = 0.1; // 1 token = $0.1 USD
69
+ const decimals = 18;
70
+
71
+ // token_amount = base_usd / rate = 10 / 0.1 = 100 tokens
72
+ const tokenAmount = baseAmount / rate;
73
+ expect(tokenAmount).toBe(100);
74
+
75
+ // Using the same logic as QuoteService
76
+ const PRECISION_SCALE = 1e8;
77
+ const scaledNumerator = Math.floor(baseAmount * PRECISION_SCALE);
78
+ const scaledDenominator = Math.floor(rate * PRECISION_SCALE);
79
+ const numerator = new BN(scaledNumerator).mul(new BN(10).pow(new BN(decimals)));
80
+ const denominator = new BN(scaledDenominator);
81
+ const quotedAmount = numerator.add(denominator).sub(new BN(1)).div(denominator);
82
+
83
+ expect(quotedAmount.toString()).toBe('100000000000000000000');
84
+ });
85
+
86
+ it('should round up fractional token amounts', () => {
87
+ const baseAmount = 10.5; // $10.5 USD
88
+ const rate = 0.15; // 1 token = $0.15 USD
89
+ const decimals = 18;
90
+
91
+ // token_amount = 10.5 / 0.15 = 70
92
+ const tokenAmount = baseAmount / rate;
93
+ expect(tokenAmount).toBe(70);
94
+
95
+ const PRECISION_SCALE = 1e8;
96
+ const scaledNumerator = Math.floor(baseAmount * PRECISION_SCALE);
97
+ const scaledDenominator = Math.floor(rate * PRECISION_SCALE);
98
+ const numerator = new BN(scaledNumerator).mul(new BN(10).pow(new BN(decimals)));
99
+ const denominator = new BN(scaledDenominator);
100
+ const quotedAmount = numerator.add(denominator).sub(new BN(1)).div(denominator);
101
+
102
+ expect(quotedAmount.toString()).toBe('70000000000000000000');
103
+ });
104
+
105
+ it('should handle precision correctly with many decimals', () => {
106
+ const baseAmount = 100;
107
+ const rate = 0.123456789;
108
+ const decimals = 18;
109
+
110
+ const PRECISION_SCALE = 1e8;
111
+ const scaledNumerator = Math.floor(baseAmount * PRECISION_SCALE);
112
+ const scaledDenominator = Math.floor(rate * PRECISION_SCALE);
113
+ const numerator = new BN(scaledNumerator).mul(new BN(10).pow(new BN(decimals)));
114
+ const denominator = new BN(scaledDenominator);
115
+ const quotedAmount = numerator.add(denominator).sub(new BN(1)).div(denominator);
116
+
117
+ // Should ceil to ensure user pays enough
118
+ expect(quotedAmount.gt(new BN(0))).toBe(true);
119
+ });
120
+ });
121
+
122
+ describe('Quote expiration', () => {
123
+ it('should calculate correct expiration time', () => {
124
+ const now = Math.floor(Date.now() / 1000);
125
+ const lockDuration = 300; // 5 minutes
126
+ const expiresAt = now + lockDuration;
127
+
128
+ expect(expiresAt).toBeGreaterThan(now);
129
+ expect(expiresAt - now).toBe(lockDuration);
130
+ });
131
+
132
+ it('should use default lock duration of 300 seconds', () => {
133
+ const defaultLockDuration = 300;
134
+ const now = Math.floor(Date.now() / 1000);
135
+ const expiresAt = now + defaultLockDuration;
136
+
137
+ expect(expiresAt - now).toBe(300);
138
+ });
139
+ });
140
+
141
+ describe('Quote metadata structure', () => {
142
+ it('should include calculation details in metadata', () => {
143
+ const tokenAmount = '100.123456789';
144
+ const unitAmount = '100123456789000000000';
145
+
146
+ const metadata = {
147
+ calculation: {
148
+ token_amount_raw: tokenAmount,
149
+ unit_amount_raw: unitAmount,
150
+ },
151
+ rounding: {
152
+ mode: 'ceil',
153
+ token_decimals: 18,
154
+ },
155
+ };
156
+
157
+ expect(metadata.calculation.token_amount_raw).toBe(tokenAmount);
158
+ expect(metadata.calculation.unit_amount_raw).toBe(unitAmount);
159
+ expect(metadata.rounding.mode).toBe('ceil');
160
+ });
161
+
162
+ it('should include risk information in metadata', () => {
163
+ const metadata = {
164
+ risk: {
165
+ anomaly_detected: false,
166
+ degraded: false,
167
+ degraded_reason: null,
168
+ },
169
+ };
170
+
171
+ expect(metadata.risk.anomaly_detected).toBe(false);
172
+ expect(metadata.risk.degraded).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe('Validation logic', () => {
177
+ it('should require either session_id or invoice_id', () => {
178
+ const params = {
179
+ price_id: 'price_123',
180
+ target_currency_id: 'currency_456',
181
+ quantity: 1,
182
+ };
183
+
184
+ // Neither session_id nor invoice_id provided
185
+ expect(params).not.toHaveProperty('session_id');
186
+ expect(params).not.toHaveProperty('invoice_id');
187
+ });
188
+
189
+ it('should validate dynamic pricing type', () => {
190
+ const price = {
191
+ pricing_type: 'fixed',
192
+ base_currency: 'USD',
193
+ base_amount: '10',
194
+ };
195
+
196
+ expect(price.pricing_type).not.toBe('dynamic');
197
+ });
198
+ });
199
+ });
@@ -383,6 +383,470 @@ describe('getSubscriptionCreateSetup', () => {
383
383
  .unix()
384
384
  );
385
385
  });
386
+
387
+ describe('subscription authorization slippage protection', () => {
388
+ it('should NOT apply slippage for static pricing (backward compatibility)', () => {
389
+ const items = [
390
+ {
391
+ price: {
392
+ type: 'recurring',
393
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
394
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
395
+ pricing_type: 'fixed', // Static pricing
396
+ },
397
+ quantity: 1,
398
+ },
399
+ ];
400
+ const result = getSubscriptionCreateSetup(items as any, 'usd');
401
+ // Base: 1000, No slippage for static pricing
402
+ expect(result.amount.setup).toBe('1000');
403
+ });
404
+
405
+ it('should apply 0.5% slippage protection for dynamic pricing', () => {
406
+ const items = [
407
+ {
408
+ price: {
409
+ type: 'recurring',
410
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
411
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
412
+ pricing_type: 'dynamic', // Dynamic pricing
413
+ },
414
+ quantity: 1,
415
+ },
416
+ ];
417
+ const result = getSubscriptionCreateSetup(items as any, 'usd');
418
+ // Base: 1000, With 0.5% slippage: 1000 * 1.005 = 1005
419
+ expect(result.amount.setup).toBe('1005');
420
+ });
421
+
422
+ it('should apply slippage correctly with multiple quantities', () => {
423
+ const items = [
424
+ {
425
+ price: {
426
+ type: 'recurring',
427
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
428
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
429
+ pricing_type: 'dynamic',
430
+ },
431
+ quantity: 3,
432
+ },
433
+ ];
434
+ const result = getSubscriptionCreateSetup(items as any, 'usd');
435
+ // Base: 1000 * 3 = 3000, With 0.5% slippage: 3000 * 1.005 = 3015
436
+ expect(result.amount.setup).toBe('3015');
437
+ });
438
+
439
+ it('should apply slippage with mixed dynamic and one-time items', () => {
440
+ const items = [
441
+ {
442
+ price: {
443
+ type: 'recurring',
444
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
445
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
446
+ pricing_type: 'dynamic',
447
+ },
448
+ quantity: 1,
449
+ },
450
+ {
451
+ price: {
452
+ type: 'one_time',
453
+ currency_options: [{ currency_id: 'usd', unit_amount: '500' }],
454
+ },
455
+ quantity: 1,
456
+ },
457
+ ];
458
+ const result = getSubscriptionCreateSetup(items as any, 'usd');
459
+ // Base: 1000 (recurring) + 500 (one-time) = 1500
460
+ // With 0.5% slippage: 1500 * 1.005 = 1507
461
+ expect(result.amount.setup).toBe('1507');
462
+ });
463
+
464
+ it('should apply slippage with mixed dynamic and static recurring items', () => {
465
+ const items = [
466
+ {
467
+ price: {
468
+ type: 'recurring',
469
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
470
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
471
+ pricing_type: 'dynamic',
472
+ },
473
+ quantity: 1,
474
+ },
475
+ {
476
+ price: {
477
+ type: 'recurring',
478
+ currency_options: [{ currency_id: 'usd', unit_amount: '500' }],
479
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
480
+ pricing_type: 'fixed', // Static pricing
481
+ },
482
+ quantity: 1,
483
+ },
484
+ ];
485
+ const result = getSubscriptionCreateSetup(items as any, 'usd');
486
+ // Base: 1000 (dynamic) + 500 (static) = 1500
487
+ // With 0.5% slippage (because at least one item is dynamic): 1500 * 1.005 = 1507
488
+ expect(result.amount.setup).toBe('1507');
489
+ });
490
+
491
+ it('should NOT apply slippage when setup amount is zero', () => {
492
+ const items = [
493
+ {
494
+ price: {
495
+ type: 'recurring',
496
+ currency_options: [{ currency_id: 'usd', unit_amount: '0' }],
497
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'metered' }, // Metered not included in setup
498
+ pricing_type: 'dynamic',
499
+ },
500
+ quantity: 1,
501
+ },
502
+ ];
503
+ const result = getSubscriptionCreateSetup(items as any, 'usd');
504
+ // Base: 0 (metered), No slippage applied
505
+ expect(result.amount.setup).toBe('0');
506
+ });
507
+
508
+ it('should handle large amounts with slippage correctly', () => {
509
+ const items = [
510
+ {
511
+ price: {
512
+ type: 'recurring',
513
+ currency_options: [{ currency_id: 'usd', unit_amount: '10000000' }], // 10M
514
+ recurring: { interval: 'year', interval_count: '1', usage_type: 'licensed' },
515
+ pricing_type: 'dynamic',
516
+ },
517
+ quantity: 1,
518
+ },
519
+ ];
520
+ const result = getSubscriptionCreateSetup(items as any, 'usd');
521
+ // Base: 10,000,000, With 0.5% slippage: 10,000,000 * 1.005 = 10,050,000
522
+ expect(result.amount.setup).toBe('10050000');
523
+ });
524
+
525
+ // Tests for custom slippage percentage parameter
526
+ it('should apply custom 5% slippage for dynamic pricing', () => {
527
+ const items = [
528
+ {
529
+ price: {
530
+ type: 'recurring',
531
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
532
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
533
+ pricing_type: 'dynamic',
534
+ },
535
+ quantity: 1,
536
+ },
537
+ ];
538
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, 5);
539
+ // Base: 1000, With 5% slippage: 1000 * 1.05 = 1050
540
+ expect(result.amount.setup).toBe('1050');
541
+ });
542
+
543
+ it('should apply custom 10% slippage for dynamic pricing', () => {
544
+ const items = [
545
+ {
546
+ price: {
547
+ type: 'recurring',
548
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
549
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
550
+ pricing_type: 'dynamic',
551
+ },
552
+ quantity: 1,
553
+ },
554
+ ];
555
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, 10);
556
+ // Base: 1000, With 10% slippage: 1000 * 1.10 = 1100
557
+ expect(result.amount.setup).toBe('1100');
558
+ });
559
+
560
+ it('should apply zero slippage when slippagePercent is 0', () => {
561
+ const items = [
562
+ {
563
+ price: {
564
+ type: 'recurring',
565
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
566
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
567
+ pricing_type: 'dynamic',
568
+ },
569
+ quantity: 1,
570
+ },
571
+ ];
572
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, 0);
573
+ // Base: 1000, With 0% slippage: 1000 * 1.00 = 1000
574
+ expect(result.amount.setup).toBe('1000');
575
+ });
576
+
577
+ it('should fallback to 0.5% slippage when slippagePercent is negative', () => {
578
+ const items = [
579
+ {
580
+ price: {
581
+ type: 'recurring',
582
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
583
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
584
+ pricing_type: 'dynamic',
585
+ },
586
+ quantity: 1,
587
+ },
588
+ ];
589
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, -1);
590
+ // Fallback to 0.5%: 1000 * 1.005 = 1005
591
+ expect(result.amount.setup).toBe('1005');
592
+ });
593
+
594
+ it('should fallback to 0.5% slippage when slippagePercent is NaN', () => {
595
+ const items = [
596
+ {
597
+ price: {
598
+ type: 'recurring',
599
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
600
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
601
+ pricing_type: 'dynamic',
602
+ },
603
+ quantity: 1,
604
+ },
605
+ ];
606
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, NaN);
607
+ // Fallback to 0.5%: 1000 * 1.005 = 1005
608
+ expect(result.amount.setup).toBe('1005');
609
+ });
610
+
611
+ it('should handle fractional slippage percentages (1.25%)', () => {
612
+ const items = [
613
+ {
614
+ price: {
615
+ type: 'recurring',
616
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
617
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
618
+ pricing_type: 'dynamic',
619
+ },
620
+ quantity: 1,
621
+ },
622
+ ];
623
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, 1.25);
624
+ // Base: 1000, With 1.25% slippage: multiplier = Math.round(1000 + 12.5) = 1013
625
+ // 1000 * 1013 / 1000 = 1013
626
+ expect(result.amount.setup).toBe('1013');
627
+ });
628
+
629
+ it('should NOT apply custom slippage for static pricing', () => {
630
+ const items = [
631
+ {
632
+ price: {
633
+ type: 'recurring',
634
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
635
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
636
+ pricing_type: 'fixed',
637
+ },
638
+ quantity: 1,
639
+ },
640
+ ];
641
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, 10);
642
+ // Static pricing: No slippage applied regardless of parameter
643
+ expect(result.amount.setup).toBe('1000');
644
+ });
645
+
646
+ // Tests for SlippageOptions object with minAcceptableRate
647
+ it('should accept SlippageOptions object with percent', () => {
648
+ const items = [
649
+ {
650
+ price: {
651
+ type: 'recurring',
652
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
653
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
654
+ pricing_type: 'dynamic',
655
+ },
656
+ quantity: 1,
657
+ },
658
+ ];
659
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, { percent: 5 });
660
+ // Base: 1000, With 5% slippage: 1000 * 1.05 = 1050
661
+ expect(result.amount.setup).toBe('1050');
662
+ });
663
+
664
+ it('should calculate authorization using minAcceptableRate when provided', () => {
665
+ const items = [
666
+ {
667
+ price: {
668
+ type: 'recurring',
669
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
670
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
671
+ pricing_type: 'dynamic',
672
+ base_amount: '100', // $100 USD
673
+ },
674
+ quantity: 1,
675
+ },
676
+ ];
677
+ // min_acceptable_rate = 0.5 means 1 token = $0.5 USD
678
+ // For $100 USD, we need: 100 / 0.5 = 200 tokens
679
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, {
680
+ percent: 5, // Should be ignored when minAcceptableRate is provided
681
+ minAcceptableRate: '0.5',
682
+ currencyDecimal: 0, // Simplified for testing
683
+ });
684
+ expect(result.amount.setup).toBe('200');
685
+ });
686
+
687
+ it('should use ceiling division for minAcceptableRate calculation', () => {
688
+ const items = [
689
+ {
690
+ price: {
691
+ type: 'recurring',
692
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
693
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
694
+ pricing_type: 'dynamic',
695
+ base_amount: '100', // $100 USD
696
+ },
697
+ quantity: 1,
698
+ },
699
+ ];
700
+ // min_acceptable_rate = 0.3 means 1 token = $0.3 USD
701
+ // For $100 USD, we need: ceil(100 / 0.3) = ceil(333.33) = 334 tokens
702
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, {
703
+ minAcceptableRate: '0.3',
704
+ currencyDecimal: 0,
705
+ });
706
+ expect(result.amount.setup).toBe('334');
707
+ });
708
+
709
+ it('should handle multiple dynamic pricing items with minAcceptableRate', () => {
710
+ const items = [
711
+ {
712
+ price: {
713
+ type: 'recurring',
714
+ currency_options: [{ currency_id: 'usd', unit_amount: '500' }],
715
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
716
+ pricing_type: 'dynamic',
717
+ base_amount: '50', // $50 USD
718
+ },
719
+ quantity: 2, // Total: $100 USD
720
+ },
721
+ {
722
+ price: {
723
+ type: 'recurring',
724
+ currency_options: [{ currency_id: 'usd', unit_amount: '250' }],
725
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
726
+ pricing_type: 'dynamic',
727
+ base_amount: '25', // $25 USD
728
+ },
729
+ quantity: 2, // Total: $50 USD
730
+ },
731
+ ];
732
+ // Total base_amount: $100 + $50 = $150 USD
733
+ // min_acceptable_rate = 0.5 means 1 token = $0.5 USD
734
+ // For $150 USD, we need: 150 / 0.5 = 300 tokens
735
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, {
736
+ minAcceptableRate: '0.5',
737
+ currencyDecimal: 0,
738
+ });
739
+ expect(result.amount.setup).toBe('300');
740
+ });
741
+
742
+ it('should fallback to percent when minAcceptableRate is missing', () => {
743
+ const items = [
744
+ {
745
+ price: {
746
+ type: 'recurring',
747
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
748
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
749
+ pricing_type: 'dynamic',
750
+ base_amount: '100',
751
+ },
752
+ quantity: 1,
753
+ },
754
+ ];
755
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, {
756
+ percent: 10,
757
+ // minAcceptableRate is undefined
758
+ currencyDecimal: 0,
759
+ });
760
+ // Fallback to percent: 1000 * 1.10 = 1100
761
+ expect(result.amount.setup).toBe('1100');
762
+ });
763
+
764
+ it('should fallback to percent when currencyDecimal is missing', () => {
765
+ const items = [
766
+ {
767
+ price: {
768
+ type: 'recurring',
769
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
770
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
771
+ pricing_type: 'dynamic',
772
+ base_amount: '100',
773
+ },
774
+ quantity: 1,
775
+ },
776
+ ];
777
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, {
778
+ percent: 5,
779
+ minAcceptableRate: '0.5',
780
+ // currencyDecimal is undefined
781
+ });
782
+ // Fallback to percent: 1000 * 1.05 = 1050
783
+ expect(result.amount.setup).toBe('1050');
784
+ });
785
+
786
+ it('should fallback to percent when minAcceptableRate is zero', () => {
787
+ const items = [
788
+ {
789
+ price: {
790
+ type: 'recurring',
791
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
792
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
793
+ pricing_type: 'dynamic',
794
+ base_amount: '100',
795
+ },
796
+ quantity: 1,
797
+ },
798
+ ];
799
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, {
800
+ percent: 5,
801
+ minAcceptableRate: '0',
802
+ currencyDecimal: 0,
803
+ });
804
+ // Fallback to percent: 1000 * 1.05 = 1050
805
+ expect(result.amount.setup).toBe('1050');
806
+ });
807
+
808
+ it('should fallback to default 0.5% when SlippageOptions is empty object', () => {
809
+ const items = [
810
+ {
811
+ price: {
812
+ type: 'recurring',
813
+ currency_options: [{ currency_id: 'usd', unit_amount: '1000' }],
814
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
815
+ pricing_type: 'dynamic',
816
+ },
817
+ quantity: 1,
818
+ },
819
+ ];
820
+ const result = getSubscriptionCreateSetup(items as any, 'usd', 0, 0, {});
821
+ // Fallback to default 0.5%: 1000 * 1.005 = 1005
822
+ expect(result.amount.setup).toBe('1005');
823
+ });
824
+
825
+ it('should handle minAcceptableRate with higher precision currencyDecimal', () => {
826
+ const items = [
827
+ {
828
+ price: {
829
+ type: 'recurring',
830
+ currency_options: [{ currency_id: 'eth', unit_amount: '100000000000000000' }], // 0.1 ETH in wei
831
+ recurring: { interval: 'month', interval_count: '1', usage_type: 'licensed' },
832
+ pricing_type: 'dynamic',
833
+ base_amount: '100', // $100 USD
834
+ },
835
+ quantity: 1,
836
+ },
837
+ ];
838
+ // min_acceptable_rate = 1000 means 1 token (wei) = $1000 USD
839
+ // But we have 18 decimals, so 1 ETH = $1000 * 10^18 / 10^18 = $1000
840
+ // For $100 USD with currencyDecimal=18:
841
+ // authorization = ceil(100 * 10^8 * 10^18 / (1000 * 10^8))
842
+ // = ceil(10^28 / 10^11) = ceil(10^17) = 100000000000000000 (0.1 ETH)
843
+ const result = getSubscriptionCreateSetup(items as any, 'eth', 0, 0, {
844
+ minAcceptableRate: '1000',
845
+ currencyDecimal: 18,
846
+ });
847
+ expect(result.amount.setup).toBe('100000000000000000');
848
+ });
849
+ });
386
850
  });
387
851
 
388
852
  describe('getCheckoutSessionSubscriptionIds', () => {