tf-checkout-react-native 2.0.0-beta.13 → 2.0.0-beta.15

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.
@@ -0,0 +1,170 @@
1
+ import type * as Stripe from '@stripe/stripe-react-native'
2
+ import {
3
+ confirmedStripeIntentStatus,
4
+ CONFIRMED_INTENT_STATUSES,
5
+ nativeStripeErrorOutcome,
6
+ paymentProviderError,
7
+ type CheckoutSnapshot,
8
+ type StripeCardPaymentContext,
9
+ } from 'tf-checkout-shared'
10
+
11
+ /** The MXN card rail collects a PaymentMethod before offering installments. */
12
+ export const nativePaymentSheetAvailable = (
13
+ snapshot: Pick<CheckoutSnapshot, 'gateway' | 'breakdown'>
14
+ ): boolean =>
15
+ snapshot.gateway === 'stripe_payment_intents' &&
16
+ typeof snapshot.breakdown?.currencyCode === 'string' &&
17
+ /^[A-Z]{3}$/.test(snapshot.breakdown.currencyCode) &&
18
+ snapshot.breakdown.currencyCode !== 'MXN'
19
+
20
+ type SheetSdk = Pick<
21
+ typeof Stripe,
22
+ 'initPaymentSheet' | 'presentPaymentSheet' | 'retrievePaymentIntent' | 'LinkDisplay'
23
+ >
24
+
25
+ export const NATIVE_SHEET_CARD_FIELD_REQUIRED = 'NATIVE_SHEET_CARD_FIELD_REQUIRED'
26
+ export const NATIVE_SHEET_CARD_FIELD_MESSAGE =
27
+ 'Enter your card details below, then select Pay to continue.'
28
+
29
+ export const NATIVE_SHEET_REVIEW_REQUIRED =
30
+ 'The original payment details changed. Review this order before paying.'
31
+
32
+ /** Financial identity is independent of method eligibility. A mismatch must
33
+ * never authorize the legacy CardField path to confirm the same stale review. */
34
+ export const nativeSheetIntentIdentity = (
35
+ intent: unknown,
36
+ intentId: string,
37
+ reviewed: StripeCardPaymentContext | undefined
38
+ ): boolean => {
39
+ if (
40
+ !intent ||
41
+ typeof intent !== 'object' ||
42
+ !reviewed ||
43
+ !Number.isSafeInteger(reviewed.amountMinor) ||
44
+ reviewed.amountMinor <= 0 ||
45
+ !/^[A-Z]{3}$/.test(reviewed.currency)
46
+ )
47
+ return false
48
+ const own = (key: string): unknown =>
49
+ Object.getOwnPropertyDescriptor(intent, key)?.value
50
+ const amount = own('amount')
51
+ const currency = own('currency')
52
+ return (
53
+ own('id') === intentId &&
54
+ Number.isSafeInteger(amount) &&
55
+ amount === reviewed.amountMinor &&
56
+ typeof currency === 'string' &&
57
+ /^[A-Za-z]{3}$/.test(currency) &&
58
+ currency.toUpperCase() === reviewed.currency
59
+ )
60
+ }
61
+
62
+ /** The bridge must preserve the complete raw PI payment_method_types list.
63
+ * Link has no universal card-fee equivalence contract, so it is not admitted. */
64
+ const nativeSheetCardMethods = (intent: unknown): boolean => {
65
+ if (!intent || typeof intent !== 'object') return false
66
+ const methods = Object.getOwnPropertyDescriptor(intent, 'paymentMethodTypes')?.value
67
+ return Array.isArray(methods) && methods.length === 1 && methods[0] === 'card'
68
+ }
69
+
70
+ /** Internal building block. The caller must hold
71
+ * the checkout confirmer gateway for the complete promise and bind assertCurrent
72
+ * to the original order/account initialization, including the return scheme.
73
+ * PaymentSheet owns collection and confirmation; no card details enter JS.
74
+ */
75
+ export const createPaymentSheetConfirmation = (
76
+ sdk: SheetSdk,
77
+ options: {
78
+ merchantDisplayName: string
79
+ returnURL: string
80
+ reviewed: StripeCardPaymentContext | undefined
81
+ assertCurrent: () => void
82
+ }
83
+ ) => {
84
+ let active = false
85
+ const assertAfterPresentation = () => {
86
+ try {
87
+ options.assertCurrent()
88
+ } catch {
89
+ throw paymentProviderError('The original payment owner changed.', 'unknown')
90
+ }
91
+ }
92
+ return async (
93
+ clientSecret: string,
94
+ billing: Stripe.BillingDetails
95
+ ): Promise<string> => {
96
+ if (active)
97
+ throw paymentProviderError('Another payment sheet is active.', 'notStarted')
98
+ // Only an existing PaymentIntent is admitted. No SetupIntent, deferred
99
+ // intent creation callback, saved Customer or account inference is supplied.
100
+ const intentId = /^(pi_[A-Za-z0-9]+)_secret_[A-Za-z0-9]+$/.exec(clientSecret)?.[1]
101
+ if (!intentId || !options.merchantDisplayName.trim() || !options.returnURL)
102
+ throw paymentProviderError('Payment sheet details are unavailable.', 'notStarted')
103
+ options.assertCurrent()
104
+ active = true
105
+ try {
106
+ const before = await sdk.retrievePaymentIntent(clientSecret)
107
+ options.assertCurrent()
108
+ const requireAdmittedIntent = (result: typeof before) => {
109
+ if (
110
+ result.error ||
111
+ !nativeSheetIntentIdentity(result.paymentIntent, intentId, options.reviewed)
112
+ )
113
+ throw paymentProviderError(NATIVE_SHEET_REVIEW_REQUIRED, 'notStarted')
114
+ if (!nativeSheetCardMethods(result.paymentIntent))
115
+ throw paymentProviderError(NATIVE_SHEET_CARD_FIELD_MESSAGE, 'notStarted', {
116
+ code: NATIVE_SHEET_CARD_FIELD_REQUIRED,
117
+ })
118
+ }
119
+ requireAdmittedIntent(before)
120
+ if (CONFIRMED_INTENT_STATUSES.includes(before.paymentIntent.status))
121
+ return confirmedStripeIntentStatus(before.paymentIntent)
122
+ const initialized = await sdk.initPaymentSheet({
123
+ paymentIntentClientSecret: clientSecret,
124
+ merchantDisplayName: options.merchantDisplayName,
125
+ returnURL: options.returnURL,
126
+ defaultBillingDetails: billing,
127
+ customFlow: false,
128
+ allowsDelayedPaymentMethods: false,
129
+ // A native Link selection can have a different canonical fee class.
130
+ // Do not infer equivalence from PI eligibility or a display preference.
131
+ link: { display: sdk.LinkDisplay.NEVER },
132
+ })
133
+ if (initialized.error)
134
+ throw paymentProviderError(NATIVE_SHEET_CARD_FIELD_MESSAGE, 'notStarted', {
135
+ code: NATIVE_SHEET_CARD_FIELD_REQUIRED,
136
+ })
137
+ // A retired owner must never present a sheet initialized for its account.
138
+ options.assertCurrent()
139
+ const ready = await sdk.retrievePaymentIntent(clientSecret)
140
+ options.assertCurrent()
141
+ requireAdmittedIntent(ready)
142
+ if (CONFIRMED_INTENT_STATUSES.includes(ready.paymentIntent.status))
143
+ return confirmedStripeIntentStatus(ready.paymentIntent)
144
+ const presented = await sdk.presentPaymentSheet()
145
+ if (presented.error)
146
+ throw paymentProviderError(
147
+ presented.error.message || 'Payment failed.',
148
+ nativeStripeErrorOutcome(presented.error)
149
+ )
150
+ // A resolved sheet alone is not authorization to finalize the order.
151
+ // Retrieve only the original intent; failures remain ambiguous to the
152
+ // engine, which retains the original order instead of creating another.
153
+ assertAfterPresentation()
154
+ const result = await sdk.retrievePaymentIntent(clientSecret)
155
+ if (
156
+ result.error ||
157
+ !nativeSheetIntentIdentity(result.paymentIntent, intentId, options.reviewed) ||
158
+ !nativeSheetCardMethods(result.paymentIntent)
159
+ )
160
+ throw paymentProviderError(
161
+ 'The original payment could not be verified.',
162
+ 'unknown'
163
+ )
164
+ assertAfterPresentation()
165
+ return confirmedStripeIntentStatus(result.paymentIntent)
166
+ } finally {
167
+ active = false
168
+ }
169
+ }
170
+ }
@@ -10,14 +10,11 @@ import { Accommodation } from './Accommodation'
10
10
  * - No Stripe Payment Element / wallet widget: web folds Apple/Google Pay into
