@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.
- package/es/components/auto-topup/modal.d.ts +2 -0
- package/es/components/auto-topup/modal.js +48 -6
- package/es/components/auto-topup/product-card.d.ts +16 -1
- package/es/components/auto-topup/product-card.js +97 -15
- package/es/components/dynamic-pricing-unavailable.d.ts +9 -0
- package/es/components/dynamic-pricing-unavailable.js +58 -0
- package/es/components/loading-amount.d.ts +17 -0
- package/es/components/loading-amount.js +46 -0
- package/es/components/price-change-confirm.d.ts +18 -0
- package/es/components/price-change-confirm.js +107 -0
- package/es/components/quote-details-panel.d.ts +21 -0
- package/es/components/quote-details-panel.js +170 -0
- package/es/components/quote-lock-banner.d.ts +7 -0
- package/es/components/quote-lock-banner.js +79 -0
- package/es/components/slippage-config.d.ts +20 -0
- package/es/components/slippage-config.js +261 -0
- package/es/history/credit/transactions-list.js +11 -1
- package/es/history/invoice/list.js +125 -15
- package/es/hooks/dynamic-pricing.d.ts +102 -0
- package/es/hooks/dynamic-pricing.js +393 -0
- package/es/index.d.ts +6 -1
- package/es/index.js +9 -1
- package/es/libs/util.d.ts +42 -5
- package/es/libs/util.js +345 -57
- package/es/locales/en.js +114 -3
- package/es/locales/zh.js +114 -3
- package/es/payment/form/index.d.ts +4 -1
- package/es/payment/form/index.js +454 -22
- package/es/payment/index.d.ts +1 -1
- package/es/payment/index.js +279 -16
- package/es/payment/product-item.d.ts +26 -1
- package/es/payment/product-item.js +330 -51
- package/es/payment/summary-section/promotion-section.d.ts +32 -0
- package/es/payment/summary-section/promotion-section.js +143 -0
- package/es/payment/summary-section/total-section.d.ts +39 -0
- package/es/payment/summary-section/total-section.js +83 -0
- package/es/payment/summary.d.ts +17 -2
- package/es/payment/summary.js +300 -253
- package/es/types/index.d.ts +11 -0
- package/lib/components/auto-topup/modal.d.ts +2 -0
- package/lib/components/auto-topup/modal.js +54 -6
- package/lib/components/auto-topup/product-card.d.ts +16 -1
- package/lib/components/auto-topup/product-card.js +75 -7
- package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
- package/lib/components/dynamic-pricing-unavailable.js +81 -0
- package/lib/components/loading-amount.d.ts +17 -0
- package/lib/components/loading-amount.js +53 -0
- package/lib/components/price-change-confirm.d.ts +18 -0
- package/lib/components/price-change-confirm.js +157 -0
- package/lib/components/quote-details-panel.d.ts +21 -0
- package/lib/components/quote-details-panel.js +226 -0
- package/lib/components/quote-lock-banner.d.ts +7 -0
- package/lib/components/quote-lock-banner.js +93 -0
- package/lib/components/slippage-config.d.ts +20 -0
- package/lib/components/slippage-config.js +316 -0
- package/lib/history/credit/transactions-list.js +11 -1
- package/lib/history/invoice/list.js +167 -27
- package/lib/hooks/dynamic-pricing.d.ts +102 -0
- package/lib/hooks/dynamic-pricing.js +390 -0
- package/lib/index.d.ts +6 -1
- package/lib/index.js +32 -0
- package/lib/libs/util.d.ts +42 -5
- package/lib/libs/util.js +367 -49
- package/lib/locales/en.js +114 -3
- package/lib/locales/zh.js +114 -3
- package/lib/payment/form/index.d.ts +4 -1
- package/lib/payment/form/index.js +476 -20
- package/lib/payment/index.d.ts +1 -1
- package/lib/payment/index.js +308 -14
- package/lib/payment/product-item.d.ts +26 -1
- package/lib/payment/product-item.js +270 -35
- package/lib/payment/summary-section/promotion-section.d.ts +32 -0
- package/lib/payment/summary-section/promotion-section.js +133 -0
- package/lib/payment/summary-section/total-section.d.ts +39 -0
- package/lib/payment/summary-section/total-section.js +117 -0
- package/lib/payment/summary.d.ts +17 -2
- package/lib/payment/summary.js +205 -127
- package/lib/types/index.d.ts +11 -0
- package/package.json +3 -3
- package/src/components/auto-topup/modal.tsx +59 -6
- package/src/components/auto-topup/product-card.tsx +118 -11
- package/src/components/dynamic-pricing-unavailable.tsx +69 -0
- package/src/components/loading-amount.tsx +66 -0
- package/src/components/price-change-confirm.tsx +136 -0
- package/src/components/quote-details-panel.tsx +218 -0
- package/src/components/quote-lock-banner.tsx +99 -0
- package/src/components/slippage-config.tsx +336 -0
- package/src/history/credit/transactions-list.tsx +14 -1
- package/src/history/invoice/list.tsx +143 -9
- package/src/hooks/dynamic-pricing.ts +617 -0
- package/src/index.ts +9 -0
- package/src/libs/util.ts +473 -58
- package/src/locales/en.tsx +117 -0
- package/src/locales/zh.tsx +111 -0
- package/src/payment/form/index.tsx +561 -19
- package/src/payment/index.tsx +349 -10
- package/src/payment/product-item.tsx +451 -37
- package/src/payment/summary-section/promotion-section.tsx +172 -0
- package/src/payment/summary-section/total-section.tsx +141 -0
- package/src/payment/summary.tsx +334 -192
- 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';
|