@blocklet/payment-react-headless 1.26.1 → 1.26.3

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 (75) hide show
  1. package/es/checkout/context/CheckoutProvider.js +26 -10
  2. package/es/checkout/context/SessionContext.d.ts +3 -1
  3. package/es/checkout/core/crossSell.d.ts +3 -2
  4. package/es/checkout/core/crossSell.js +24 -8
  5. package/es/checkout/core/customerForm.js +0 -6
  6. package/es/checkout/core/lineItems.d.ts +6 -5
  7. package/es/checkout/core/lineItems.js +48 -17
  8. package/es/checkout/core/paymentMethod.js +15 -7
  9. package/es/checkout/core/pricing.js +1 -2
  10. package/es/checkout/core/promotion.d.ts +1 -1
  11. package/es/checkout/core/promotion.js +2 -1
  12. package/es/checkout/hooks/useBillingInterval.js +8 -5
  13. package/es/checkout/hooks/useCheckout.js +17 -11
  14. package/es/checkout/hooks/useCheckoutSession.d.ts +2 -2
  15. package/es/checkout/hooks/useCheckoutSession.js +7 -0
  16. package/es/checkout/hooks/useCheckoutStatus.d.ts +1 -1
  17. package/es/checkout/hooks/useCrossSell.js +11 -3
  18. package/es/checkout/hooks/useCustomerForm.d.ts +4 -0
  19. package/es/checkout/hooks/useCustomerForm.js +6 -0
  20. package/es/checkout/hooks/useLineItems.js +36 -9
  21. package/es/checkout/hooks/usePaymentMethod.d.ts +1 -1
  22. package/es/checkout/hooks/usePaymentMethod.js +8 -4
  23. package/es/checkout/hooks/usePricing.d.ts +1 -1
  24. package/es/checkout/hooks/usePricing.js +4 -4
  25. package/es/checkout/hooks/useSubmit.js +6 -1
  26. package/es/checkout/hooks/useUpsell.js +3 -3
  27. package/es/checkout/types.d.ts +5 -3
  28. package/lib/checkout/context/CheckoutProvider.js +21 -12
  29. package/lib/checkout/context/SessionContext.d.ts +3 -1
  30. package/lib/checkout/core/crossSell.d.ts +3 -2
  31. package/lib/checkout/core/crossSell.js +36 -8
  32. package/lib/checkout/core/customerForm.js +0 -5
  33. package/lib/checkout/core/lineItems.d.ts +6 -5
  34. package/lib/checkout/core/lineItems.js +72 -18
  35. package/lib/checkout/core/paymentMethod.js +14 -8
  36. package/lib/checkout/core/pricing.js +1 -2
  37. package/lib/checkout/core/promotion.d.ts +1 -1
  38. package/lib/checkout/core/promotion.js +4 -1
  39. package/lib/checkout/hooks/useBillingInterval.js +10 -5
  40. package/lib/checkout/hooks/useCheckout.js +16 -11
  41. package/lib/checkout/hooks/useCheckoutSession.d.ts +2 -2
  42. package/lib/checkout/hooks/useCheckoutSession.js +7 -0
  43. package/lib/checkout/hooks/useCheckoutStatus.d.ts +1 -1
  44. package/lib/checkout/hooks/useCrossSell.js +5 -3
  45. package/lib/checkout/hooks/useCustomerForm.d.ts +4 -0
  46. package/lib/checkout/hooks/useCustomerForm.js +6 -0
  47. package/lib/checkout/hooks/useLineItems.js +10 -8
  48. package/lib/checkout/hooks/usePaymentMethod.d.ts +1 -1
  49. package/lib/checkout/hooks/usePaymentMethod.js +14 -4
  50. package/lib/checkout/hooks/usePricing.d.ts +1 -1
  51. package/lib/checkout/hooks/usePricing.js +4 -4
  52. package/lib/checkout/hooks/useSubmit.js +8 -1
  53. package/lib/checkout/hooks/useUpsell.js +5 -3
  54. package/lib/checkout/types.d.ts +5 -3
  55. package/package.json +3 -3
  56. package/src/checkout/context/CheckoutProvider.tsx +38 -17
  57. package/src/checkout/context/SessionContext.ts +3 -1
  58. package/src/checkout/core/crossSell.ts +29 -8
  59. package/src/checkout/core/customerForm.ts +0 -6
  60. package/src/checkout/core/lineItems.ts +62 -18
  61. package/src/checkout/core/paymentMethod.ts +24 -7
  62. package/src/checkout/core/pricing.ts +1 -2
  63. package/src/checkout/core/promotion.ts +6 -2
  64. package/src/checkout/hooks/useBillingInterval.ts +8 -5
  65. package/src/checkout/hooks/useCheckout.ts +20 -12
  66. package/src/checkout/hooks/useCheckoutSession.ts +12 -3
  67. package/src/checkout/hooks/useCheckoutStatus.ts +1 -1
  68. package/src/checkout/hooks/useCrossSell.ts +11 -3
  69. package/src/checkout/hooks/useCustomerForm.ts +12 -0
  70. package/src/checkout/hooks/useLineItems.ts +42 -9
  71. package/src/checkout/hooks/usePaymentMethod.ts +13 -5
  72. package/src/checkout/hooks/usePricing.ts +5 -4
  73. package/src/checkout/hooks/useSubmit.ts +13 -1
  74. package/src/checkout/hooks/useUpsell.ts +3 -3
  75. package/src/checkout/types.ts +4 -2