11
11
  * the Element. Native offers Apple/Google Pay as explicit rows only when
12
12
  * the backend enables the wallet and Stripe confirms device support.
13
- * - The card slot renders the native `<CardInput/>` (Stripe `<CardField/>`) for
14
- * the Stripe card + payment-plan-new-card paths. Xendit card entry tokenizes
15
- * in a WebView sheet (deferred in `confirmer.ts`), so the Xendit card row
16
- * shows a note instead of a field; `placeOrder({method:'xenditCard'})` returns
17
- * the typed unsupported-gateway error until that WebView port is injected.
18
- * - Redirect rails / Razorpay / PayPal / Xendit e-wallets render their rows and
19
- * call `placeOrder` with the mapped request; the WebView `providerPage` port
20
- * that presents them is likewise a follow-up (see `confirmer.ts` header).
13
+ * - An explicitly configured native PaymentSheet collects regular Stripe
14
+ * payments. CardField remains for other hosts, MXN installments and new-card
15
+ * payment plans. Existing explicit wallet rows keep their own policy gates.
16
+ * - CheckoutProvider owns provider-page windows for Xendit, redirect methods,
17
+ * Razorpay and PayPal; imperative hosts provide that port themselves.
21
18
  * - Conditions render as plain text (HTML stripped) — no DOM to inject markup.
