@blocklet/payment-react 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 (101) hide show
  1. package/es/components/auto-topup/modal.d.ts +2 -0
  2. package/es/components/auto-topup/modal.js +48 -6
  3. package/es/components/auto-topup/product-card.d.ts +16 -1
  4. package/es/components/auto-topup/product-card.js +97 -15
  5. package/es/components/dynamic-pricing-unavailable.d.ts +9 -0
  6. package/es/components/dynamic-pricing-unavailable.js +58 -0
  7. package/es/components/loading-amount.d.ts +17 -0
  8. package/es/components/loading-amount.js +46 -0
  9. package/es/components/price-change-confirm.d.ts +18 -0
  10. package/es/components/price-change-confirm.js +107 -0
  11. package/es/components/quote-details-panel.d.ts +21 -0
  12. package/es/components/quote-details-panel.js +170 -0
  13. package/es/components/quote-lock-banner.d.ts +7 -0
  14. package/es/components/quote-lock-banner.js +79 -0
  15. package/es/components/slippage-config.d.ts +20 -0
  16. package/es/components/slippage-config.js +261 -0
  17. package/es/history/credit/transactions-list.js +11 -1
  18. package/es/history/invoice/list.js +125 -15
  19. package/es/hooks/dynamic-pricing.d.ts +102 -0
  20. package/es/hooks/dynamic-pricing.js +393 -0
  21. package/es/index.d.ts +6 -1
  22. package/es/index.js +9 -1
  23. package/es/libs/util.d.ts +42 -5
  24. package/es/libs/util.js +345 -57
  25. package/es/locales/en.js +114 -3
  26. package/es/locales/zh.js +114 -3
  27. package/es/payment/form/index.d.ts +4 -1
  28. package/es/payment/form/index.js +454 -22
  29. package/es/payment/index.d.ts +1 -1
  30. package/es/payment/index.js +279 -16
  31. package/es/payment/product-item.d.ts +26 -1
  32. package/es/payment/product-item.js +330 -51
  33. package/es/payment/summary-section/promotion-section.d.ts +32 -0
  34. package/es/payment/summary-section/promotion-section.js +143 -0
  35. package/es/payment/summary-section/total-section.d.ts +39 -0
  36. package/es/payment/summary-section/total-section.js +83 -0
  37. package/es/payment/summary.d.ts +17 -2
  38. package/es/payment/summary.js +300 -253
  39. package/es/types/index.d.ts +11 -0
  40. package/lib/components/auto-topup/modal.d.ts +2 -0
  41. package/lib/components/auto-topup/modal.js +54 -6
  42. package/lib/components/auto-topup/product-card.d.ts +16 -1
  43. package/lib/components/auto-topup/product-card.js +75 -7
  44. package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
  45. package/lib/components/dynamic-pricing-unavailable.js +81 -0
  46. package/lib/components/loading-amount.d.ts +17 -0
  47. package/lib/components/loading-amount.js +53 -0
  48. package/lib/components/price-change-confirm.d.ts +18 -0
  49. package/lib/components/price-change-confirm.js +157 -0
  50. package/lib/components/quote-details-panel.d.ts +21 -0
  51. package/lib/components/quote-details-panel.js +226 -0
  52. package/lib/components/quote-lock-banner.d.ts +7 -0
  53. package/lib/components/quote-lock-banner.js +93 -0
  54. package/lib/components/slippage-config.d.ts +20 -0
  55. package/lib/components/slippage-config.js +316 -0
  56. package/lib/history/credit/transactions-list.js +11 -1
  57. package/lib/history/invoice/list.js +167 -27
  58. package/lib/hooks/dynamic-pricing.d.ts +102 -0
  59. package/lib/hooks/dynamic-pricing.js +390 -0
  60. package/lib/index.d.ts +6 -1
  61. package/lib/index.js +32 -0
  62. package/lib/libs/util.d.ts +42 -5
  63. package/lib/libs/util.js +367 -49
  64. package/lib/locales/en.js +114 -3
  65. package/lib/locales/zh.js +114 -3
  66. package/lib/payment/form/index.d.ts +4 -1
  67. package/lib/payment/form/index.js +476 -20
  68. package/lib/payment/index.d.ts +1 -1
  69. package/lib/payment/index.js +308 -14
  70. package/lib/payment/product-item.d.ts +26 -1
  71. package/lib/payment/product-item.js +270 -35
  72. package/lib/payment/summary-section/promotion-section.d.ts +32 -0
  73. package/lib/payment/summary-section/promotion-section.js +133 -0
  74. package/lib/payment/summary-section/total-section.d.ts +39 -0
  75. package/lib/payment/summary-section/total-section.js +117 -0
  76. package/lib/payment/summary.d.ts +17 -2
  77. package/lib/payment/summary.js +205 -127
  78. package/lib/types/index.d.ts +11 -0
  79. package/package.json +3 -3
  80. package/src/components/auto-topup/modal.tsx +59 -6
  81. package/src/components/auto-topup/product-card.tsx +118 -11
  82. package/src/components/dynamic-pricing-unavailable.tsx +69 -0
  83. package/src/components/loading-amount.tsx +66 -0
  84. package/src/components/price-change-confirm.tsx +136 -0
  85. package/src/components/quote-details-panel.tsx +218 -0
  86. package/src/components/quote-lock-banner.tsx +99 -0
  87. package/src/components/slippage-config.tsx +336 -0
  88. package/src/history/credit/transactions-list.tsx +14 -1
  89. package/src/history/invoice/list.tsx +143 -9
  90. package/src/hooks/dynamic-pricing.ts +617 -0
  91. package/src/index.ts +9 -0
  92. package/src/libs/util.ts +473 -58
  93. package/src/locales/en.tsx +117 -0
  94. package/src/locales/zh.tsx +111 -0
  95. package/src/payment/form/index.tsx +561 -19
  96. package/src/payment/index.tsx +349 -10
  97. package/src/payment/product-item.tsx +451 -37
  98. package/src/payment/summary-section/promotion-section.tsx +172 -0
  99. package/src/payment/summary-section/total-section.tsx +141 -0
  100. package/src/payment/summary.tsx +334 -192
  101. package/src/types/index.ts +15 -0