@@ -38,12 +38,13 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
38
38
 
39
39
  const session = sessionData?.checkoutSession;
40
40
  const effectiveSessionId = resolvedSessionId || sessionId;
41
- const items = (session?.line_items || []) as TLineItemExpanded[];
41
+ const items = useMemo(() => (session?.line_items || []) as TLineItemExpanded[], [session?.line_items]);
42
42
  const isDonation = session?.submit_type === 'donate';
43
43
 
44
44
  const sessionValue = useMemo<SessionContextValue>(
45
45
  () => ({
46
46
  sessionData,
47
+ setSessionData,
47
48
  sessionId,
48
49
  effectiveSessionId,
49
50
  isLoading,
@@ -60,6 +61,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
60
61
  }),
61
62
  [
62
63
  sessionData,
64
+ setSessionData,
63
65
  sessionId,
64
66
  effectiveSessionId,
65
67
  isLoading,
@@ -77,16 +79,22 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
77
79
  );
78
80
 
79
81
  // 2. Payment method layer
80
- const paymentMethodHook = usePaymentMethodHook(sessionData, effectiveSessionId, refresh);
82
+ const paymentMethodHook = usePaymentMethodHook(sessionData, effectiveSessionId, refresh, setSessionData);
81
83
 
82
- // Recalculate promotion when currency changes
84
+ // Recalculate promotion when currency changes or on initial mount (if discounts exist).
85
+ // Uses refresh() (fresh GET) instead of POST response to avoid stale-closure overwrites
86
+ // when switch-currency and recalculate-promotion race. Matches V1 pattern.
83
87
  const prevCurrencyRef = useRef<string | null>(null);
84
88
  useEffect(() => {
85
89
  const currId = paymentMethodHook.currency?.id || null;
86
90
  if (!currId || !session || session.status === 'complete') return;
87
91
  if (prevCurrencyRef.current === null || currId !== prevCurrencyRef.current) {
88
92
  prevCurrencyRef.current = currId;
89
- recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then(() => refresh(true));
93
+ recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then((recalculated) => {
94
+ if (recalculated) {
95
+ refresh(true);
96
+ }
97
+ });
90
98
  }
91
99
  }, [paymentMethodHook.currency?.id, session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
92
100
 
@@ -105,6 +113,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
105
113
  setCurrency: paymentMethodHook.setCurrency,
106
114
  stripe: paymentMethodHook.stripe,
107
115
  }),
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
117
  [
109
118
  paymentMethodHook.current,
110
119
  paymentMethodHook.currency,
@@ -134,7 +143,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
134
143
  const hasDynamicPricing = useMemo(() => checkHasDynamicPricing(items), [items]);
135
144
 
136
145
  const fetchRate = useMemoizedFn(async () => {
137
- if (!effectiveSessionId || !hasDynamicPricing || paymentMethodHook.isStripe) {
146
+ if (!effectiveSessionId || !hasDynamicPricing || paymentMethodHook.isStripe || paymentMethodHook.switching) {
138
147
  setRateStatus(hasDynamicPricing ? 'unavailable' : 'available');
139
148
  if (paymentMethodHook.isStripe) {
140
149
  setExchangeRate(null);
@@ -175,7 +184,13 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
175
184
  useEffect(() => {
176
185
  mountedRef.current = true;
177
186
 
178
- if (!hasDynamicPricing || paymentMethodHook.isStripe || !effectiveSessionId || session?.status === 'complete') {
187
+ if (
188
+ !hasDynamicPricing ||
189
+ paymentMethodHook.isStripe ||
190
+ paymentMethodHook.switching ||
191
+ !effectiveSessionId ||
192
+ session?.status === 'complete'
193
+ ) {
179
194
  // Clear stale rate when switching to Stripe
180
195
  if (paymentMethodHook.isStripe) {
181
196
  setExchangeRate(null);
@@ -183,16 +198,14 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
183
198
  setRateProviderDisplay(null);
184
199
  setRateFetchedAt(null);
185
200
  }
186
- // When session is complete and we already have a rate, keep it as 'available'
187
- // (the rate used at payment time is still valid for display).
188
- // Otherwise set appropriate status:
189
- // - non-dynamic 'available' (no rate needed)
190
- // - dynamic but Stripe or no session → 'unavailable'
191
- if (session?.status === 'complete') {
192
- // Completed session: rate is irrelevant, suppress unavailable warnings
193
- setRateStatus('available');
194
- } else {
195
- setRateStatus(hasDynamicPricing ? 'unavailable' : 'available');
201
+ // Don't change rate status during currency switch keep current rate visible
202
+ // to avoid triggering "unavailable" toast while switch is in progress.
203
+ if (!paymentMethodHook.switching) {
204
+ if (session?.status === 'complete') {
205
+ setRateStatus('available');
206
+ } else {
207
+ setRateStatus(hasDynamicPricing ? 'unavailable' : 'available');
208
+ }
196
209
  }
197
210
  return undefined;
198
211
  }
@@ -219,7 +232,15 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
219
232
  if (intervalRef.current) clearInterval(intervalRef.current);
220
233
  document.removeEventListener('visibilitychange', handleVisibility);
221
234
  };
222
- }, [hasDynamicPricing, paymentMethodHook.isStripe, effectiveSessionId, paymentMethodHook.currency?.id, session?.status]); // eslint-disable-line react-hooks/exhaustive-deps
235
+ // eslint-disable-next-line react-hooks/exhaustive-deps
236
+ }, [
237
+ hasDynamicPricing,
238
+ paymentMethodHook.isStripe,
239
+ paymentMethodHook.switching,
240
+ effectiveSessionId,
241
+ paymentMethodHook.currency?.id,
242
+ session?.status,
243
+ ]);
223
244
 
224
245
  const exchangeRateValue = useMemo<ExchangeRateContextValue>(
225
246
  () => ({
@@ -4,11 +4,13 @@ import type { SessionData } from '../hooks/useCheckoutSession';
4
4
 
5
5
  export interface SessionContextValue {
6
6
  sessionData: SessionData | null;
7
+ /** Directly replace session data (e.g. using PUT response to skip redundant GET) */
8
+ setSessionData: (data: SessionData) => void;
7
9
  sessionId: string;
8
10
  effectiveSessionId: string;
9
11
  isLoading: boolean;
10
12
  error: string | null;
11
- errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
13
+ errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
12
14
  refresh: (forceRefresh?: boolean) => Promise<void>;
13
15
  items: TLineItemExpanded[];
14
16
  session: TCheckoutSessionExpanded | null | undefined;
@@ -2,28 +2,49 @@ import type { TCheckoutSessionExpanded, TPrice } from '@blocklet/payment-types';
2
2
 
3
3
  import api, { API } from '../../shared/api';
4
4
  import { recalculatePromotionIfNeeded } from './lineItems';
5
+ import type { SessionData } from '../hooks/useCheckoutSession';
5
6
 
6
7
  export async function addCrossSellItem(
7
8
  sessionId: string,
8
9
  crossSellItemId: string,
9
10
  session: TCheckoutSessionExpanded | undefined | null,
10
11
  currencyId: string | null | undefined,
11
- refresh: (force?: boolean) => Promise<void>
12
+ refresh: (force?: boolean) => Promise<void>,
13
+ sessionData?: SessionData | null,
14
+ setSessionData?: (data: SessionData) => void
12
15
  ): Promise<void> {
13
- await api.put(API.CROSS_SELL(sessionId), { to: crossSellItemId });
14
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
15
- await refresh(true);
16
+ const { data } = await api.put(API.CROSS_SELL(sessionId), { to: crossSellItemId });
17
+ let finalSession = data;
18
+ if (data.discounts?.length) {
19
+ const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
20
+ if (recalculated) finalSession = recalculated;
21
+ }
22
+ if (sessionData && setSessionData) {
23
+ setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
24
+ } else {
25
+ await refresh(true);
26
+ }
16
27
  }
17
28
 
18
29
  export async function removeCrossSellItem(
19
30
  sessionId: string,
20
31
  session: TCheckoutSessionExpanded | undefined | null,
21
32
  currencyId: string | null | undefined,
22
- refresh: (force?: boolean) => Promise<void>
33
+ refresh: (force?: boolean) => Promise<void>,
34
+ sessionData?: SessionData | null,
35
+ setSessionData?: (data: SessionData) => void
23
36
  ): Promise<void> {
24
- await api.delete(API.CROSS_SELL(sessionId));
25
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
26
- await refresh(true);
37
+ const { data } = await api.delete(API.CROSS_SELL(sessionId));
38
+ let finalSession = data;
39
+ if (data.discounts?.length) {
40
+ const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
41
+ if (recalculated) finalSession = recalculated;
42
+ }
43
+ if (sessionData && setSessionData) {
44
+ setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
45
+ } else {
46
+ await refresh(true);
47
+ }
27
48
  }
28
49
 
29
50
  // Dedup concurrent fetchCrossSellItem calls (multiple useLineItems instances share one in-flight request)
@@ -41,12 +41,6 @@ export function buildFields(session: TCheckoutSessionExpanded | undefined | null
41
41
  required: true,
42
42
  group: 'address',
43
43
  },
44
- {
45
- name: 'billing_address.line2',
46
- type: 'text',
47
- required: false,
48
- group: 'address',
49
- },
50
44
  {
51
45
  name: 'billing_address.city',
52
46
  type: 'text',
@@ -3,17 +3,19 @@ import type { TCheckoutSessionExpanded, TLineItemExpanded, TPrice } from '@block
3
3
  import api, { API } from '../../shared/api';
4
4
  import { recalculatePromotion, hasAppliedDiscounts } from './promotion';
5
5
  import type { PriceWithCrossSell } from '../../types/checkout-augmented';
6
+ import type { SessionData } from '../hooks/useCheckoutSession';
6
7
 
7
8
  export async function recalculatePromotionIfNeeded(
8
9
  session: TCheckoutSessionExpanded | undefined | null,
9
10
  sessionId: string,
10
11
  currencyId: string | null | undefined
11
- ): Promise<void> {
12
- if (!hasAppliedDiscounts(session)) return;
12
+ ): Promise<TCheckoutSessionExpanded | null> {
13
+ if (!hasAppliedDiscounts(session)) return null;
13
14
  try {
14
- await recalculatePromotion(sessionId, currencyId);
15
+ return await recalculatePromotion(sessionId, currencyId);
15
16
  } catch {
16
17
  // Ignore recalculation error
18
+ return null;
17
19
  }
18
20
  }
19
21
 
@@ -23,15 +25,25 @@ export async function adjustQuantity(
23
25
  qty: number,
24
26
  currencyId: string | null | undefined,
25
27
  session: TCheckoutSessionExpanded | undefined | null,
26
- refresh: (force?: boolean) => Promise<void>
28
+ refresh: (force?: boolean) => Promise<void>,
29
+ sessionData?: SessionData | null,
30
+ setSessionData?: (data: SessionData) => void
27
31
  ): Promise<void> {
28
- await api.put(API.ADJUST_QUANTITY(sessionId), {
32
+ const { data } = await api.put(API.ADJUST_QUANTITY(sessionId), {
29
33
  itemId,
30
34
  quantity: qty,
31
35
  currency_id: currencyId,
32
36
  });
33
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
34
- await refresh(true);
37
+ let finalSession = data;
38
+ if (data.discounts?.length || hasAppliedDiscounts(session)) {
39
+ const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
40
+ if (recalculated) finalSession = recalculated;
41
+ }
42
+ if (sessionData && setSessionData) {
43
+ setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
44
+ } else {
45
+ await refresh(true);
46
+ }
35
47
  }
36
48
 
37
49
  export async function performUpsell(
@@ -40,7 +52,9 @@ export async function performUpsell(
40
52
  toId: string,
41
53
  session: TCheckoutSessionExpanded | undefined | null,
42
54
  currencyId: string | null | undefined,
43
- refresh: (force?: boolean) => Promise<void>
55
+ refresh: (force?: boolean) => Promise<void>,
56
+ sessionData?: SessionData | null,
57
+ setSessionData?: (data: SessionData) => void
44
58
  ): Promise<void> {
45
59
  // Backend rejects upsell with multiple line items — auto-remove cross-sell first
46
60
  // Always try DELETE when multiple items exist (cross_sell flag may not be set)
@@ -51,9 +65,19 @@ export async function performUpsell(
51
65
  // No cross-sell to remove — will proceed and let upsell API decide
52
66
  }
53
67
  }
54
- await api.put(API.UPSELL(sessionId), { from: fromId, to: toId });
55
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
56
- await refresh(true);
68
+ const { data } = await api.put(API.UPSELL(sessionId), { from: fromId, to: toId });
69
+
70
+ // Use PUT response directly; if discounts exist, recalculate and use that response
71
+ let finalSession = data;
72
+ if (data.discounts?.length || hasAppliedDiscounts(session)) {
73
+ const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
74
+ if (recalculated) finalSession = recalculated;
75
+ }
76
+ if (sessionData && setSessionData) {
77
+ setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
78
+ } else {
79
+ await refresh(true);
80
+ }
57
81
  }
58
82
 
59
83
  export async function performDownsell(
@@ -61,7 +85,9 @@ export async function performDownsell(
61
85
  priceId: string,
62
86
  session: TCheckoutSessionExpanded | undefined | null,
63
87
  currencyId: string | null | undefined,
64
- refresh: (force?: boolean) => Promise<void>
88
+ refresh: (force?: boolean) => Promise<void>,
89
+ sessionData?: SessionData | null,
90
+ setSessionData?: (data: SessionData) => void
65
91
  ): Promise<void> {
66
92
  // Auto-remove cross-sell to keep interval consistency (cross-sell is interval-aware)
67
93
  if ((session?.line_items?.length || 0) > 1) {
@@ -71,9 +97,19 @@ export async function performDownsell(
71
97
  // No cross-sell to remove — will proceed and let downsell API decide
72
98
  }
73
99
  }
74
- await api.put(API.DOWNSELL(sessionId), { from: priceId });
75
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
76
- await refresh(true);
100
+ const { data } = await api.put(API.DOWNSELL(sessionId), { from: priceId });
101
+
102
+ // Use PUT response directly; if discounts exist, recalculate and use that response
103
+ let finalSession = data;
104
+ if (data.discounts?.length || hasAppliedDiscounts(session)) {
105
+ const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
106
+ if (recalculated) finalSession = recalculated;
107
+ }
108
+ if (sessionData && setSessionData) {
109
+ setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
110
+ } else {
111
+ await refresh(true);
112
+ }
77
113
  }
78
114
 
79
115
  export async function changeDonationAmount(
@@ -82,16 +118,24 @@ export async function changeDonationAmount(
82
118
  amount: string,
83
119
  session: TCheckoutSessionExpanded | undefined | null,
84
120
  currencyId: string | null | undefined,
85
- refresh: (force?: boolean) => Promise<void>
121
+ refresh: (force?: boolean) => Promise<void>,
122
+ sessionData?: SessionData | null,
123
+ setSessionData?: (data: SessionData) => void
86
124
  ): Promise<void> {
87
125
  const { data } = await api.put(API.CHANGE_AMOUNT(sessionId), {
88
126
  priceId,
89
127
  amount,
90
128
  });
129
+ let finalSession = data;
91
130
  if (data?.discounts?.length) {
92
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
131
+ const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
132
+ if (recalculated) finalSession = recalculated;
133
+ }
134
+ if (sessionData && setSessionData) {
135
+ setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
136
+ } else {
137
+ await refresh(true);
93
138
  }
94
- await refresh(true);
95
139
  }
96
140
 
97
141
  // Extract cross-sell price from items
@@ -36,11 +36,22 @@ export function getInitialCurrencyId(
36
36
  session: TCheckoutSessionExpanded | undefined | null,
37
37
  methods: TPaymentMethodExpanded[]
38
38
  ): string | null {
39
+ const availableCurrencyIds = new Set(
40
+ methods.flatMap((m) => (m.payment_currencies || []).map((c) => c.id)).filter(Boolean)
41
+ );
42
+ const isAvailable = (currencyId?: string | null) => !!currencyId && availableCurrencyIds.has(currencyId);
43
+
44
+ // Keep session currency stable when promotion is already applied.
45
+ // Auto-switching on refresh may trigger recalculate-promotion and remove discount.
46
+ if (session?.discounts?.length && isAvailable(session.currency_id)) {
47
+ return session.currency_id;
48
+ }
49
+
39
50
  if (typeof window !== 'undefined') {
40
51
  try {
41
52
  const params = new URLSearchParams(window.location.search);
42
53
  const urlCurrency = params.get('currencyId') || params.get('currency_id');
43
- if (urlCurrency) return urlCurrency;
54
+ if (isAvailable(urlCurrency)) return urlCurrency;
44
55
  } catch {
45
56
  // Ignore
46
57
  }
@@ -49,28 +60,34 @@ export function getInitialCurrencyId(
49
60
  if (user && !hasDidWallet(user)) {
50
61
  const stripeMethod = methods.find((m) => m.type === 'stripe');
51
62
  const stripeCurrency = stripeMethod?.payment_currencies?.[0];
52
- if (stripeCurrency) return stripeCurrency.id;
63
+ if (isAvailable(stripeCurrency?.id)) return stripeCurrency?.id as string;
53
64
  }
54
65
 
55
66
  try {
56
67
  const did = (session as CheckoutSessionRuntime | undefined)?.user?.did;
57
68
  const stored = localStorage.getItem(getCurrencyStorageKey(did));
58
- if (stored) return stored;
69
+ if (isAvailable(stored)) return stored;
59
70
  } catch {
60
71
  // Ignore
61
72
  }
62
73
  }
63
- return session?.currency_id || null;
74
+
75
+ if (isAvailable(session?.currency_id)) {
76
+ return session?.currency_id as string;
77
+ }
78
+
79
+ return methods[0]?.payment_currencies?.[0]?.id || null;
64
80
  }
65
81
 
66
82
  export function findMethodAndCurrency(
67
83
  methods: TPaymentMethodExpanded[],
68
84
  currencyId: string | null
69
85
  ): { method: TPaymentMethodExpanded | null; currency: TPaymentCurrency | null } {
86
+ // Return null when currencyId hasn't been initialized yet.
87
+ // Prevents premature fallback to the first currency (which may differ from session.currency_id),
88
+ // avoiding a wrong-currency recalculate-promotion call that removes fixed-amount discounts.
70
89
  if (!currencyId) {
71
- const first = methods[0];
72
- const firstCurrency = first?.payment_currencies?.[0] || null;
73
- return { method: first || null, currency: firstCurrency };
90
+ return { method: null, currency: null };
74
91
  }
75
92
 
76
93
  for (const method of methods) {
@@ -141,7 +141,6 @@ export function calculateAmounts(
141
141
  });
142
142
 
143
143
  const subtotalBN = new BN(result.total);
144
- const subtotalFormatted = formatDynamicPrice(fromUnitToToken(subtotalBN, currency.decimal), hasDynamicPricing);
145
144
 
146
145
  // Calculate discount client-side from coupon data
147
146
  const discountBN = calculateCouponDiscount(items, currency, session, hasDynamicPricing, exchangeRate, trialing, false);
@@ -216,7 +215,7 @@ export function calculateAmounts(
216
215
 
217
216
  return {
218
217
  subtotal: `${displaySubtotalFormatted} ${currency.symbol}`,
219
- paymentAmount: `${subtotalFormatted} ${currency.symbol}`,
218
+ paymentAmount: `${totalFormatted} ${currency.symbol}`,
220
219
  total: `${totalFormatted} ${currency.symbol}`,
221
220
  discount: discount ? `${discount} ${currency.symbol}` : null,
222
221
  tax,
@@ -43,10 +43,14 @@ export async function removePromotionCode(sessionId: string): Promise<void> {
43
43
  await api.delete(API.REMOVE_PROMOTION(sessionId));
44
44
  }
45
45
 
46
- export async function recalculatePromotion(sessionId: string, currencyId: string | null | undefined): Promise<void> {
47
- await api.post(API.RECALCULATE_PROMOTION_SESSION(sessionId), {
46
+ export async function recalculatePromotion(
47
+ sessionId: string,
48
+ currencyId: string | null | undefined
49
+ ): Promise<TCheckoutSessionExpanded> {
50
+ const { data } = await api.post(API.RECALCULATE_PROMOTION_SESSION(sessionId), {
48
51
  currency_id: currencyId,
49
52
  });
53
+ return data;
50
54
  }
51
55
 
52
56
  export function isPromotionActive(session: TCheckoutSessionExpanded | undefined | null): boolean {
@@ -22,14 +22,15 @@ export interface BillingIntervalData {
22
22
  export type UseBillingIntervalReturn = BillingIntervalData | null;
23
23
 
24
24
  export function useBillingInterval(): UseBillingIntervalReturn {
25
- const { items, session, effectiveSessionId, refresh } = useSessionContext();
25
+ const { items, session, effectiveSessionId, refresh, sessionData, setSessionData } = useSessionContext();
26
26
  const { currency } = usePaymentMethodContext();
27
27
  const currencyId = currency?.id || null;
28
28
  const [switching, setSwitching] = useState(false);
29
+ const [pendingInterval, setPendingInterval] = useState<BillingIntervalType | null>(null);
29
30
 
30
31
  const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
31
32
  try {
32
- await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
33
+ await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
33
34
  } catch (err: unknown) {
34
35
  console.error('Failed to upsell:', getErrorMessage(err));
35
36
  }
@@ -37,7 +38,7 @@ export function useBillingInterval(): UseBillingIntervalReturn {
37
38
 
38
39
  const downsell = useMemoizedFn(async (priceId: string) => {
39
40
  try {
40
- await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
41
+ await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
41
42
  } catch (err: unknown) {
42
43
  console.error('Failed to downsell:', getErrorMessage(err));
43
44
  }
@@ -48,13 +49,14 @@ export function useBillingInterval(): UseBillingIntervalReturn {
48
49
  if (!parsed) return null;
49
50
 
50
51
  return {
51
- current: parsed.current,
52
+ current: pendingInterval || parsed.current,
52
53
  available: parsed.available,
53
54
  switching,
54
55
  switch: async (interval: BillingIntervalType) => {
55
56
  const target = parsed.available.find((a) => a.interval === interval);
56
57
  if (!target || switching) return;
57
58
 
59
+ setPendingInterval(interval);
58
60
  setSwitching(true);
59
61
  try {
60
62
  if (!parsed.firstItem.upsell_price_id && target.priceId) {
@@ -64,8 +66,9 @@ export function useBillingInterval(): UseBillingIntervalReturn {
64
66
  }
65
67
  } finally {
66
68
  setSwitching(false);
69
+ setPendingInterval(null);
67
70
  }
68
71
  },
69
72
  };
70
- }, [items, effectiveSessionId, switching]); // eslint-disable-line react-hooks/exhaustive-deps
73
+ }, [items, effectiveSessionId, switching, pendingInterval]); // eslint-disable-line react-hooks/exhaustive-deps
71
74
  }
@@ -28,6 +28,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
28
28
  error,
29
29
  errorCode,
30
30
  refresh,
31
+ setSessionData,
31
32
  sessionData,
32
33
  resolvedSessionId,
33
34
  vendorCount,
@@ -41,7 +42,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
41
42
  const effectiveSessionId = resolvedSessionId || sessionId;
42
43
 
43
44
  // 2. Payment method
44
- const paymentMethodHook = usePaymentMethod(sessionData, effectiveSessionId, refresh);
45
+ const paymentMethodHook = usePaymentMethod(sessionData, effectiveSessionId, refresh, setSessionData);
45
46
 
46
47
  // 3. Pricing
47
48
  const pricingHook = usePricing(
@@ -50,7 +51,8 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
50
51
  paymentMethodHook.currency,
51
52
  paymentMethodHook.isStripe,
52
53
  refresh,
53
- paymentMethodHook.current?.type || null
54
+ paymentMethodHook.current?.type || null,
55
+ paymentMethodHook.switching
54
56
  );
55
57
 
56
58
  // 4. Customer form
@@ -77,23 +79,29 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
77
79
  );
78
80
 
79
81
  // 7. Line items operations
80
- const items = (session?.line_items || []) as TLineItemExpanded[];
82
+ const items = useMemo(() => (session?.line_items || []) as TLineItemExpanded[], [session?.line_items]);
81
83
  const currencyId = paymentMethodHook.currency?.id || null;
82
84
 
83
- // Recalculate promotion when currency changes or on initial load
85
+ // Recalculate promotion when currency changes or on initial mount (if discounts exist).
86
+ // Uses refresh() (fresh GET) instead of POST response to avoid stale-closure overwrites
87
+ // when switch-currency and recalculate-promotion race. Matches V1 pattern.
84
88
  const prevCurrencyRef = useRef<string | null>(null);
85
89
  useEffect(() => {
86
90
  const currId = paymentMethodHook.currency?.id || null;
87
- if (!currId || !session) return;
91
+ if (!currId || !session || session.status === 'complete') return;
88
92
  if (prevCurrencyRef.current === null || currId !== prevCurrencyRef.current) {
89
93
  prevCurrencyRef.current = currId;
90
- recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then(() => refresh(true));
94
+ recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then((recalculated) => {
95
+ if (recalculated) {
96
+ refresh(true);
97
+ }
98
+ });
91
99
  }
92
100
  }, [paymentMethodHook.currency?.id, session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
93
101
 
94
102
  const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
95
103
  try {
96
- await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
104
+ await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh, sessionData, setSessionData);
97
105
  } catch (err: unknown) {
98
106
  console.error('Failed to update quantity:', getErrorMessage(err));
99
107
  }
@@ -101,7 +109,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
101
109
 
102
110
  const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
103
111
  try {
104
- await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
112
+ await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
105
113
  } catch (err: unknown) {
106
114
  console.error('Failed to upsell:', getErrorMessage(err));
107
115
  }
@@ -109,7 +117,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
109
117
 
110
118
  const downsell = useMemoizedFn(async (priceId: string) => {
111
119
  try {
112
- await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
120
+ await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
113
121
  } catch (err: unknown) {
114
122
  console.error('Failed to downsell:', getErrorMessage(err));
115
123
  }
@@ -140,7 +148,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
140
148
  const addCrossSell = useMemoizedFn(async () => {
141
149
  if (!crossSellItem) return;
142
150
  try {
143
- await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh);
151
+ await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh, sessionData, setSessionData);
144
152
  } catch (err: unknown) {
145
153
  console.error('Failed to add cross-sell:', getErrorMessage(err));
146
154
  }
@@ -148,7 +156,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
148
156
 
149
157
  const removeCrossSell = useMemoizedFn(async () => {
150
158
  try {
151
- await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
159
+ await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
152
160
  } catch (err: unknown) {
153
161
  console.error('Failed to remove cross-sell:', getErrorMessage(err));
154
162
  }
@@ -179,7 +187,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
179
187
  const setDonationAmount = useMemoizedFn(async (priceId: string, amount: string) => {
180
188
  if (!isDonation) return;
181
189
  try {
182
- await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh);
190
+ await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh, sessionData, setSessionData);
183
191
  } catch (err: unknown) {
184
192
  console.error('Failed to change amount:', getErrorMessage(err));
185
193
  }
@@ -36,8 +36,8 @@ export interface UseCheckoutSessionReturn {
36
36
  // Loading
37
37
  isLoading: boolean;
38
38
  error: string | null;
39
- /** Error code for structured error handling: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null */
40
- errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
39
+ /** Error code for structured error handling */
40
+ errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
41
41
  refresh: (forceRefresh?: boolean) => Promise<void>;
42
42
  /** Directly update session data (e.g. after completion polling returns fresh data) */
43
43
  setSessionData: (data: SessionData) => void;
@@ -151,6 +151,13 @@ export function useCheckoutSession(sessionId: string): UseCheckoutSessionReturn
151
151
  }
152
152
  }
153
153
 
154
+ // System-level order acceptance check (only block non-completed sessions)
155
+ if (data?.stopAcceptingOrders && cs?.status !== 'complete') {
156
+ setError('STOP_ACCEPTING_ORDERS');
157
+ setIsLoading(false);
158
+ return;
159
+ }
160
+
154
161
  setSessionData(data);
155
162
  setIsLoading(false);
156
163
  } catch (err: unknown) {
@@ -188,11 +195,13 @@ export function useCheckoutSession(sessionId: string): UseCheckoutSessionReturn
188
195
  const resolvedSessionId = resolvedIdRef.current || (isPaymentLink ? '' : sessionId);
189
196
 
190
197
  // Determine error code for structured handling
191
- let errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null = null;
198
+ let errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null = null;
192
199
  if (error === 'SESSION_EXPIRED') {
193
200
  errorCode = 'SESSION_EXPIRED';
194
201
  } else if (error === 'EMPTY_LINE_ITEMS') {
195
202
  errorCode = 'EMPTY_LINE_ITEMS';
203
+ } else if (error === 'STOP_ACCEPTING_ORDERS') {
204
+ errorCode = 'STOP_ACCEPTING_ORDERS';
196
205
  }
197
206
 
198
207
  // Count vendor configs for post-payment polling
@@ -3,7 +3,7 @@ import { useSessionContext } from '../context/SessionContext';
3
3
  export interface UseCheckoutStatusReturn {
4
4
  isLoading: boolean;
5
5
  error: string | null;
6
- errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
6
+ errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
7
7
  canSubmit: boolean;
8
8
  isCompleted: boolean;
9
9
  isDonation: boolean;