22
19
  */
23
20
  import React, { useEffect, useMemo, useState, type FC } from 'react'
@@ -53,6 +50,7 @@ import {
53
50
  } from 'tf-checkout-shared'
54
51
 
55
52
  import { CardInput, claimCardField } from '../CardInput'
53
+ import { nativePaymentSheetAvailable } from '../paymentSheet'
56
54
  import { rnConfirmer } from '../instance'
57
55
  import { useCheckoutContext, useCheckoutValidation } from '../ui/CheckoutProvider'
58
56
  import { Checkbox } from '../ui/Field'
@@ -69,7 +67,6 @@ import {
69
67
  import { FrozenFields } from '../ui/Frozen'
70
68
  import { OrderSummary } from './OrderSummary'
71
69
 
72
-
73
70
  /** Server spellings → display labels for Stripe redirect rails. */
74
71
  const REDIRECT_LABELS: Record<string, string> = {
75
72
  klarna: 'Klarna',
@@ -185,8 +182,6 @@ export const Payment: FC<PaymentProps> = ({
185
182
  }
186
183
  }, [])
187
184
 
188
-
189
-
190
185
  const bd = checkout.breakdown
191
186
  const isFree = bd !== undefined && Number(bd.total) === 0
192
187
  const isXendit = checkout.gateway === 'xendit'
@@ -274,10 +269,14 @@ export const Payment: FC<PaymentProps> = ({
274
269
  const isPlanSelected = selectedOption?.id.kind === 'paymentPlan'
275
270
  const savedLast4 = methods?.savedPlanCardLast4
276
271
  const reuseSavedCard = planUsesSavedCard(savedLast4, useSavedCard)
272
+ const useStripeSheet = Boolean(
273
+ checkout.paymentSheet && isCardSelected && nativePaymentSheetAvailable(checkout)
274
+ )
277
275
  // The native Stripe card field: shown for the Stripe card path and for a
278
276
  // payment-plan NEW card (SetupIntent). Xendit card is a WebView sheet (below).
279
277
  const showStripeCardField =
280
- !isXendit && (isCardSelected || (isPlanSelected && !reuseSavedCard))
278
+ !isXendit &&
279
+ ((!useStripeSheet && isCardSelected) || (isPlanSelected && !reuseSavedCard))
281
280
  const showXenditCardNote = isXendit && isCardSelected
282
281
  // One card form at a time owns the shared payment SDK (bundles and tickets
283
282
  // alike): while another one does, this form offers "Continue paying here".
@@ -367,7 +366,8 @@ export const Payment: FC<PaymentProps> = ({
367
366
  req = { method: 'xenditEWallet', walletKey: id.walletKey, extra: extras[selected] }
368
367
  else if (id.kind === 'redirect')
369
368
  req = { method: 'redirect', methodType: id.methodType }
370
- else if (id.kind === 'paymentPlan') req = { method: 'paymentPlan', useSavedCard: reuseSavedCard }
369
+ else if (id.kind === 'paymentPlan')
370
+ req = { method: 'paymentPlan', useSavedCard: reuseSavedCard }
371
371
  else req = { method: id.kind } as PaymentRequest
372
372
  // Called synchronously in the press handler — popup rails (e-wallet,
373
373
  // redirect, razorpay, paypal) want the user gesture on their WebView host.
@@ -377,15 +377,18 @@ export const Payment: FC<PaymentProps> = ({
377
377
  // The card field is claimed inside the order, after the payment SDK holds
378
378
  // the ticket order's keys and right before the card is confirmed. Whether
379
379
  // a key change empties the field is decided in shared (`cardClaimStep`).
380
- checkout
381
- .placeOrder(
382
- req,
383
- usesOwnCardField
384
- ? {
385
- claimCard: cardClaimStep('nativeCard', () => claimCardField(cardOwner)),
386
- }
387
- : undefined
388
- )
380
+ const payment =
381
+ useStripeSheet && checkout.paymentSheet && !isFree
382
+ ? checkout.paymentSheet.placeOrder()
383
+ : checkout.placeOrder(
384
+ req,
385
+ usesOwnCardField
386
+ ? {
387
+ claimCard: cardClaimStep('nativeCard', () => claimCardField(cardOwner)),
388
+ }
389
+ : undefined
390
+ )
391
+ payment
389
392
  .then((order) => onComplete?.(order))
390
393
  // Cart ownership and native field ownership each fail before a charge.
391
394
  .catch((e) =>
@@ -419,6 +422,11 @@ export const Payment: FC<PaymentProps> = ({
419
422
  ) : showStripeCardField ? (
420
423
  <CardInput owner={cardOwner} />
421
424
  ) : null}
425
+ {useStripeSheet && !isFree ? (
426
+ <MutedText>
427
+ Choose your payment details securely with Stripe when you pay.
428
+ </MutedText>
429
+ ) : null}
422
430
  {showXenditCardNote ? (
423
431
  // Xendit card tokenizes in a WebView sheet (deferred — see confirmer.ts).
424
432
  <MutedText>
@@ -474,7 +482,11 @@ export const Payment: FC<PaymentProps> = ({
474
482
  }
475
483
  showValidation={validation.attempt > 0}
476
484
  inputSlotFor={
477
- isPlanSelected ? (reuseSavedCard ? undefined : 'paymentPlan') : cardValue
485
+ isPlanSelected
486
+ ? reuseSavedCard
487
+ ? undefined
488
+ : 'paymentPlan'
489
+ : cardValue
478
490
  }
479
491
  >
480
492
  {paymentInput}
@@ -505,7 +517,12 @@ export const Payment: FC<PaymentProps> = ({
505
517
  })}
506
518
  </Card>
507
519
 
508
- {!isFree && paymentMethods.error ? <Card><ErrorText>{paymentMethods.error.message}</ErrorText><Button onPress={()=>void paymentMethods.refetch()}>Try again</Button></Card> : null}
520
+ {!isFree && paymentMethods.error ? (
521
+ <Card>
522
+ <ErrorText>{paymentMethods.error.message}</ErrorText>
523
+ <Button onPress={() => void paymentMethods.refetch()}>Try again</Button>
524
+ </Card>
525
+ ) : null}
509
526
  <OrderSummary translate={t} />
510
527
 
511
528
  <Button
@@ -24,19 +24,15 @@ import React, {
24
24
 
25
25
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
26
26
 
27
- import {
28
- hooks,
29
- type UseCheckout,
30
- type CheckoutOptions,
31
- type CheckoutImagePicker,
32
- } from 'tf-checkout-shared'
27
+ import { hooks, type CheckoutOptions, type CheckoutImagePicker } from 'tf-checkout-shared'
33
28
 
34
- import { useCheckout } from '../useCheckout'
29
+ import { useCheckout, type NativeUseCheckout } from '../useCheckout'
30
+ import type { NativePaymentSheetOptions } from '../confirmer'
35
31
  import { useInstallmentPicker } from './InstallmentPicker'
36
32
  import { useProviderPages } from './ProviderPages'
37
33
  import { resolveTheme, type CheckoutTheme, type ResolvedTheme } from './theme'
38
34
 
39
- const CheckoutCtx = createContext<UseCheckout | null>(null)
35
+ const CheckoutCtx = createContext<NativeUseCheckout | null>(null)
40
36
  const ThemeCtx = createContext<ResolvedTheme>(resolveTheme())
41
37
  const ImagePickerCtx = createContext<CheckoutImagePicker | undefined>(undefined)
42
38
  const ValidationCtx = createContext<{
@@ -61,12 +57,14 @@ export interface CheckoutProviderProps {
61
57
  pickImage?: CheckoutImagePicker
62
58
  /** Wallet/redirect settings for the provider-owned native engine. */
63
59
  options?: CheckoutOptions
60
+ /** Enable Stripe's native sheet for regular card payments. */
61
+ paymentSheet?: NativePaymentSheetOptions
64
62
  /**
65
63
  * Bring your own checkout instance (from a top-level `useCheckout()`). Omit
66
64
  * and the provider runs `useCheckout()` itself. Either way the sections read
67
65
  * the same shared cart (the engine store is a module singleton).
68
66
  */
69
- value?: UseCheckout
67
+ value?: NativeUseCheckout
70
68
  /** Retheme via design tokens. */
71
69
  theme?: CheckoutTheme
72
70
  /** Provide your own react-query client instead of the bundled shared one. */
@@ -80,14 +78,17 @@ export interface CheckoutThemeProviderProps {
80
78
  }
81
79
 
82
80
  /** Retheme standalone SDK screens without binding checkout or query state. */
83
- export const CheckoutThemeProvider: FC<CheckoutThemeProviderProps> = ({ theme, children }) => {
81
+ export const CheckoutThemeProvider: FC<CheckoutThemeProviderProps> = ({
82
+ theme,
83
+ children,
84
+ }) => {
84
85
  const resolved = useMemo(() => resolveTheme(theme), [theme])
85
86
  return <ThemeCtx.Provider value={resolved}>{children}</ThemeCtx.Provider>
86
87
  }
87
88
 
88
89
  /** The context boundary — given a resolved checkout instance + theme. */
89
90
  const CheckoutRoot: FC<{
90
- checkout: UseCheckout
91
+ checkout: NativeUseCheckout
91
92
  pickImage?: CheckoutImagePicker
92
93
  theme?: CheckoutTheme
93
94
  children: ReactNode
@@ -123,6 +124,7 @@ const SelfProvidedCheckout: FC<Omit<CheckoutProviderProps, 'value' | 'queryClien
123
124
  const checkout = useCheckout({
124
125
  ports: { onInstallmentOffers, providerPage },
125
126
  options: rest.options,
127
+ paymentSheet: rest.paymentSheet,
126
128
  })
127
129
  return (
128
130
  <CheckoutRoot checkout={checkout} {...rest}>
@@ -154,7 +156,7 @@ export const CheckoutProvider: FC<CheckoutProviderProps> = ({
154
156
  }
155
157
 
156
158
  /** Read the shared checkout inside any component under `<CheckoutProvider>`. */
157
- export const useCheckoutContext = (): UseCheckout => {
159
+ export const useCheckoutContext = (): NativeUseCheckout => {
158
160
  const ctx = useContext(CheckoutCtx)
159
161
  if (!ctx) {
160
162
  throw new Error('useCheckoutContext must be used within <CheckoutProvider>.')
@@ -8,8 +8,7 @@
8
8
  * - `<select>` and `radio` (single choice) both render as the native `Select`
9
9
  * sheet — there is no native radio primitive.
10
10
  * - `select_multi` renders a column of native `Checkbox` rows.
11
- * - there is no multiline `Textarea` primitive, so `textarea` falls back to a
12
- * single-line `Input` (value parity preserved).
11
+ * - `textarea` uses the native multiline Input, preserving line breaks.
13
12
  */
14
13
  import React, { type FC } from 'react'
15
14
  import { Image, View } from 'react-native'
@@ -94,11 +93,15 @@ export const CustomFieldInput: FC<CustomFieldInputProps> = ({
94
93
 
95
94
  switch (field.type) {
96
95
  case 'textarea':
97
- // Native adaptation: no multiline Textarea primitive — single-line Input.
98
96
  return (
99
97
  <Field label={field.label} required={field.required} error={error}>
100
98
  {desc}
101
- <Input value={asString(value)} onChangeText={onChange} invalid={invalid} />
99
+ <Input
100
+ multiline
101
+ value={asString(value)}
102
+ onChangeText={onChange}
103
+ invalid={invalid}
104
+ />
102
105
  </Field>
103
106
  )
104
107
  case 'select':
package/src/ui/Select.tsx CHANGED
@@ -4,7 +4,7 @@
4
4
  * list of options. Generic over the option value so callers keep type-safety
5
5
  * (country ids, gender codes, currency codes, ISO dates, …).
6
6
  */
7
- import React, { useMemo, useState } from 'react'
7
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
8
  import { FlatList, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
9
9
 
10
10
  import { useTheme } from './CheckoutProvider'
@@ -36,6 +36,17 @@ export function Select<T = string>({
36
36
  const theme = useTheme()
37
37
  const s = useSelectStyles(theme)
38
38
  const [open, setOpen] = useState(false)
39
+ const interaction = useRef({ disabled, open: false, revision: 0 })
40
+ interaction.current.disabled = disabled
41
+ const revision = interaction.current.revision
42
+ const close = useCallback(() => {
43
+ interaction.current.open = false
44
+ interaction.current.revision += 1
45
+ setOpen(false)
46
+ }, [])
47
+ useEffect(() => {
48
+ if (disabled && interaction.current.open) close()
49
+ }, [disabled, close])
39
50
 
40
51
  const selected = options.find((o) => o.value === value)
41
52
 
@@ -44,7 +55,12 @@ export function Select<T = string>({
44
55
  <Pressable
45
56
  accessibilityRole="button"
46
57
  disabled={disabled}
47
- onPress={() => setOpen(true)}
58
+ onPress={() => {
59
+ if (interaction.current.disabled) return
60
+ interaction.current.open = true
61
+ interaction.current.revision += 1
62
+ setOpen(true)
63
+ }}
48
64
  style={[s.control, invalid && s.controlInvalid, disabled && s.controlDisabled]}
49
65
  >
50
66
  <Text style={[s.value, !selected && s.placeholder]} numberOfLines={1}>
@@ -54,12 +70,12 @@ export function Select<T = string>({
54
70
  </Pressable>
55
71
 
56
72
  <Modal
57
- visible={open}
73
+ visible={open && !disabled}
58
74
  transparent
59
75
  animationType="fade"
60
- onRequestClose={() => setOpen(false)}
76
+ onRequestClose={close}
61
77
  >
62
- <Pressable style={s.backdrop} onPress={() => setOpen(false)}>
78
+ <Pressable style={s.backdrop} onPress={close}>
63
79
  <Pressable style={s.sheet} onPress={() => {}}>
64
80
  <FlatList
65
81
  data={options}
@@ -70,9 +86,17 @@ export function Select<T = string>({
70
86
  return (
71
87
  <Pressable
72
88
  style={s.option}
89
+ disabled={disabled}
73
90
  onPress={() => {
91
+ const current = interaction.current
92
+ if (
93
+ current.disabled ||
94
+ !current.open ||
95
+ current.revision !== revision
96
+ )
97
+ return
74
98
  onChange(item.value)
75
- setOpen(false)
99
+ close()
76
100
  }}
77
101
  >
78
102
  <Text style={[s.optionLabel, isSelected && s.optionLabelSelected]}>
@@ -11,15 +11,27 @@
11
11
  * checkout.setBilling(b)
12
12
  * await checkout.placeOrder('card') // <CardInput/> rendered
13
13
  */
14
+ import { useState } from 'react'
14
15
  import {
15
16
  useCheckout as useSharedCheckout,
16
17
  type CheckoutOptions,
17
18
  type CheckoutPorts,
18
19
  type UseCheckout,
20
+ paymentProviderError,
19
21
  } from 'tf-checkout-shared'
20
22
 
21
23
  import { claimDefaultCardField } from './CardInput'
22
24
  import { rnConfirmer, rnPorts } from './instance'
25
+ import type { NativePaymentSheetOptions } from './confirmer'
26
+ import {
27
+ nativePaymentSheetAvailable,
28
+ NATIVE_SHEET_CARD_FIELD_MESSAGE,
29
+ } from './paymentSheet'
30
+
31
+ export interface NativeUseCheckout extends UseCheckout {
32
+ /** Native intent-only sheet. Omitted unless explicitly configured by the host. */
33
+ paymentSheet?: { placeOrder: () => ReturnType<UseCheckout['placeOrder']> }
34
+ }
23
35
 
24
36
  export interface UseCheckoutOptions {
25
37
  /** Extra platform ports merged over the native defaults (e.g. an injected
@@ -27,11 +39,74 @@ export interface UseCheckoutOptions {
27
39
  ports?: Omit<CheckoutPorts, 'confirmer'>
28
40
  /** Wallet/redirect engine options (merchant identity, deep-link scheme). */
29
41
  options?: CheckoutOptions
42
+ /** Host must register the return scheme and route callbacks to Stripe. */
43
+ paymentSheet?: NativePaymentSheetOptions
30
44
  }
31
45
 
32
- export const useCheckout = (opts: UseCheckoutOptions = {}): UseCheckout =>
33
- useSharedCheckout({
46
+ export const useCheckout = (opts: UseCheckoutOptions = {}): NativeUseCheckout => {
47
+ const [sheetConfig] = useState(() =>
48
+ opts.paymentSheet ? { ...opts.paymentSheet } : undefined
49
+ )
50
+ const [sheetScheme] = useState(() => opts.options?.urlScheme)
51
+ const [sheet] = useState(() =>
52
+ sheetConfig ? rnConfirmer.createPaymentSheetSession(sheetConfig) : undefined
53
+ )
54
+ const nativePorts = { ...rnPorts, ...opts.ports }
55
+ const checkout = useSharedCheckout({
34
56
  // A Pay without its own claim uses the default ticket field (`checkout`).
35
- ports: { confirmer: rnConfirmer, ...rnPorts, claimDefaultCardField, ...opts.ports },
57
+ ports: {
58
+ confirmer: sheet?.confirmer ?? rnConfirmer,
59
+ claimDefaultCardField,
60
+ ...nativePorts,
61
+ ...(sheet && nativePorts.createCardPaymentMethod
62
+ ? {
63
+ createCardPaymentMethod: (billing) =>
64
+ sheet.cardFieldOperation(() =>
65
+ nativePorts.createCardPaymentMethod!(billing)
66
+ ),
67
+ }
68
+ : {}),
69
+ },
36
70
  options: opts.options,
37
71
  })
72
+ return {
73
+ ...checkout,
74
+ paymentSheet:
75
+ sheet && !sheet.requiresCardField()
76
+ ? {
77
+ placeOrder: () => {
78
+ if (sheet.requiresCardField())
79
+ return Promise.reject(
80
+ paymentProviderError(NATIVE_SHEET_CARD_FIELD_MESSAGE, 'notStarted')
81
+ )
82
+ // MSI needs a CardField PaymentMethod before its server-confirmed path.
83
+ // Keep that rail intact rather than collecting a different method in a sheet.
84
+ if (
85
+ !opts.options?.urlScheme ||
86
+ !sheetConfig?.merchantDisplayName.trim() ||
87
+ opts.options.urlScheme !== sheetScheme ||
88
+ opts.paymentSheet?.merchantDisplayName !==
89
+ sheetConfig?.merchantDisplayName ||
90
+ opts.paymentSheet?.returnURL !== sheetConfig?.returnURL ||
91
+ opts.paymentSheet?.returnURL !==
92
+ `${opts.options.urlScheme}://stripe-redirect`
93
+ )
94
+ return Promise.reject(
95
+ paymentProviderError(
96
+ 'The payment return route is not configured.',
97
+ 'notStarted'
98
+ )
99
+ )
100
+ if (!nativePaymentSheetAvailable(checkout.store.getState()))
101
+ return Promise.reject(
102
+ paymentProviderError(
103
+ 'Use the installment card form for this currency.',
104
+ 'notStarted'
105
+ )
106
+ )
107
+ return sheet.run((claimCard) => checkout.placeOrder('card', { claimCard }))
108
+ },
109
+ }
110
+ : undefined,
111
+ }
112
+ }