@spring-payments/payments-react-native 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spring Payments
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,178 @@
1
- # Temporary Holding Version
1
+ # Spring Payments for React Native / Expo
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A React Native library: a ready-made **Add debit or credit card** button opens native card entry. Partners do not initialize Stripe, handle intents, or build card fields. Card policy, customer authorization, saved accounts, removals, charging and duplicate-card decisions remain on Spring/partner backends.
4
+
5
+ Target: Expo SDK 57, RN 0.86, Stripe React Native 0.64.x. Type checks and mocked contract tests are not certification of native 3DS behavior. Use a development build, not Expo Go, as the release verification environment. iOS/Android device tests remain a release gate. This package does not support React Native Web; use `@spring-payments/payments-js` on web.
6
+
7
+ Expo SDK 57 requires Node.js 22.13+ and Xcode 26.4+ for local iOS builds ([Expo requirements](https://docs.expo.dev/versions/latest/)). Older Swift compilers reject Expo's `weak let` declarations; upgrade the toolchain rather than patching Expo's Swift sources.
8
+
9
+ With Xcode 27/iOS 27, SDK 57 apps must also opt into Expo's scene lifecycle: install
10
+ `expo-build-properties` with `npx expo install expo-build-properties` and add
11
+ `["expo-build-properties", { "ios": { "enableSceneSupport": true } }]` to your Expo
12
+ plugins. This is app-level Expo setup, not a Spring theme option.
13
+
14
+ ## Install
15
+
16
+ For Expo SDK 57 / React Native 0.86:
17
+
18
+ ```sh
19
+ npx expo install @spring-payments/payments-react-native @stripe/stripe-react-native@0.64.0
20
+ ```
21
+
22
+ The published npm package is the only supported partner distribution. Partners do not need access to Spring's private repository and should not copy source or install supplied tarballs. Use the compatible Stripe version above rather than assuming an arbitrary latest version is supported. Browser apps must likewise install `@spring-payments/payments-js` from npm; direct Gateway module imports and no-build integrations are not supported.
23
+
24
+ Configure your dedicated return scheme, then create/rebuild your development app:
25
+
26
+ ```json
27
+ {
28
+ "expo": {
29
+ "plugins": [["@spring-payments/payments-react-native", { "scheme": "partnerpayments" }]]
30
+ }
31
+ }
32
+ ```
33
+
34
+ The plugin preserves existing schemes and adds this one. Native Stripe code is autolinked; wallets are deliberately not enabled. No Apple merchant ID is needed for card-only entry. Run `npx expo run:ios`, `npx expo run:android`, or your existing EAS development-build workflow. Installing JS or an OTA update alone cannot add the native dependency.
35
+
36
+ For bare React Native, install the package and compatible Stripe dependency normally, run your iOS CocoaPods installation, and register the dedicated URL scheme with your app's iOS/Android configuration. No Expo runtime dependency is required by this SDK. Do not add another StripeProvider or initialize Stripe separately while a Spring flow is active.
37
+
38
+ ## One provider and one button
39
+
40
+ ```tsx
41
+ import { SpringPaymentsProvider, SpringAddCardButton } from '@spring-payments/payments-react-native';
42
+
43
+ const options = {
44
+ applicationId: 'com.partner.app',
45
+ returnUrl: 'partnerpayments://safepay',
46
+ gatewayOrigin: 'https://dev.gateway.pay-api.springpayments.com',
47
+ merchantDisplayName: 'Your Business',
48
+ createSession: request => authenticatedPartnerApi.createPaymentSession(request),
49
+ };
50
+
51
+ export function App() {
52
+ return (
53
+ <SpringPaymentsProvider options={options}>
54
+ <SpringAddCardButton onResult={result => {
55
+ if (result.status === 'COMPLETED') refreshSavedCards();
56
+ if (result.status === 'PENDING') reconcileWithPartnerBackend(result);
57
+ }} />
58
+ </SpringPaymentsProvider>
59
+ );
60
+ }
61
+ ```
62
+
63
+ Mount one provider above navigation. Exact web/native layouts differ; entry point, purpose and completion semantics are consistent.
64
+
65
+ ## Styling: no Spring theme by default
66
+
67
+ `SpringAddCardButton` adds no colors, fonts, padding, rounding, opacity, or layout styles. React Native's default `Text`, `View` and `Pressable` rendering still applies. Stripe's native sheet also retains its own defaults unless you supply `appearance`. The green button in the example is explicitly styled by that app.
68
+
69
+ | Prop | What you control |
70
+ | --- | --- |
71
+ | `style` | The launch `Pressable`; accepts a style, array, or `({pressed}) => style` callback |
72
+ | `textStyle` | Launch button text: font, size, weight, color and alignment |
73
+ | `containerStyle` | The wrapping `View`: layout, width, spacing and background |
74
+ | `statusStyle` | Status/error text styling |
75
+ | `label`, `busyLabel`, `busyMessage` | Launch label and in-progress copy |
76
+ | `messages` | Override `COMPLETED`, `PENDING`, `CANCELLED`, `FAILED` result messages |
77
+ | `disabled` | Disable the launch button; busy operations also disable it automatically |
78
+
79
+ ```tsx
80
+ const payments = useSpringPayments(); // Inside the provider.
81
+ <SpringAddCardButton
82
+ label="Add a card"
83
+ style={({ pressed }) => ({
84
+ backgroundColor: '#234db5', padding: 14, minHeight: 48,
85
+ borderRadius: 6, opacity: pressed || payments.busy ? 0.6 : 1,
86
+ })}
87
+ textStyle={{ color: '#fff', fontSize: 16, textAlign: 'center' }}
88
+ containerStyle={{ marginVertical: 16 }}
89
+ statusStyle={{ color: '#333', marginTop: 8 }}
90
+ messages={{ FAILED: 'We could not save your card. Please try again.' }}
91
+ />
92
+ ```
93
+
94
+ Your styles should provide sufficient touch targets, readable contrast and appropriate disabled/pressed states. `useSpringPayments().busy` lets your theme reflect pending operations. Accessibility roles, disabled/busy semantics and live status text remain in the component. Use your own button with `addCard()` for arbitrary icons, children or layout; you then own its accessibility and status presentation.
95
+
96
+ **Style the card sheet separately** using the provider's `options.appearance` (`PaymentSheet.AppearanceParams`). Stripe supports colors (including light/dark), fonts, shapes/borders and primary-button appearance, with platform-specific limits. `options.saveCardLabel` customizes Save; `merchantDisplayName` identifies your business. Neither option styles the launch button. For example:
97
+
98
+ ```ts
99
+ const themedOptions = {
100
+ ...options, // The session options above; pass themedOptions to the provider.
101
+ appearance: {
102
+ colors: { primary: '#234db5' },
103
+ shapes: { borderRadius: 6 },
104
+ },
105
+ saveCardLabel: 'Save card',
106
+ };
107
+ ```
108
+
109
+ We pass these settings to Stripe without imposing a Spring theme. They do not allow arbitrary native-sheet layout, field order or replacement card inputs. Bank-owned 3DS challenge content is not freely themeable. Web Appearance API rules and native PaymentSheet appearance are different schemas, not an interchangeable cross-platform theme object.
110
+
111
+ For your own button, call `useSpringPayments().addCard()`. To recover an existing off-session payment call `useSpringPayments().authenticatePayment(paymentId)`. Both return safe results, and the hook exposes `busy` and `result`. Do not create a new charge or save a replacement card for authentication recovery.
112
+
113
+ ## Backend session adapter
114
+
115
+ `createSession` receives `{purpose,applicationId,returnUrl,paymentId?}` and returns `{sessionId,sessionToken,expiresAt,componentUrl}`. Your authenticated backend resolves the customer and authorizes the operation, then uses Spring's M2M API:
116
+
117
+ - Add card: `POST /api/external/spg/v1/clients/{key}/native-card-sessions`
118
+ - Authenticate: `POST /api/external/spg/v1/clients/{key}/payments/{paymentId}/native-authentication-sessions`
119
+ - Both take `{applicationId,returnUrl}` and `Idempotency-Key`.
120
+
121
+ Spring must register the partner UUID, application ID and exact return URL before native sessions can be issued. The app never holds Spring M2M credentials. `gatewayOrigin` restricts where the SDK sends the capability. The application-ID header identifies the session audience, not proof of app authenticity.
122
+
123
+ Keep an operation's idempotency key on the partner side and reuse it after network uncertainty or returning to the app; do not silently mint a fresh setup on each retry. A replay first checks Spring status, so a completed setup does not present another sheet. For an expired add-card session, reconcile the saved-account list before authorizing a new operation. Payment recovery additionally has the existing payment-status API.
124
+
125
+ ## Results and lifecycle
126
+
127
+ - **COMPLETED**: Spring confirmed the saved account or original payment. Add-card includes `paymentAccountId`; authentication includes `paymentId`. A native sheet's success alone is not completion.
128
+ - **PENDING**: The operation is unresolved, with a safe `sessionId` and, for payment authentication, `paymentId`. Bounded polling stops after 10 reads; check/replay the same operation through your backend rather than starting a duplicate.
129
+ - **FAILED**: Safe `UNAVAILABLE`, `EXPIRED`, or `REJECTED` code; no raw Stripe or transport errors.
130
+ - **CANCELLED** is reserved for cancellation known to occur before submission. The current native sheet does not expose whether the user tapped Save before dismissing, so dismissal conservatively reconciles and returns COMPLETED or PENDING, **not a claim that no card was saved**. Web can distinguish closing before its explicit submission button.
131
+
132
+ Only one native operation may present at a time. Duplicate calls for the same operation share the underlying work; a competing operation gets UNAVAILABLE. Do not change the provider's customer/session context or sign in as another customer while a sheet is open.
133
+
134
+ The dedicated return URL must be `<your-scheme>://safepay` in this SDK version: direct iOS confirmation uses Stripe's fixed suffix. The provider forwards matching iOS Linking callbacks internally. Android retains Stripe's native return handling; no JS Linking event is required for success. Configure partner navigation/analytics to ignore this dedicated callback route and never log its query parameters. Do not store capabilities, processor client secrets or callback URLs in persistent storage.
135
+
136
+ After process death there is no retained JS promise/session. Restore partner login, reconcile/replay the operation through `createSession`, and let Spring determine completion; URL arrival is not payment success. Partners remain responsible for consent to future off-session charges and prompting absent customers to return.
137
+
138
+ ## Spring-hosted payment recovery (preferred)
139
+
140
+ For a customer returning to finish an existing payment, Spring's hosted browser recovery page remains the preferred option. This is a Spring-owned application, not a hosted SDK distribution. Embedded native card entry and in-app authentication still use this npm package.
141
+
142
+ Send the customer to your authenticated web handoff page. Its backend authorizes the original payment and creates a **web** authentication session using the Gateway origin and `{gatewayOrigin}/payment-components/recover` as the return URL. The handoff submits `sessionId` and `sessionToken` in a browser form POST body to that page. Never put capabilities in a deep link, query string or fragment, and never send a native session to the web recovery page. A browser redirect or return to the app is not proof of payment; reconcile the original payment through your backend. Partners choosing in-app recovery instead can use `authenticatePayment(paymentId)` as described above.
143
+
144
+ ## Verification
145
+
146
+ `npm test` compiles against the actual RN/Stripe types and tests backend-confirmed completion, bounded pending, unknown submission/dismissal, scope mismatch, safe errors, replay/concurrent operations and plugin behavior. `npm pack` verifies the distributable includes compiled JS/types and the Expo plugin. Before release, verify fresh Expo installation and native builds on both platforms, actual card-only PaymentSheet (no saved cards/Link/wallets), issuer 3DS success/cancellation, app switching/process death, accessibility/text scaling and real Gateway webhook timing.
147
+
148
+ The 0.1.x releases are initial integration releases, not certification of a live payment flow. iOS simulator Release build and styled/unstyled UI checks have passed; actual PaymentSheet/3DS with a configured backend and Android native verification remain outstanding. Complete those checks before a production integration.
149
+
150
+ ## License
151
+
152
+ MIT; see [LICENSE](./LICENSE). This license covers the Spring code in this SDK package, not Spring's backend, other repository directories, trademarks or access to payment services. Third-party dependencies retain their own licenses; Stripe services remain subject to their own terms. Preserve required notices in redistributed apps. The source repository may remain private; recipients may reuse and redistribute this package under MIT.
153
+
154
+ ## Maintaining and publishing (Spring maintainers)
155
+
156
+ Source of truth: `packages/spring-payments-react-native/` in the private `ktechgit/spring-payments` repository. Edit `src/`, `app.plugin.js` and this README here. `dist/` is generated; do not edit it. The repository's `examples/embedded-partner-expo/` is the integration consumer, not the package source. Local tarballs below are for maintainer release validation only, not an alternative partner installation route.
157
+
158
+ From this directory:
159
+
160
+ ```sh
161
+ npm ci
162
+ npm test
163
+ npm pack --dry-run
164
+ npm pack
165
+ ```
166
+
167
+ The `prepack` script compiles TypeScript. Inspect the actual tarball: only compiled `dist/*.js` and `dist/*.d.ts`, `app.plugin.js`, `README.md`, `LICENSE` and `package.json` belong in it. Do not ship source maps, tests, app configuration, credentials or stale build files. Install the tarball in a clean Expo consumer, check plugin resolution and types, and verify native builds and the supported payment flows before promoting a release for production use.
168
+
169
+ For subsequent releases, choose a semantic version and run `npm version patch --no-git-tag-version` (or `minor` / `major`) before packing; keep the lockfile synchronized. Never overwrite or reuse a published version. Review and commit source/version changes through Spring's release workflow.
170
+
171
+ Authenticate interactively with `npm login --registry=https://registry.npmjs.org/`; confirm the account with `npm whoami` and its publish access to `@spring-payments`. Keep npm credentials in the maintainer/build environment only. With release authorization, publish the exact inspected tarball (replace the version in its filename):
172
+
173
+ ```sh
174
+ npm publish ./spring-payments-payments-react-native-0.1.1.tgz --access public --registry=https://registry.npmjs.org/
175
+ npm view @spring-payments/payments-react-native@0.1.1 version license dist.integrity --registry=https://registry.npmjs.org/
176
+ ```
177
+
178
+ For a preview release, use a prerelease version and `--tag next`; do not move `latest` inadvertently. After registry verification, install the published version in the Expo demo, regenerate its lockfile, run its typecheck and Expo configuration check, and update partner installation instructions. An npm release does not deploy Gateway configuration, authorize a native application or complete device/processor certification.
package/app.plugin.js ADDED
@@ -0,0 +1,9 @@
1
+ // Expo already translates `scheme` into both platforms' native URL registrations.
2
+ module.exports = function withSpringPayments(config, { scheme } = {}) {
3
+ if (typeof scheme !== 'string' || !/^[a-z][a-z0-9+.-]+$/.test(scheme)
4
+ || ['http', 'https', 'file', 'data', 'javascript', 'intent', 'exp', 'exps'].includes(scheme)) {
5
+ throw new Error('Spring Payments requires a dedicated application URL scheme');
6
+ }
7
+ const schemes = config.scheme ? [].concat(config.scheme) : [];
8
+ return { ...config, scheme: [...new Set([...schemes, scheme])] };
9
+ };
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { type PressableProps, type StyleProp, type TextStyle, type ViewStyle } from 'react-native';
3
+ import { type PaymentSheet } from '@stripe/stripe-react-native';
4
+ import { type Options, type Result } from './session';
5
+ export type { Session, SessionRequest, Result } from './session';
6
+ export interface SpringPaymentsOptions extends Options {
7
+ appearance?: PaymentSheet.AppearanceParams;
8
+ saveCardLabel?: string;
9
+ }
10
+ type Payments = {
11
+ addCard(): Promise<Result>;
12
+ authenticatePayment(paymentId: string): Promise<Result>;
13
+ busy: boolean;
14
+ result?: Result;
15
+ };
16
+ /** Mount once above partner navigation so authentication returns keep their owner alive. */
17
+ export declare function SpringPaymentsProvider({ options, children }: React.PropsWithChildren<{
18
+ options: SpringPaymentsOptions;
19
+ }>): React.JSX.Element;
20
+ export declare function useSpringPayments(): Payments;
21
+ export declare function SpringAddCardButton({ label, busyLabel, busyMessage, disabled, style, textStyle, statusStyle, containerStyle, messages: customMessages, onResult }: {
22
+ label?: string;
23
+ busyLabel?: string;
24
+ busyMessage?: string;
25
+ disabled?: boolean;
26
+ style?: PressableProps['style'];
27
+ textStyle?: StyleProp<TextStyle>;
28
+ statusStyle?: StyleProp<TextStyle>;
29
+ containerStyle?: StyleProp<ViewStyle>;
30
+ messages?: Partial<Record<Result['status'], string>>;
31
+ onResult?: (result: Result) => void;
32
+ }): React.JSX.Element;
package/dist/index.js ADDED
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SpringPaymentsProvider = SpringPaymentsProvider;
4
+ exports.useSpringPayments = useSpringPayments;
5
+ exports.SpringAddCardButton = SpringAddCardButton;
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const react_1 = require("react");
8
+ const react_native_1 = require("react-native");
9
+ const react_native_url_polyfill_1 = require("react-native-url-polyfill");
10
+ const stripe_react_native_1 = require("@stripe/stripe-react-native");
11
+ const session_1 = require("./session");
12
+ const Context = (0, react_1.createContext)(null);
13
+ /** Mount once above partner navigation so authentication returns keep their owner alive. */
14
+ function SpringPaymentsProvider({ options, children }) {
15
+ const current = (0, react_1.useRef)(options);
16
+ current.current = options;
17
+ const [busy, setBusy] = (0, react_1.useState)(false);
18
+ const [result, setResult] = (0, react_1.useState)();
19
+ const mounted = (0, react_1.useRef)(true);
20
+ (0, react_1.useEffect)(() => {
21
+ mounted.current = true;
22
+ // Forward only this provider's return route; never log URLs carrying processor parameters.
23
+ const handle = (url) => {
24
+ if (!url)
25
+ return;
26
+ try {
27
+ const incoming = new react_native_url_polyfill_1.URL(url), expected = new react_native_url_polyfill_1.URL(current.current.returnUrl);
28
+ if (incoming.protocol === expected.protocol && incoming.host === expected.host
29
+ && incoming.pathname === expected.pathname)
30
+ void (0, stripe_react_native_1.handleURLCallback)(url).catch(() => { });
31
+ }
32
+ catch { /* Not a payment return URL. */ }
33
+ };
34
+ void react_native_1.Linking.getInitialURL().then(handle).catch(() => { });
35
+ const subscription = react_native_1.Linking.addEventListener('url', event => handle(event.url));
36
+ return () => { mounted.current = false; subscription.remove(); };
37
+ }, []);
38
+ const payments = (0, react_1.useMemo)(() => {
39
+ const config = { ...options, createSession: (request) => current.current.createSession(request) };
40
+ async function initialize(state) {
41
+ await (0, stripe_react_native_1.initStripe)({ publishableKey: state.publishableKey, urlScheme: new react_native_url_polyfill_1.URL(config.returnUrl).protocol.slice(0, -1) });
42
+ }
43
+ return (0, session_1.createPayments)(config, {
44
+ async addCard(state) {
45
+ await initialize(state);
46
+ const initialized = await (0, stripe_react_native_1.initPaymentSheet)({
47
+ merchantDisplayName: config.merchantDisplayName,
48
+ setupIntentClientSecret: state.clientSecret,
49
+ customFlow: false,
50
+ returnURL: config.returnUrl,
51
+ primaryButtonLabel: current.current.saveCardLabel || 'Save card',
52
+ appearance: current.current.appearance,
53
+ link: { display: stripe_react_native_1.LinkDisplay.NEVER },
54
+ // No CustomerSession/ephemeral credentials, wallets, or saved-card manager.
55
+ });
56
+ if (initialized.error)
57
+ throw new Error('UNAVAILABLE');
58
+ const presented = await (0, stripe_react_native_1.presentPaymentSheet)();
59
+ if (presented.error)
60
+ throw new Error('UNCONFIRMED');
61
+ },
62
+ async authenticatePayment(state) {
63
+ await initialize(state);
64
+ const retrieved = await (0, stripe_react_native_1.retrievePaymentIntent)(state.clientSecret);
65
+ if (retrieved.error || !retrieved.paymentIntent)
66
+ throw new Error('UNAVAILABLE');
67
+ const intent = retrieved.paymentIntent;
68
+ if (intent.status === 'RequiresAction') {
69
+ const response = await (0, stripe_react_native_1.handleNextAction)(state.clientSecret, config.returnUrl);
70
+ if (response.error)
71
+ throw new Error('UNCONFIRMED');
72
+ }
73
+ else if (intent.status === 'RequiresPaymentMethod' || intent.status === 'RequiresConfirmation') {
74
+ const paymentMethodId = intent.paymentMethod?.id || intent.lastPaymentError?.paymentMethod?.id || intent.paymentMethodId;
75
+ if (!paymentMethodId)
76
+ throw new Error('UNAVAILABLE');
77
+ const response = await (0, stripe_react_native_1.confirmPayment)(state.clientSecret, {
78
+ paymentMethodType: 'Card', paymentMethodData: { paymentMethodId },
79
+ });
80
+ if (response.error)
81
+ throw new Error('UNCONFIRMED');
82
+ }
83
+ else if (!['Succeeded', 'Processing'].includes(intent.status))
84
+ throw new Error('UNAVAILABLE');
85
+ },
86
+ }, undefined, undefined, react_native_url_polyfill_1.URL);
87
+ }, [options.applicationId, options.returnUrl, options.gatewayOrigin, options.merchantDisplayName]);
88
+ const pendingCalls = (0, react_1.useRef)(0);
89
+ const run = (0, react_1.useCallback)(async (operation) => {
90
+ pendingCalls.current++;
91
+ setBusy(true);
92
+ setResult(undefined);
93
+ try {
94
+ const value = await operation();
95
+ if (mounted.current)
96
+ setResult(value);
97
+ return value;
98
+ }
99
+ finally {
100
+ pendingCalls.current--;
101
+ if (mounted.current)
102
+ setBusy(pendingCalls.current > 0);
103
+ }
104
+ }, []);
105
+ return (0, jsx_runtime_1.jsx)(Context.Provider, { value: { busy, result,
106
+ addCard: () => run(payments.addCard), authenticatePayment: id => run(() => payments.authenticatePayment(id)) }, children: children });
107
+ }
108
+ function useSpringPayments() {
109
+ const payments = (0, react_1.useContext)(Context);
110
+ if (!payments)
111
+ throw new Error('Mount SpringPaymentsProvider above payment controls');
112
+ return payments;
113
+ }
114
+ function SpringAddCardButton({ label = 'Add debit or credit card', busyLabel = 'Saving card…', busyMessage = 'Complete verification, then wait for Spring confirmation.', disabled = false, style, textStyle, statusStyle, containerStyle, messages: customMessages, onResult }) {
115
+ const payments = useSpringPayments();
116
+ const [result, setResult] = (0, react_1.useState)();
117
+ const messages = { COMPLETED: 'Card saved.', PENDING: 'Card setup is not yet confirmed. Check your saved cards before trying again.',
118
+ CANCELLED: 'Card entry cancelled.', FAILED: 'Unable to save the card. Please try again.', ...customMessages };
119
+ return (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: containerStyle, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityState: { disabled: disabled || payments.busy, busy: payments.busy }, disabled: disabled || payments.busy, style: style, onPress: () => { setResult(undefined); void payments.addCard().then(value => { setResult(value); onResult?.(value); }); }, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: textStyle, children: payments.busy ? busyLabel : label }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { accessibilityLiveRegion: "polite", style: statusStyle, children: payments.busy ? busyMessage : result ? messages[result.status] : '' })] });
120
+ }
@@ -0,0 +1,51 @@
1
+ export type Purpose = 'ADD_CARD' | 'AUTHENTICATE_PAYMENT';
2
+ export interface Session {
3
+ sessionId: string;
4
+ sessionToken: string;
5
+ expiresAt: string;
6
+ componentUrl: string;
7
+ }
8
+ export interface SessionRequest {
9
+ purpose: Purpose;
10
+ applicationId: string;
11
+ returnUrl: string;
12
+ paymentId?: string;
13
+ }
14
+ export interface State {
15
+ purpose: Purpose;
16
+ status: string;
17
+ publishableKey?: string;
18
+ clientSecret?: string;
19
+ returnUrl: string;
20
+ paymentAccountId?: string;
21
+ paymentId?: string;
22
+ }
23
+ export type Result = {
24
+ status: 'COMPLETED';
25
+ paymentAccountId?: string;
26
+ paymentId?: string;
27
+ } | {
28
+ status: 'PENDING';
29
+ sessionId?: string;
30
+ paymentId?: string;
31
+ } | {
32
+ status: 'CANCELLED';
33
+ } | {
34
+ status: 'FAILED';
35
+ code: 'UNAVAILABLE' | 'EXPIRED' | 'REJECTED';
36
+ };
37
+ export interface Options {
38
+ applicationId: string;
39
+ returnUrl: string;
40
+ gatewayOrigin: string;
41
+ merchantDisplayName: string;
42
+ createSession: (request: SessionRequest) => Promise<Session>;
43
+ }
44
+ export interface Processor {
45
+ addCard(state: State): Promise<void>;
46
+ authenticatePayment(state: State): Promise<void>;
47
+ }
48
+ export declare function createPayments(options: Options, processor: Processor, transport?: typeof fetch, delay?: (ms: number) => Promise<unknown>, URLParser?: new (value: string) => Pick<URL, 'protocol' | 'origin' | 'username' | 'password' | 'search' | 'hash' | 'hostname' | 'host' | 'pathname' | 'href'>): {
49
+ addCard: () => Promise<Result>;
50
+ authenticatePayment: (paymentId: string) => Promise<Result>;
51
+ };
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createPayments = createPayments;
4
+ // PaymentSheet and Stripe initialization are process-wide. Never overlap presentations,
5
+ // even when partners accidentally mount multiple providers.
6
+ let presenting = false;
7
+ function createPayments(options, processor, transport = fetch, delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)), URLParser = URL) {
8
+ const gateway = new URLParser(options.gatewayOrigin);
9
+ const returnUrl = new URLParser(options.returnUrl);
10
+ if (gateway.protocol !== 'https:' || gateway.origin !== options.gatewayOrigin
11
+ || gateway.username || gateway.password || gateway.search || gateway.hash
12
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(options.applicationId)
13
+ || !returnUrl.hostname || returnUrl.username || returnUrl.password || returnUrl.search || returnUrl.hash
14
+ || returnUrl.host !== 'safepay' || returnUrl.pathname !== ''
15
+ || ['http:', 'https:', 'file:', 'data:', 'javascript:', 'intent:', 'exp:', 'exps:'].includes(returnUrl.protocol)
16
+ || !options.merchantDisplayName.trim())
17
+ throw new Error('Invalid Spring Payments configuration');
18
+ let active;
19
+ let activeOperation;
20
+ function run(purpose, paymentId) {
21
+ const operation = `${purpose}:${paymentId || ''}`;
22
+ if (active) {
23
+ return activeOperation === operation ? active : Promise.resolve({ status: 'FAILED', code: 'UNAVAILABLE' });
24
+ }
25
+ if (presenting)
26
+ return Promise.resolve({ status: 'FAILED', code: 'UNAVAILABLE' });
27
+ presenting = true;
28
+ activeOperation = operation;
29
+ active = execute(purpose, paymentId).finally(() => {
30
+ active = undefined;
31
+ activeOperation = undefined;
32
+ presenting = false;
33
+ });
34
+ return active;
35
+ }
36
+ async function execute(purpose, paymentId) {
37
+ let session;
38
+ let potentiallySubmitted = false;
39
+ const pending = () => ({ status: 'PENDING', ...(session ? { sessionId: session.sessionId } : {}),
40
+ ...(paymentId ? { paymentId } : {}) });
41
+ try {
42
+ if (purpose === 'AUTHENTICATE_PAYMENT' && !paymentId)
43
+ return { status: 'FAILED', code: 'UNAVAILABLE' };
44
+ session = await options.createSession({ purpose, applicationId: options.applicationId,
45
+ returnUrl: options.returnUrl, ...(paymentId ? { paymentId } : {}) });
46
+ const endpoint = new URLParser(session.componentUrl);
47
+ if (endpoint.origin !== gateway.origin || endpoint.username || endpoint.password || endpoint.search || endpoint.hash
48
+ || !/^[a-f0-9-]{36}$/.test(session.sessionId)
49
+ || endpoint.pathname !== `/api/public/payment-components/native-sessions/${session.sessionId}`
50
+ || !session.sessionToken || !Number.isFinite(Date.parse(session.expiresAt))) {
51
+ return { status: 'FAILED', code: 'UNAVAILABLE' };
52
+ }
53
+ async function read() {
54
+ if (Date.parse(session.expiresAt) <= Date.now())
55
+ throw new Error('EXPIRED');
56
+ const abort = new AbortController();
57
+ const timeout = setTimeout(() => abort.abort(), 15000);
58
+ try {
59
+ const response = await transport(endpoint.href, { method: 'GET',
60
+ headers: { Authorization: `Bearer ${session.sessionToken}`, 'X-Spring-Application-Id': options.applicationId },
61
+ credentials: 'omit', cache: 'no-store', redirect: 'error', signal: abort.signal });
62
+ if (!response.ok)
63
+ throw new Error(response.status === 410 ? 'EXPIRED' : 'UNAVAILABLE');
64
+ const state = await response.json();
65
+ if (state.purpose !== purpose || state.returnUrl !== options.returnUrl
66
+ || (paymentId && state.paymentId !== paymentId))
67
+ throw new Error('UNAVAILABLE');
68
+ return state;
69
+ }
70
+ finally {
71
+ clearTimeout(timeout);
72
+ }
73
+ }
74
+ function result(state) {
75
+ if (state.status === 'COMPLETED' || purpose === 'AUTHENTICATE_PAYMENT' && state.status === 'SUCCEEDED') {
76
+ if (purpose === 'ADD_CARD' && !state.paymentAccountId)
77
+ return pending();
78
+ return { status: 'COMPLETED', ...(state.paymentAccountId ? { paymentAccountId: state.paymentAccountId } : {}),
79
+ ...(paymentId ? { paymentId } : {}) };
80
+ }
81
+ if (['REJECTED', 'PAYMENT_FAILED', 'FAILED'].includes(state.status))
82
+ return { status: 'FAILED', code: 'REJECTED' };
83
+ }
84
+ let state = await read();
85
+ const existing = result(state);
86
+ if (existing)
87
+ return existing;
88
+ if (['READY', 'AUTHENTICATION_REQUIRED'].includes(state.status)) {
89
+ if (!state.publishableKey || !state.clientSecret)
90
+ throw new Error('UNAVAILABLE');
91
+ // A dismissed native sheet can follow a submission or 3DS attempt. Reconcile,
92
+ // never infer that closing the sheet undid an operation.
93
+ potentiallySubmitted = true;
94
+ try {
95
+ if (purpose === 'ADD_CARD')
96
+ await processor.addCard(state);
97
+ else
98
+ await processor.authenticatePayment(state);
99
+ }
100
+ catch {
101
+ state = await read();
102
+ return result(state) || pending();
103
+ }
104
+ }
105
+ else
106
+ potentiallySubmitted = true;
107
+ for (let attempt = 0; attempt < 10; attempt++) {
108
+ state = await read();
109
+ const finished = result(state);
110
+ if (finished)
111
+ return finished;
112
+ if (state.status === 'AUTHENTICATION_REQUIRED')
113
+ return pending();
114
+ if (attempt < 9)
115
+ await delay(1500);
116
+ }
117
+ return pending();
118
+ }
119
+ catch (error) {
120
+ if (potentiallySubmitted)
121
+ return pending();
122
+ return { status: 'FAILED', code: error instanceof Error && error.message === 'EXPIRED' ? 'EXPIRED' : 'UNAVAILABLE' };
123
+ }
124
+ }
125
+ return { addCard: () => run('ADD_CARD'), authenticatePayment: (paymentId) => run('AUTHENTICATE_PAYMENT', paymentId) };
126
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@spring-payments/payments-react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "description": "Spring-managed native card saving and payment authentication",
6
+ "publishConfig": {
7
+ "access": "public",
8
+ "registry": "https://registry.npmjs.org/"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/ktechgit/spring-payments.git",
13
+ "directory": "packages/spring-payments-react-native"
14
+ },
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "react-native": "dist/index.js",
18
+ "files": [
19
+ "dist",
20
+ "app.plugin.js",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json",
26
+ "test": "npm run build && node --test test/*.test.cjs",
27
+ "prepack": "npm run build"
28
+ },
29
+ "peerDependencies": {
30
+ "@stripe/stripe-react-native": "0.64.x",
31
+ "react": ">=19.2.0 <20",
32
+ "react-native": "0.86.x"
33
+ },
34
+ "devDependencies": {
35
+ "@stripe/stripe-react-native": "0.64.0",
36
+ "@types/react": "^19.2.0",
37
+ "react": "19.2.3",
38
+ "react-native": "0.86.3",
39
+ "typescript": "^5.9.3"
40
+ },
41
+ "dependencies": {
42
+ "react-native-url-polyfill": "^4.0.0"
43
+ }
44
+ }