@@ -0,0 +1,617 @@
1
+ /**
2
+ * useDynamicPricing Hook
3
+ *
4
+ * Extracts and centralizes all dynamic pricing related calculations
5
+ * for the checkout summary component.
6
+ *
7
+ * Final Freeze Architecture: Quotes are created at Submit time only.
8
+ * This hook handles preview-time display calculations.
9
+ */
10
+
11
+ import { useMemo } from 'react';
12
+ import type { TCheckoutSession, TLineItemExpanded, TPaymentCurrency, TPaymentIntent } from '@blocklet/payment-types';
13
+ import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
14
+ import {
15
+ formatDateTime,
16
+ formatDynamicPrice,
17
+ formatExchangeRate,
18
+ formatUsdAmount,
19
+ getUsdAmountFromTokenUnits,
20
+ } from '../libs/util';
21
+
22
+ export interface LiveRateInfo {
23
+ rate?: string;
24
+ provider_id?: string;
25
+ provider_name?: string;
26
+ provider_display?: string; // Human-readable: "CoinGecko" or "CoinGecko (2 sources)"
27
+ base_currency?: string;
28
+ timestamp_ms?: number;
29
+ fetched_at?: number; // When we fetched the data (updates every 30s)
30
+ }
31
+
32
+ export interface LiveQuoteSnapshot {
33
+ id: string;
34
+ quoted_amount: string;
35
+ exchange_rate: string;
36
+ expires_at: number;
37
+ rate_timestamp_ms?: number | null;
38
+ renewed?: boolean;
39
+ }
40
+
41
+ export interface DiscountInfo {
42
+ promotion_code?: string;
43
+ coupon?: string;
44
+ discount_amount?: string;
45
+ coupon_details?: {
46
+ percent_off?: number;
47
+ amount_off?: string;
48
+ currency_id?: string;
49
+ currency_options?: Record<string, { amount_off?: string }>;
50
+ };
51
+ }
52
+
53
+ export interface DynamicPricingOptions {
54
+ items: TLineItemExpanded[];
55
+ currency: TPaymentCurrency;
56
+ liveRate?: LiveRateInfo;
57
+ liveQuoteSnapshot?: LiveQuoteSnapshot;
58
+ checkoutSession?: TCheckoutSession;
59
+ paymentIntent?: TPaymentIntent | null;
60
+ locale?: string;
61
+ isStripePayment?: boolean;
62
+ isSubscription?: boolean;
63
+ slippageConfig?: {
64
+ mode?: 'percent' | 'rate';
65
+ percent?: number | null;
66
+ min_acceptable_rate?: string;
67
+ base_currency?: string;
68
+ };
69
+ trialInDays?: number;
70
+ trialEnd?: number;
71
+ discounts?: DiscountInfo[];
72
+ }
73
+
74
+ export interface RateInfo {
75
+ exchangeRate: string | null;
76
+ baseCurrency: string;
77
+ providerName: string | null;
78
+ providerId: string | null;
79
+ timestampMs: number | null;
80
+ fetchedAt: number | null; // When we fetched (for display)
81
+ }
82
+
83
+ export interface QuoteMeta {
84
+ exchangeRate: string | null;
85
+ baseCurrency: string;
86
+ expiresAt: number | null;
87
+ providerName: string | null;
88
+ providerId: string | null;
89
+ rateTimestampMs: number | null;
90
+ slippagePercent: number | null;
91
+ }
92
+
93
+ /**
94
+ * Extract quote metadata from line items
95
+ */
96
+ function extractQuoteMeta(items: TLineItemExpanded[]): QuoteMeta | null {
97
+ if (!items?.length) {
98
+ return null;
99
+ }
100
+
101
+ let exchangeRate: string | null = null;
102
+ let baseCurrency: string | null = null;
103
+ let expiresAt: number | null = null;
104
+ let providerName: string | null = null;
105
+ let providerId: string | null = null;
106
+ let rateTimestampMs: number | null = null;
107
+ let slippagePercent: number | null = null;
108
+
109
+ items.forEach((item) => {
110
+ const price = item.upsell_price || item.price;
111
+ const rate = (item as any)?.exchange_rate;
112
+ if (!exchangeRate && rate) {
113
+ exchangeRate = rate;
114
+ }
115
+ const base = (price as any)?.base_currency;
116
+ if (!baseCurrency && base) {
117
+ baseCurrency = base;
118
+ }
119
+ const expires = (item as any)?.expires_at;
120
+ if (expires) {
121
+ expiresAt = expiresAt === null ? expires : Math.min(expiresAt, expires);
122
+ }
123
+ const itemProviderName = (item as any)?.rate_provider_name;
124
+ if (!providerName && itemProviderName) {
125
+ providerName = itemProviderName;
126
+ }
127
+ const itemProviderId = (item as any)?.rate_provider_id;
128
+ if (!providerId && itemProviderId) {
129
+ providerId = itemProviderId;
130
+ }
131
+ const itemRateTimestamp = (item as any)?.rate_timestamp_ms;
132
+ if (!rateTimestampMs && Number.isFinite(Number(itemRateTimestamp))) {
133
+ rateTimestampMs = Number(itemRateTimestamp);
134
+ }
135
+ const itemSlippage = (item as any)?.slippage_percent;
136
+ if (slippagePercent === null && Number.isFinite(Number(itemSlippage))) {
137
+ slippagePercent = Number(itemSlippage);
138
+ }
139
+ });
140
+
141
+ return {
142
+ exchangeRate,
143
+ baseCurrency: baseCurrency || 'USD',
144
+ expiresAt,
145
+ providerName,
146
+ providerId,
147
+ rateTimestampMs,
148
+ slippagePercent,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Calculate token amount from USD base amount and exchange rate
154
+ * @param items - Line items to calculate
155
+ * @param rate - Exchange rate
156
+ * @param currencyDecimal - Currency decimal places
157
+ * @param trialing - Whether currently in trial period (skips recurring items if true)
158
+ */
159
+ function calculateTokenAmount(
160
+ items: TLineItemExpanded[],
161
+ rate: string,
162
+ currencyDecimal: number,
163
+ trialing: boolean = false
164
+ ): string | null {
165
+ // Get USD base amount from items
166
+ const usdTotal = items.reduce((acc, item) => {
167
+ const price = item.upsell_price || item.price;
168
+ // Skip recurring items when in trial period (they don't need payment during trial)
169
+ if (trialing && price?.type === 'recurring') {
170
+ return acc;
171
+ }
172
+ // Skip metered items (pay-as-you-go, no upfront payment)
173
+ if (price?.type === 'recurring' && price?.recurring?.usage_type === 'metered') {
174
+ return acc;
175
+ }
176
+ const baseAmount = (price as any)?.base_amount;
177
+ if (baseAmount) {
178
+ const quantity = item.quantity || 1;
179
+ return acc + Number(baseAmount) * quantity;
180
+ }
181
+ return acc;
182
+ }, 0);
183
+
184
+ if (usdTotal <= 0) {
185
+ return null;
186
+ }
187
+
188
+ const rateNum = Number(rate);
189
+ if (rateNum <= 0) {
190
+ return null;
191
+ }
192
+
193
+ // Calculate token amount: usdTotal / exchangeRate
194
+ const tokenAmount = usdTotal / rateNum;
195
+ const tokenAmountUnit = fromTokenToUnit(tokenAmount.toFixed(currencyDecimal || 8), currencyDecimal || 8);
196
+ return tokenAmountUnit.toString();
197
+ }
198
+
199
+ /**
200
+ * Extract quote lock timestamp from various sources
201
+ */
202
+ function extractQuoteLockedAt(
203
+ paymentIntent?: TPaymentIntent | null,
204
+ checkoutSession?: TCheckoutSession
205
+ ): number | null {
206
+ if (paymentIntent?.quote_locked_at) {
207
+ const lockedAtMs = new Date(paymentIntent.quote_locked_at).getTime();
208
+ if (Number.isFinite(lockedAtMs)) {
209
+ return Math.floor(lockedAtMs / 1000);
210
+ }
211
+ }
212
+ const metaLockedAt = checkoutSession?.metadata?.quote_locked_at;
213
+ if (typeof metaLockedAt === 'number') {
214
+ return metaLockedAt;
215
+ }
216
+ if (typeof metaLockedAt === 'string') {
217
+ const parsed = Number(metaLockedAt);
218
+ if (Number.isFinite(parsed)) {
219
+ return parsed;
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /**
226
+ * Custom hook for dynamic pricing calculations
227
+ */
228
+ export function useDynamicPricing(options: DynamicPricingOptions) {
229
+ const {
230
+ items,
231
+ currency,
232
+ liveRate,
233
+ liveQuoteSnapshot,
234
+ checkoutSession,
235
+ paymentIntent,
236
+ locale = 'en',
237
+ isStripePayment = false,
238
+ isSubscription = checkoutSession?.mode === 'subscription' || checkoutSession?.mode === 'setup',
239
+ slippageConfig,
240
+ trialInDays = 0,
241
+ trialEnd = 0,
242
+ discounts,
243
+ } = options;
244
+
245
+ // Check if currently in trial period
246
+ const currentTime = Math.floor(Date.now() / 1000);
247
+ const trialing = trialInDays > 0 || trialEnd > currentTime;
248
+
249
+ // Check if any items have dynamic pricing
250
+ const hasDynamicPricing = useMemo(
251
+ () => items.some((item) => ((item.upsell_price || item.price) as any)?.pricing_type === 'dynamic'),
252
+ [items]
253
+ );
254
+
255
+ // Extract quote metadata from line items
256
+ const quoteMeta = useMemo(() => (hasDynamicPricing ? extractQuoteMeta(items) : null), [items, hasDynamicPricing]);
257
+
258
+ // Combine live rate with quote metadata
259
+ const rateInfo = useMemo<RateInfo>(
260
+ () => ({
261
+ exchangeRate: isStripePayment ? null : liveRate?.rate || quoteMeta?.exchangeRate || null,
262
+ baseCurrency: liveRate?.base_currency || quoteMeta?.baseCurrency || 'USD',
263
+ providerName: isStripePayment ? null : liveRate?.provider_name || quoteMeta?.providerName || null,
264
+ providerId: isStripePayment ? null : liveRate?.provider_id || quoteMeta?.providerId || null,
265
+ timestampMs: isStripePayment ? null : liveRate?.timestamp_ms || quoteMeta?.rateTimestampMs || null,
266
+ fetchedAt: isStripePayment ? null : liveRate?.fetched_at || null, // When we fetched the data
267
+ }),
268
+ [liveRate, quoteMeta, isStripePayment]
269
+ );
270
+
271
+ // Calculate token amount from live rate (Final Freeze: no quote during preview)
272
+ // When trialing, skip recurring items as they don't need payment during trial
273
+ const calculatedTokenAmount = useMemo(() => {
274
+ if (isStripePayment || !hasDynamicPricing || !liveRate?.rate) {
275
+ return null;
276
+ }
277
+ return calculateTokenAmount(items, liveRate.rate, currency.decimal, trialing);
278
+ }, [hasDynamicPricing, liveRate?.rate, items, currency.decimal, isStripePayment, trialing]);
279
+
280
+ // Calculate discount amount dynamically when rate changes
281
+ // For percent_off: subtotal * percent_off / 100
282
+ // For amount_off: min(amount_off, subtotal) - because discount can't exceed subtotal
283
+ const calculatedDiscountAmount = useMemo(() => {
284
+ if (!discounts?.length) {
285
+ return null;
286
+ }
287
+
288
+ const couponDetails = discounts[0]?.coupon_details;
289
+ if (!couponDetails) {
290
+ return null;
291
+ }
292
+
293
+ // Stripe payment: calculate discount from USD amounts
294
+ if (isStripePayment) {
295
+ // Calculate discountable subtotal in cents (USD smallest unit)
296
+ const discountableSubtotalCents = items.reduce((sum, item) => {
297
+ // Only include discountable items
298
+ if (!(item as any).discountable) {
299
+ return sum;
300
+ }
301
+
302
+ const price = item.upsell_price || item.price;
303
+
304
+ // Skip recurring items during trial
305
+ if (trialing && price?.type === 'recurring') {
306
+ return sum;
307
+ }
308
+
309
+ // Skip metered items
310
+ if (price?.type === 'recurring' && price?.recurring?.usage_type === 'metered') {
311
+ return sum;
312
+ }
313
+
314
+ // For dynamic pricing, use base_amount (USD dollars), not unit_amount (token wei)
315
+ const isDynamic = (price as any)?.pricing_type === 'dynamic';
316
+ const baseAmount = (price as any)?.base_amount;
317
+
318
+ let amountCents: number;
319
+ if (isDynamic && baseAmount !== undefined && baseAmount !== null) {
320
+ // base_amount is in dollars, convert to cents
321
+ amountCents = Number(baseAmount) * 100;
322
+ } else {
323
+ // For non-dynamic pricing, unit_amount is in cents
324
+ const unitAmount = price?.unit_amount;
325
+ if (!unitAmount) {
326
+ return sum;
327
+ }
328
+ amountCents = Number(unitAmount);
329
+ }
330
+
331
+ const quantity = item.quantity || 1;
332
+ return sum + amountCents * quantity;
333
+ }, 0);
334
+
335
+ if (discountableSubtotalCents <= 0) {
336
+ return null;
337
+ }
338
+
339
+ // Percent off discount: subtotal * percent_off / 100
340
+ if (couponDetails.percent_off && couponDetails.percent_off > 0) {
341
+ const discountCents = Math.round((discountableSubtotalCents * couponDetails.percent_off) / 100);
342
+ return discountCents.toString();
343
+ }
344
+
345
+ // Fixed amount discount: min(amount_off, subtotal)
346
+ if (couponDetails.amount_off) {
347
+ // For Stripe, use USD amount_off directly (should be in cents)
348
+ const amountOffCents = Number(couponDetails.amount_off);
349
+ if (Number.isFinite(amountOffCents) && amountOffCents > 0) {
350
+ return Math.min(amountOffCents, discountableSubtotalCents).toString();
351
+ }
352
+ }
353
+
354
+ return null;
355
+ }
356
+
357
+ // Dynamic pricing: calculate discount from token amounts
358
+ if (!hasDynamicPricing || !liveRate?.rate) {
359
+ return null;
360
+ }
361
+
362
+ const rateNum = Number(liveRate.rate);
363
+ if (rateNum <= 0) {
364
+ return null;
365
+ }
366
+
367
+ // Calculate discountable subtotal in tokens (shared by both percent_off and amount_off)
368
+ const discountableSubtotal = items.reduce((sum, item) => {
369
+ // Only include discountable items
370
+ if (!(item as any).discountable) {
371
+ return sum;
372
+ }
373
+
374
+ const price = item.upsell_price || item.price;
375
+
376
+ // Skip recurring items during trial
377
+ if (trialing && price?.type === 'recurring') {
378
+ return sum;
379
+ }
380
+
381
+ // Skip metered items
382
+ if (price?.type === 'recurring' && price?.recurring?.usage_type === 'metered') {
383
+ return sum;
384
+ }
385
+
386
+ const baseAmount = (price as any)?.base_amount;
387
+ if (!baseAmount) {
388
+ return sum;
389
+ }
390
+
391
+ const quantity = item.quantity || 1;
392
+ const tokenAmount = (Number(baseAmount) * quantity) / rateNum;
393
+ return sum + tokenAmount;
394
+ }, 0);
395
+
396
+ if (discountableSubtotal <= 0) {
397
+ return null;
398
+ }
399
+
400
+ // Percent off discount: subtotal * percent_off / 100
401
+ if (couponDetails.percent_off && couponDetails.percent_off > 0) {
402
+ const discountAmount = (discountableSubtotal * couponDetails.percent_off) / 100;
403
+ const discountAmountUnit = fromTokenToUnit(discountAmount.toFixed(currency.decimal || 8), currency.decimal || 8);
404
+ return discountAmountUnit.toString();
405
+ }
406
+
407
+ // Fixed amount discount: min(amount_off, subtotal)
408
+ // When subtotal changes due to rate change, discount may also change
409
+ if (couponDetails.amount_off) {
410
+ // Get amount_off for current currency
411
+ const amountOff =
412
+ couponDetails.currency_id === currency.id
413
+ ? couponDetails.amount_off
414
+ : couponDetails.currency_options?.[currency.id]?.amount_off;
415
+
416
+ if (!amountOff) {
417
+ return null;
418
+ }
419
+
420
+ // Convert subtotal to unit for comparison
421
+ const subtotalUnit = fromTokenToUnit(discountableSubtotal.toFixed(currency.decimal || 8), currency.decimal || 8);
422
+ const amountOffBN = new BN(amountOff);
423
+ const subtotalBN = new BN(subtotalUnit);
424
+
425
+ // Return min(amount_off, subtotal)
426
+ return BN.min(amountOffBN, subtotalBN).toString();
427
+ }
428
+
429
+ return null;
430
+ }, [isStripePayment, hasDynamicPricing, liveRate?.rate, discounts, items, trialing, currency.decimal, currency.id]);
431
+
432
+ // Format rate for display
433
+ const rateDisplay = useMemo(() => {
434
+ if (!rateInfo.exchangeRate) {
435
+ return null;
436
+ }
437
+ const formattedRate = formatExchangeRate(rateInfo.exchangeRate);
438
+ if (!formattedRate) {
439
+ return null;
440
+ }
441
+ if (rateInfo.baseCurrency === 'USD') {
442
+ return `$${formattedRate}`;
443
+ }
444
+ return `${formattedRate} ${rateInfo.baseCurrency}`;
445
+ }, [rateInfo]);
446
+
447
+ // Quote lock status
448
+ const quoteLockedAt = useMemo(
449
+ () => extractQuoteLockedAt(paymentIntent, checkoutSession),
450
+ [paymentIntent, checkoutSession]
451
+ );
452
+
453
+ const isPriceLocked = useMemo(() => {
454
+ if (!quoteLockedAt) return false;
455
+ const now = Math.floor(Date.now() / 1000);
456
+ const lockRemaining = quoteLockedAt + 180 - now;
457
+ return lockRemaining > 0;
458
+ }, [quoteLockedAt]);
459
+
460
+ const lockExpired = useMemo(() => {
461
+ if (!quoteLockedAt) return false;
462
+ const now = Math.floor(Date.now() / 1000);
463
+ const lockRemaining = quoteLockedAt + 180 - now;
464
+ return lockRemaining <= 0;
465
+ }, [quoteLockedAt]);
466
+
467
+ // Current slippage value
468
+ const currentSlippagePercent = useMemo(() => {
469
+ let slippageValue: number | null | undefined = quoteMeta?.slippagePercent;
470
+ if (slippageValue === null || slippageValue === undefined) {
471
+ slippageValue =
472
+ slippageConfig?.percent ??
473
+ (checkoutSession as any)?.metadata?.slippage?.percent ??
474
+ (checkoutSession as any)?.slippage_percent;
475
+ }
476
+ if (slippageValue === null || slippageValue === undefined) {
477
+ slippageValue = 0.5; // default
478
+ }
479
+ return slippageValue as number;
480
+ }, [quoteMeta?.slippagePercent, slippageConfig?.percent, checkoutSession]);
481
+
482
+ // Provider display string - use provider_display from backend for human-readable format
483
+ const providerDisplay = useMemo(() => {
484
+ const fallback = '—';
485
+ // Prefer provider_display from liveRate (e.g., "CoinGecko (2 sources)")
486
+ if (liveRate?.provider_display) {
487
+ return liveRate.provider_display;
488
+ }
489
+ // Fallback to provider name/id
490
+ if (!rateInfo.providerName && !rateInfo.providerId) {
491
+ return fallback;
492
+ }
493
+ return rateInfo.providerName || rateInfo.providerId || fallback;
494
+ }, [liveRate?.provider_display, rateInfo.providerName, rateInfo.providerId]);
495
+
496
+ // Format total amount for display
497
+ const formatTotalDisplay = (totalAmountValue: string, fallback = '—') => {
498
+ // Final Freeze: Use calculated token amount from live rate
499
+ if (hasDynamicPricing && calculatedTokenAmount) {
500
+ const displayAmount = fromUnitToToken(calculatedTokenAmount, currency.decimal);
501
+ const numericValue = Number(displayAmount);
502
+ if (Number.isFinite(numericValue)) {
503
+ return formatDynamicPrice(displayAmount, true, 6);
504
+ }
505
+ }
506
+ // Fallback to liveQuoteSnapshot if available (for backward compatibility)
507
+ if (hasDynamicPricing && liveQuoteSnapshot?.quoted_amount) {
508
+ const snapshotAmount = fromUnitToToken(liveQuoteSnapshot.quoted_amount, currency.decimal);
509
+ const numericValue = Number(snapshotAmount);
510
+ if (Number.isFinite(numericValue)) {
511
+ return formatDynamicPrice(snapshotAmount, true, 6);
512
+ }
513
+ }
514
+ if (totalAmountValue === null || totalAmountValue === undefined || totalAmountValue === '') {
515
+ return fallback;
516
+ }
517
+ const numericValue = Number(totalAmountValue);
518
+ if (!Number.isFinite(numericValue)) {
519
+ return fallback;
520
+ }
521
+ return formatDynamicPrice(totalAmountValue, hasDynamicPricing, 6);
522
+ };
523
+
524
+ // Calculate USD equivalent display
525
+ const calculateUsdDisplay = (totalAmountValue: string) => {
526
+ const fallback = '—';
527
+ if (!hasDynamicPricing || !totalAmountValue || totalAmountValue === fallback) {
528
+ return null;
529
+ }
530
+ const numericValue = Number(totalAmountValue);
531
+ if (!Number.isFinite(numericValue) || numericValue < 0) {
532
+ return null;
533
+ }
534
+ // For zero amount (e.g., free trial), display $0
535
+ if (numericValue === 0) {
536
+ return formatUsdAmount('0', locale);
537
+ }
538
+ const { exchangeRate } = rateInfo;
539
+ if (!exchangeRate) {
540
+ return null;
541
+ }
542
+ const totalAmountInUnits = fromTokenToUnit(totalAmountValue, currency.decimal);
543
+ const totalUsd = getUsdAmountFromTokenUnits(new BN(totalAmountInUnits), currency.decimal, exchangeRate);
544
+ if (!totalUsd) {
545
+ return null;
546
+ }
547
+ return formatUsdAmount(totalUsd, locale);
548
+ };
549
+
550
+ // Build quote detail rows for display
551
+ const buildQuoteDetailRows = (t: (key: string) => string) => {
552
+ if (!hasDynamicPricing) {
553
+ return [];
554
+ }
555
+
556
+ const rows: Array<{ label: string; value: string | React.ReactNode; isSlippage?: boolean; tooltip?: string }> = [];
557
+ const fallback = '—';
558
+
559
+ // Only show provider and updatedAt if we have rate info
560
+ if (rateDisplay) {
561
+ // Use fetched_at (when we fetched) instead of timestamp_ms (provider's data timestamp)
562
+ // This shows users that we're actively fetching, even if provider data hasn't changed
563
+ const displayTimestamp = rateInfo.fetchedAt || rateInfo.timestampMs;
564
+ const updatedAt = displayTimestamp ? formatDateTime(displayTimestamp) : fallback;
565
+
566
+ rows.push(
567
+ { label: t('payment.checkout.quote.detailProvider'), value: providerDisplay },
568
+ { label: t('payment.checkout.quote.detailUpdatedAt'), value: updatedAt }
569
+ );
570
+ }
571
+
572
+ // Always show slippage for subscriptions with dynamic pricing (even metered without current rate)
573
+ if (isSubscription) {
574
+ let slippageDisplay = `${currentSlippagePercent}%`;
575
+ if (slippageConfig?.mode === 'rate' && slippageConfig.min_acceptable_rate) {
576
+ const formattedRate = formatExchangeRate(slippageConfig.min_acceptable_rate);
577
+ const displayRate = formattedRate || slippageConfig.min_acceptable_rate;
578
+ const displayCurrency = slippageConfig.base_currency || rateInfo.baseCurrency || 'USD';
579
+ const displayText = displayCurrency === 'USD' ? `$${displayRate}` : `${displayRate} ${displayCurrency}`;
580
+ slippageDisplay = `${displayText}`;
581
+ }
582
+
583
+ rows.push({
584
+ label: t('payment.checkout.quote.detailSlippage'),
585
+ value: slippageDisplay,
586
+ isSlippage: true,
587
+ tooltip: t('payment.checkout.quote.slippage.tooltip'),
588
+ });
589
+ }
590
+
591
+ return rows;
592
+ };
593
+
594
+ return {
595
+ // Flags
596
+ hasDynamicPricing,
597
+ isPriceLocked,
598
+ lockExpired,
599
+
600
+ // Data
601
+ quoteMeta,
602
+ rateInfo,
603
+ quoteLockedAt,
604
+ calculatedTokenAmount,
605
+ calculatedDiscountAmount,
606
+ currentSlippagePercent,
607
+
608
+ // Display helpers
609
+ rateDisplay,
610
+ providerDisplay,
611
+ formatTotalDisplay,
612
+ calculateUsdDisplay,
613
+ buildQuoteDetailRows,
614
+ };
615
+ }
616
+
617
+ export default useDynamicPricing;
package/src/index.ts CHANGED
@@ -26,6 +26,8 @@ import StripeForm from './payment/form/stripe';
26
26
  import Payment from './payment/index';
27
27
  import ProductSkeleton from './payment/product-skeleton';
28
28
  import PaymentSummary from './payment/summary';
29
+ import PromotionSection from './payment/summary-section/promotion-section';
30
+ import TotalSection from './payment/summary-section/total-section';
29
31
  import PricingItem from './components/pricing-item';
30
32
  import CountrySelect from './components/country-select';
31
33
  import TruncatedText from './components/truncated-text';
@@ -42,6 +44,8 @@ import AutoTopup from './components/auto-topup';
42
44
  import Collapse from './components/collapse';
43
45
  import PromotionCode from './components/promotion-code';
44
46
  import SourceDataViewer from './components/source-data-viewer';
47
+ import SlippageConfig from './components/slippage-config';
48
+ import DynamicPricingUnavailable from './components/dynamic-pricing-unavailable';
45
49
 
46
50
  export { PaymentThemeProvider } from './theme';
47
51
 
@@ -108,7 +112,12 @@ export {
108
112
  Collapse,
109
113
  PromotionCode,
110
114
  SourceDataViewer,
115
+ SlippageConfig,
116
+ DynamicPricingUnavailable,
117
+ PromotionSection,
118
+ TotalSection,
111
119
  };
112
120
 
113
121
  export type { CountrySelectProps } from './components/country-select';
114
122
  export type { StripePaymentActionProps } from './components/stripe-payment-action';
123
+ export type { SlippageConfigValue, SlippageConfigProps } from './components/slippage-config';