@funnelsgrove/payments 0.7.2 → 0.7.4

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/README.md CHANGED
@@ -34,6 +34,12 @@ Use these for new one-time paywall plans:
34
34
 
35
35
  Use `completeStripeOneClickPayment` for normal post-checkout one-click upsells. It delegates the charge to `funnelSdkService.chargeOneClickPayment` and owns any required Stripe confirmation. `chargeStripeOneClickPayment` remains the lower-level charge-only primitive.
36
36
 
37
+ Solidgate browser checkout is available for controlled provider-routed funnels through
38
+ `SolidgateCheckoutDialog`, `prepareSolidgateCheckout`, and `pollSolidgateCheckoutStatus`. The dialog
39
+ mounts one official Solidgate form per attempt, destroys the remote form on replacement/unmount,
40
+ and never treats iframe `success` as funnel completion. Its `onSuccess` callback runs only after the
41
+ provider-neutral status endpoint finds canonical shared-ledger payment evidence.
42
+
37
43
  ## Advanced Wallet Primitives
38
44
 
39
45
  `ApplePaySubscriptionCheckoutSlot`, `GooglePaySubscriptionCheckoutSlot`, and `WalletSubscriptionCheckoutSlot` stay exported and runtime-compatible. They are backward-compatible advanced low-level primitives: reach for them only when a placement needs wiring `StripeSubscriptionWalletSurface` does not cover. Never share one `useStripeSubscriptionCheckoutSession` result across placements; give each visible placement its own session.
@@ -84,6 +90,8 @@ When a live funnel still uses one of these, migrate the funnel deliberately with
84
90
  - `providers/stripe/hooks/*`: Stripe-backed plan resolution and isolated card/wallet checkout-session state.
85
91
  - `providers/stripe/components/*`: plan selector, checkout dialogs, Express Checkout wrappers, wallet slots, placeholders, and trust assets.
86
92
  - `providers/stripe/testing/*`: Stripe wallet checkout smoke assertions.
93
+ - `providers/solidgate/services/*`: strict Solidgate checkout preparation, status, and bounded polling clients.
94
+ - `providers/solidgate/components/*`: the official-SDK checkout dialog and canonical verification lifecycle.
87
95
  - `components/ManageSubscriptionScreen`: provider-neutral subscription management UI.
88
96
 
89
97
  Funnels should call `resolvePublishedBillingRuntime` once at their runtime-provider boundary. Screens consume its resolved view and should not parse offer sets or pricing-experiment variants themselves.
@@ -92,6 +100,7 @@ Funnels should call `resolvePublishedBillingRuntime` once at their runtime-provi
92
100
 
93
101
  - Keep every provider-specific component, hook, service, and test helper under `src/providers/<provider>`. Add future providers as sibling folders; do not place provider SDK imports in root `components`, `hooks`, or `services`.
94
102
  - Stripe owns real card fields and wallet controls. Local wallet buttons are placeholders until Stripe confirms availability.
103
+ - Keep `planId` as the funnel plan key and send Stripe's price id only as `providerPlanId`; the API uses both fields to resolve the active test/live offer mapping.
95
104
  - Wallet unavailable state must fall back to card/manual checkout or disappear.
96
105
  - Count `checkout_started` only at shared UI boundaries: when a shared card dialog opens or when Stripe reports an Apple Pay / Google Pay button click.
97
106
  - Pass `checkoutAnalytics` to every current shared dialog and wallet surface. The package then owns `checkout_started`, `add_payment_info`, and verified `checkout_completed`, all deduplicated by the actual Checkout Session.
package/dist/index.d.ts CHANGED
@@ -8,4 +8,5 @@ export * from './services/paywallOffer.service.js';
8
8
  export * from './services/paymentMethodAnalytics.service.js';
9
9
  export * from './providers/paymentProvider.types.js';
10
10
  export * from './providers/stripe/index.js';
11
+ export * from './providers/solidgate/index.js';
11
12
  export { ManageSubscriptionScreen, type ManageSubscriptionContent, type ManageSubscriptionScreenProps, } from './components/ManageSubscriptionScreen.js';
package/dist/index.js CHANGED
@@ -10,5 +10,6 @@ export * from './services/paymentMethodAnalytics.service.js';
10
10
  export * from './providers/paymentProvider.types.js';
11
11
  // Provider-specific integrations are grouped behind provider barrels.
12
12
  export * from './providers/stripe/index.js';
13
+ export * from './providers/solidgate/index.js';
13
14
  // Provider-neutral subscription management UI.
14
15
  export { ManageSubscriptionScreen, } from './components/ManageSubscriptionScreen.js';
@@ -0,0 +1,25 @@
1
+ import { type PrepareSolidgateCheckoutInput, type SolidgateCheckoutPreparation } from '../services/solidgate.service.js';
2
+ export type SolidgateCheckoutDialogState = 'loading' | 'ready' | 'submitting' | 'verifying' | 'success' | 'recoverable_error' | 'expired';
3
+ export type SolidgateCheckoutDialogContent = {
4
+ title: string;
5
+ closeAriaLabel: string;
6
+ loading: string;
7
+ submitting: string;
8
+ verifying: string;
9
+ success: string;
10
+ error: string;
11
+ expired: string;
12
+ retry: string;
13
+ resumeVerification: string;
14
+ };
15
+ export type SolidgateCheckoutDialogProps = {
16
+ open: boolean;
17
+ request: Omit<PrepareSolidgateCheckoutInput, 'signal'>;
18
+ content?: Partial<SolidgateCheckoutDialogContent>;
19
+ onClose: () => void;
20
+ onSuccess?: (checkout: SolidgateCheckoutPreparation) => void | Promise<void>;
21
+ onRetry?: () => void;
22
+ onError?: (message: string) => void;
23
+ onStateChange?: (state: SolidgateCheckoutDialogState) => void;
24
+ };
25
+ export declare function SolidgateCheckoutDialog({ open, request, content, onClose, onSuccess, onRetry, onError, onStateChange, }: SolidgateCheckoutDialogProps): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,250 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
4
+ import { pollSolidgateCheckoutStatus, prepareSolidgateCheckout, } from '../services/solidgate.service.js';
5
+ const SolidgatePayment = lazy(async () => ({
6
+ default: (await import('@solidgate/react-sdk')).default,
7
+ }));
8
+ const defaultContent = {
9
+ title: 'Secure checkout',
10
+ closeAriaLabel: 'Close checkout',
11
+ loading: 'Preparing your secure checkout…',
12
+ submitting: 'Submitting payment…',
13
+ verifying: 'Verifying payment securely…',
14
+ success: 'Payment confirmed.',
15
+ error: 'Payment could not be completed. Please try again.',
16
+ expired: 'This checkout has expired. Start a new payment to continue.',
17
+ retry: 'Try again',
18
+ resumeVerification: 'Check payment status',
19
+ };
20
+ const initialView = {
21
+ state: 'loading',
22
+ checkout: null,
23
+ canResumeVerification: false,
24
+ };
25
+ const isAbortError = (error) => (error instanceof DOMException && error.name === 'AbortError');
26
+ const safeDestroyPaymentForm = (instance) => {
27
+ var _a;
28
+ try {
29
+ instance === null || instance === void 0 ? void 0 : instance.unsubscribeAll();
30
+ }
31
+ catch (_b) {
32
+ // The global destroy below is the final cleanup boundary.
33
+ }
34
+ try {
35
+ (_a = window.PaymentFormSdk) === null || _a === void 0 ? void 0 : _a.destroy();
36
+ }
37
+ catch (_c) {
38
+ // Cleanup must stay safe when the remote SDK is partially initialized.
39
+ }
40
+ };
41
+ function SolidgatePaymentForm({ checkout, hidden, loadingLabel, onError, onMounted, onSubmit, onVerify, }) {
42
+ const instanceRef = useRef(null);
43
+ useEffect(() => () => {
44
+ safeDestroyPaymentForm(instanceRef.current);
45
+ instanceRef.current = null;
46
+ }, [checkout.attemptId]);
47
+ return (_jsx("div", { className: hidden ? 'hidden' : 'block', "data-solidgate-attempt-id": checkout.attemptId, "aria-hidden": hidden || undefined, children: _jsx(Suspense, { fallback: (_jsx("p", { role: 'status', className: 'text-sm text-muted-foreground', children: loadingLabel })), children: _jsx(SolidgatePayment, { merchantData: checkout.initialization.merchantData, width: '100%', formParams: { isSolidLogoVisible: true }, onReadyPaymentInstance: (instance) => {
48
+ if (instanceRef.current && instanceRef.current !== instance) {
49
+ try {
50
+ instanceRef.current.unsubscribeAll();
51
+ }
52
+ catch (_a) {
53
+ // The active instance remains usable even if stale-listener cleanup fails.
54
+ }
55
+ }
56
+ instanceRef.current = instance;
57
+ }, onMounted: onMounted, onSubmit: onSubmit, onVerify: onVerify, onSuccess: onVerify, onFail: onVerify, onError: onError }, checkout.attemptId) }) }));
58
+ }
59
+ export function SolidgateCheckoutDialog({ open, request, content, onClose, onSuccess, onRetry, onError, onStateChange, }) {
60
+ const copy = useMemo(() => (Object.assign(Object.assign({}, defaultContent), content)), [content]);
61
+ const [view, setView] = useState(initialView);
62
+ const prepareAbortRef = useRef(null);
63
+ const statusAbortRef = useRef(null);
64
+ const successAttemptRef = useRef(null);
65
+ const closeButtonRef = useRef(null);
66
+ const copyRef = useRef(copy);
67
+ const onErrorRef = useRef(onError);
68
+ const onStateChangeRef = useRef(onStateChange);
69
+ const onSuccessRef = useRef(onSuccess);
70
+ useEffect(() => {
71
+ copyRef.current = copy;
72
+ onErrorRef.current = onError;
73
+ onStateChangeRef.current = onStateChange;
74
+ onSuccessRef.current = onSuccess;
75
+ }, [copy, onError, onStateChange, onSuccess]);
76
+ const { discountKeyOrProviderId, failUrl, funnelEndUserId, funnelId, idempotencyKey, offerSetId, projectBillingPlanId, returnUrl, runtimeConfig, successUrl, } = request;
77
+ const { apiBaseUrl, funnelId: runtimeFunnelId, funnelSdkPublishableKey, funnelVersionId, } = runtimeConfig || {};
78
+ const hasRuntimeConfig = runtimeConfig !== undefined;
79
+ const normalizedRequest = useMemo(() => ({
80
+ funnelId: (funnelId === null || funnelId === void 0 ? void 0 : funnelId.trim()) || null,
81
+ funnelEndUserId: funnelEndUserId.trim(),
82
+ offerSetId: offerSetId.trim(),
83
+ projectBillingPlanId: projectBillingPlanId.trim(),
84
+ idempotencyKey: idempotencyKey.trim(),
85
+ discountKeyOrProviderId: (discountKeyOrProviderId === null || discountKeyOrProviderId === void 0 ? void 0 : discountKeyOrProviderId.trim()) || null,
86
+ returnUrl: returnUrl.trim(),
87
+ successUrl: successUrl.trim(),
88
+ failUrl: failUrl.trim(),
89
+ runtimeConfig: hasRuntimeConfig ? {
90
+ apiBaseUrl: (apiBaseUrl === null || apiBaseUrl === void 0 ? void 0 : apiBaseUrl.trim()) || null,
91
+ funnelId: (runtimeFunnelId === null || runtimeFunnelId === void 0 ? void 0 : runtimeFunnelId.trim()) || null,
92
+ funnelVersionId: (funnelVersionId === null || funnelVersionId === void 0 ? void 0 : funnelVersionId.trim()) || null,
93
+ funnelSdkPublishableKey: (funnelSdkPublishableKey === null || funnelSdkPublishableKey === void 0 ? void 0 : funnelSdkPublishableKey.trim()) || null,
94
+ } : undefined,
95
+ }), [
96
+ apiBaseUrl,
97
+ discountKeyOrProviderId,
98
+ failUrl,
99
+ funnelEndUserId,
100
+ funnelId,
101
+ funnelSdkPublishableKey,
102
+ funnelVersionId,
103
+ idempotencyKey,
104
+ offerSetId,
105
+ projectBillingPlanId,
106
+ returnUrl,
107
+ hasRuntimeConfig,
108
+ runtimeFunnelId,
109
+ successUrl,
110
+ ]);
111
+ const setState = useCallback((nextView) => {
112
+ var _a;
113
+ setView(nextView);
114
+ (_a = onStateChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onStateChangeRef, nextView.state);
115
+ }, []);
116
+ useEffect(() => {
117
+ var _a, _b, _c, _d;
118
+ if (!open) {
119
+ (_a = prepareAbortRef.current) === null || _a === void 0 ? void 0 : _a.abort();
120
+ (_b = statusAbortRef.current) === null || _b === void 0 ? void 0 : _b.abort();
121
+ return;
122
+ }
123
+ const controller = new AbortController();
124
+ (_c = prepareAbortRef.current) === null || _c === void 0 ? void 0 : _c.abort();
125
+ (_d = statusAbortRef.current) === null || _d === void 0 ? void 0 : _d.abort();
126
+ prepareAbortRef.current = controller;
127
+ successAttemptRef.current = null;
128
+ queueMicrotask(() => {
129
+ if (!controller.signal.aborted) {
130
+ setState(initialView);
131
+ }
132
+ });
133
+ void prepareSolidgateCheckout(Object.assign(Object.assign({}, normalizedRequest), { signal: controller.signal }))
134
+ .then((checkout) => {
135
+ if (!controller.signal.aborted) {
136
+ setState({ state: 'ready', checkout, canResumeVerification: false });
137
+ }
138
+ })
139
+ .catch((error) => {
140
+ var _a;
141
+ if (!controller.signal.aborted && !isAbortError(error)) {
142
+ setState({ state: 'recoverable_error', checkout: null, canResumeVerification: false });
143
+ (_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, copyRef.current.error);
144
+ }
145
+ });
146
+ return () => controller.abort();
147
+ }, [normalizedRequest, open, setState]);
148
+ useEffect(() => {
149
+ var _a;
150
+ if (!open) {
151
+ return;
152
+ }
153
+ const previouslyFocused = document.activeElement instanceof HTMLElement
154
+ ? document.activeElement
155
+ : null;
156
+ const handleKeyDown = (event) => {
157
+ if (event.key === 'Escape') {
158
+ onClose();
159
+ }
160
+ };
161
+ window.addEventListener('keydown', handleKeyDown);
162
+ (_a = closeButtonRef.current) === null || _a === void 0 ? void 0 : _a.focus();
163
+ return () => {
164
+ window.removeEventListener('keydown', handleKeyDown);
165
+ previouslyFocused === null || previouslyFocused === void 0 ? void 0 : previouslyFocused.focus();
166
+ };
167
+ }, [onClose, open]);
168
+ useEffect(() => () => {
169
+ var _a, _b;
170
+ (_a = prepareAbortRef.current) === null || _a === void 0 ? void 0 : _a.abort();
171
+ (_b = statusAbortRef.current) === null || _b === void 0 ? void 0 : _b.abort();
172
+ }, []);
173
+ const verifyCanonicalStatus = useCallback(async () => {
174
+ var _a, _b, _c, _d;
175
+ const checkout = view.checkout;
176
+ if (!checkout || view.state === 'success') {
177
+ return;
178
+ }
179
+ (_a = statusAbortRef.current) === null || _a === void 0 ? void 0 : _a.abort();
180
+ const controller = new AbortController();
181
+ statusAbortRef.current = controller;
182
+ setState({ state: 'verifying', checkout, canResumeVerification: false });
183
+ try {
184
+ const result = await pollSolidgateCheckoutStatus({
185
+ attemptId: checkout.attemptId,
186
+ funnelId: normalizedRequest.funnelId,
187
+ funnelEndUserId: normalizedRequest.funnelEndUserId,
188
+ runtimeConfig: normalizedRequest.runtimeConfig,
189
+ signal: controller.signal,
190
+ });
191
+ if (controller.signal.aborted) {
192
+ return;
193
+ }
194
+ if (result.status === 'succeeded') {
195
+ setState({ state: 'success', checkout, canResumeVerification: false });
196
+ if (successAttemptRef.current !== checkout.attemptId) {
197
+ successAttemptRef.current = checkout.attemptId;
198
+ await ((_b = onSuccessRef.current) === null || _b === void 0 ? void 0 : _b.call(onSuccessRef, checkout));
199
+ }
200
+ }
201
+ else if (result.status === 'expired') {
202
+ setState({ state: 'expired', checkout, canResumeVerification: false });
203
+ }
204
+ else if (result.status === 'failed') {
205
+ setState({ state: 'recoverable_error', checkout, canResumeVerification: false });
206
+ (_c = onErrorRef.current) === null || _c === void 0 ? void 0 : _c.call(onErrorRef, copyRef.current.error);
207
+ }
208
+ else if (result.status === 'requires_payment') {
209
+ setState({ state: 'ready', checkout, canResumeVerification: false });
210
+ }
211
+ else {
212
+ setState({ state: 'verifying', checkout, canResumeVerification: result.timedOut });
213
+ }
214
+ }
215
+ catch (error) {
216
+ if (!controller.signal.aborted && !isAbortError(error)) {
217
+ setState({ state: 'recoverable_error', checkout, canResumeVerification: true });
218
+ (_d = onErrorRef.current) === null || _d === void 0 ? void 0 : _d.call(onErrorRef, copyRef.current.error);
219
+ }
220
+ }
221
+ }, [normalizedRequest, setState, view]);
222
+ if (!open) {
223
+ return null;
224
+ }
225
+ const showForm = view.state === 'ready'
226
+ || view.state === 'submitting'
227
+ || view.state === 'verifying';
228
+ const statusMessage = view.state === 'loading'
229
+ ? copy.loading
230
+ : view.state === 'submitting'
231
+ ? copy.submitting
232
+ : view.state === 'verifying'
233
+ ? copy.verifying
234
+ : view.state === 'success'
235
+ ? copy.success
236
+ : view.state === 'expired'
237
+ ? copy.expired
238
+ : view.state === 'recoverable_error'
239
+ ? copy.error
240
+ : null;
241
+ return (_jsx("div", { className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4', children: _jsxs("div", { role: 'dialog', "aria-modal": 'true', "aria-labelledby": 'solidgate-checkout-title', "data-checkout-state": view.state, className: 'w-full max-w-lg rounded-2xl border border-border bg-card p-5 text-foreground shadow-xl', children: [_jsxs("div", { className: 'mb-4 flex items-center justify-between gap-4', children: [_jsx("h2", { id: 'solidgate-checkout-title', className: 'text-lg font-semibold text-foreground', children: copy.title }), _jsx("button", { ref: closeButtonRef, type: 'button', onClick: onClose, "aria-label": copy.closeAriaLabel, className: 'rounded-md border border-border bg-background px-3 py-1.5 text-foreground hover:bg-accent', children: "\u00D7" })] }), view.checkout ? (_jsx(SolidgatePaymentForm, { checkout: view.checkout, hidden: !showForm, loadingLabel: copy.loading, onMounted: () => {
242
+ if (view.state === 'loading') {
243
+ setState(Object.assign(Object.assign({}, view), { state: 'ready' }));
244
+ }
245
+ }, onSubmit: () => setState(Object.assign(Object.assign({}, view), { state: 'submitting' })), onVerify: () => void verifyCanonicalStatus(), onError: () => {
246
+ var _a;
247
+ setState(Object.assign(Object.assign({}, view), { state: 'recoverable_error', canResumeVerification: false }));
248
+ (_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, copyRef.current.error);
249
+ } }, view.checkout.attemptId)) : null, statusMessage && view.state !== 'ready' ? (_jsx("div", { className: 'rounded-xl border border-border bg-background p-4 text-sm text-muted-foreground', children: _jsx("p", { role: 'status', "aria-live": 'polite', children: statusMessage }) })) : null, view.state === 'verifying' && view.canResumeVerification ? (_jsx("button", { type: 'button', onClick: () => void verifyCanonicalStatus(), className: 'mt-4 w-full rounded-lg border border-border bg-accent px-4 py-2 font-medium text-foreground', children: copy.resumeVerification })) : null, (view.state === 'recoverable_error' || view.state === 'expired') && onRetry ? (_jsx("button", { type: 'button', onClick: onRetry, className: 'mt-4 w-full rounded-lg border border-border bg-accent px-4 py-2 font-medium text-foreground', children: copy.retry })) : null] }) }));
250
+ }
@@ -0,0 +1 @@
1
+ export * from './SolidgateCheckoutDialog.js';
@@ -0,0 +1 @@
1
+ export * from './SolidgateCheckoutDialog.js';
@@ -0,0 +1,2 @@
1
+ export * from './components/index.js';
2
+ export * from './services/index.js';
@@ -0,0 +1,2 @@
1
+ export * from './components/index.js';
2
+ export * from './services/index.js';
@@ -0,0 +1 @@
1
+ export * from './solidgate.service.js';
@@ -0,0 +1 @@
1
+ export * from './solidgate.service.js';
@@ -0,0 +1,59 @@
1
+ import { type FunnelSdkRuntimeConfigOverrides } from '@funnelsgrove/runtime';
2
+ export type SolidgateCheckoutStatus = 'preparing' | 'requires_payment' | 'verifying' | 'succeeded' | 'failed' | 'expired';
3
+ export type SolidgateCheckoutPreparation = {
4
+ attemptId: string;
5
+ display: {
6
+ title: string;
7
+ amountMinor: number;
8
+ currency: string;
9
+ billingInterval: string | null;
10
+ billingIntervalCount: number | null;
11
+ };
12
+ statusVerification: {
13
+ attemptId: string;
14
+ };
15
+ initialization: {
16
+ kind: 'solidgate';
17
+ merchantData: {
18
+ merchant: string;
19
+ signature: string;
20
+ paymentIntent: string;
21
+ };
22
+ };
23
+ };
24
+ export type PrepareSolidgateCheckoutInput = {
25
+ funnelId?: string | null;
26
+ funnelEndUserId: string;
27
+ offerSetId: string;
28
+ projectBillingPlanId: string;
29
+ idempotencyKey: string;
30
+ discountKeyOrProviderId?: string | null;
31
+ returnUrl: string;
32
+ successUrl: string;
33
+ failUrl: string;
34
+ runtimeConfig?: FunnelSdkRuntimeConfigOverrides;
35
+ signal?: AbortSignal;
36
+ };
37
+ export type RetrieveSolidgateCheckoutStatusInput = {
38
+ funnelId?: string | null;
39
+ funnelEndUserId: string;
40
+ attemptId: string;
41
+ runtimeConfig?: FunnelSdkRuntimeConfigOverrides;
42
+ signal?: AbortSignal;
43
+ };
44
+ export type SolidgateCheckoutStatusResponse = {
45
+ attemptId: string;
46
+ status: SolidgateCheckoutStatus;
47
+ retryAfterMs: number | null;
48
+ };
49
+ export type PollSolidgateCheckoutStatusResult = SolidgateCheckoutStatusResponse & {
50
+ timedOut: boolean;
51
+ };
52
+ export declare const prepareSolidgateCheckout: (input: PrepareSolidgateCheckoutInput) => Promise<SolidgateCheckoutPreparation>;
53
+ export declare const retrieveSolidgateCheckoutStatus: (input: RetrieveSolidgateCheckoutStatusInput) => Promise<SolidgateCheckoutStatusResponse>;
54
+ export declare const pollSolidgateCheckoutStatus: (input: RetrieveSolidgateCheckoutStatusInput, options?: {
55
+ maxDurationMs?: number;
56
+ now?: () => number;
57
+ delay?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
58
+ retrieveStatus?: typeof retrieveSolidgateCheckoutStatus;
59
+ }) => Promise<PollSolidgateCheckoutStatusResult>;
@@ -0,0 +1,196 @@
1
+ import { FUNNEL_ID, FUNNEL_SDK_PUBLISHABLE_KEY, buildMainApiUrl, resolvePreviewPaymentPublishableKey, } from '@funnelsgrove/runtime';
2
+ const trimTrailingSlash = (value) => value.replace(/\/+$/, '');
3
+ const requiredString = (value, name) => {
4
+ const normalized = typeof value === 'string' ? value.trim() : '';
5
+ if (!normalized) {
6
+ throw new Error(`Solidgate checkout response is missing ${name}`);
7
+ }
8
+ return normalized;
9
+ };
10
+ const resolveFunnelId = (inputFunnelId, runtimeConfig) => requiredString(inputFunnelId || (runtimeConfig === null || runtimeConfig === void 0 ? void 0 : runtimeConfig.funnelId) || FUNNEL_ID, 'funnelId');
11
+ const resolveApiUrl = (path, runtimeConfig) => {
12
+ var _a;
13
+ const configuredBaseUrl = (_a = runtimeConfig === null || runtimeConfig === void 0 ? void 0 : runtimeConfig.apiBaseUrl) === null || _a === void 0 ? void 0 : _a.trim();
14
+ return configuredBaseUrl
15
+ ? `${trimTrailingSlash(configuredBaseUrl)}${path}`
16
+ : buildMainApiUrl(path);
17
+ };
18
+ const resolvePublishableKey = (runtimeConfig) => {
19
+ var _a;
20
+ return resolvePreviewPaymentPublishableKey((_a = runtimeConfig === null || runtimeConfig === void 0 ? void 0 : runtimeConfig.funnelSdkPublishableKey) !== null && _a !== void 0 ? _a : FUNNEL_SDK_PUBLISHABLE_KEY) || null;
21
+ };
22
+ const postJson = async (path, body, runtimeConfig, signal) => {
23
+ const publishableKey = resolvePublishableKey(runtimeConfig);
24
+ const response = await fetch(resolveApiUrl(path, runtimeConfig), {
25
+ method: 'POST',
26
+ headers: Object.assign({ 'content-type': 'application/json' }, (publishableKey ? { 'x-sdk-publishable-key': publishableKey } : {})),
27
+ body: JSON.stringify(body),
28
+ signal,
29
+ });
30
+ if (!response.ok) {
31
+ throw new Error('Solidgate checkout is temporarily unavailable');
32
+ }
33
+ return response.json();
34
+ };
35
+ const isRecord = (value) => (Boolean(value) && typeof value === 'object' && !Array.isArray(value));
36
+ const parsePreparation = (value) => {
37
+ if (!isRecord(value) || !isRecord(value.display) || !isRecord(value.initialization)) {
38
+ throw new Error('Solidgate checkout returned an invalid response');
39
+ }
40
+ if (value.initialization.kind !== 'solidgate' || !isRecord(value.initialization.merchantData)) {
41
+ throw new Error('Solidgate checkout returned an invalid provider');
42
+ }
43
+ const amountMinor = value.display.amountMinor;
44
+ if (typeof amountMinor !== 'number' || !Number.isSafeInteger(amountMinor) || amountMinor < 0) {
45
+ throw new Error('Solidgate checkout returned an invalid amount');
46
+ }
47
+ const billingInterval = value.display.billingInterval;
48
+ const billingIntervalCount = value.display.billingIntervalCount;
49
+ if (billingInterval !== null && typeof billingInterval !== 'string') {
50
+ throw new Error('Solidgate checkout returned an invalid billing interval');
51
+ }
52
+ if (billingIntervalCount !== null
53
+ && (typeof billingIntervalCount !== 'number' || !Number.isSafeInteger(billingIntervalCount))) {
54
+ throw new Error('Solidgate checkout returned an invalid billing interval count');
55
+ }
56
+ const attemptId = requiredString(value.attemptId, 'attemptId');
57
+ return {
58
+ attemptId,
59
+ display: {
60
+ title: requiredString(value.display.title, 'display.title'),
61
+ amountMinor,
62
+ currency: requiredString(value.display.currency, 'display.currency'),
63
+ billingInterval,
64
+ billingIntervalCount,
65
+ },
66
+ statusVerification: { attemptId },
67
+ initialization: {
68
+ kind: 'solidgate',
69
+ merchantData: {
70
+ merchant: requiredString(value.initialization.merchantData.merchant, 'merchant'),
71
+ signature: requiredString(value.initialization.merchantData.signature, 'signature'),
72
+ paymentIntent: requiredString(value.initialization.merchantData.paymentIntent, 'paymentIntent'),
73
+ },
74
+ },
75
+ };
76
+ };
77
+ const statuses = new Set([
78
+ 'preparing',
79
+ 'requires_payment',
80
+ 'verifying',
81
+ 'succeeded',
82
+ 'failed',
83
+ 'expired',
84
+ ]);
85
+ const parseStatus = (value) => {
86
+ if (!isRecord(value) || typeof value.status !== 'string' || !statuses.has(value.status)) {
87
+ throw new Error('Solidgate checkout status returned an invalid response');
88
+ }
89
+ const retryAfterMs = value.retryAfterMs;
90
+ if (retryAfterMs !== null
91
+ && (typeof retryAfterMs !== 'number' || !Number.isFinite(retryAfterMs) || retryAfterMs < 0)) {
92
+ throw new Error('Solidgate checkout status returned an invalid retry delay');
93
+ }
94
+ return {
95
+ attemptId: requiredString(value.attemptId, 'attemptId'),
96
+ status: value.status,
97
+ retryAfterMs,
98
+ };
99
+ };
100
+ export const prepareSolidgateCheckout = async (input) => {
101
+ var _a;
102
+ return parsePreparation(await postJson('/sdk/public/payments/solidgate/checkout', {
103
+ funnelId: resolveFunnelId(input.funnelId, input.runtimeConfig),
104
+ funnelEndUserId: requiredString(input.funnelEndUserId, 'funnelEndUserId'),
105
+ offerSetId: requiredString(input.offerSetId, 'offerSetId'),
106
+ projectBillingPlanId: requiredString(input.projectBillingPlanId, 'projectBillingPlanId'),
107
+ idempotencyKey: requiredString(input.idempotencyKey, 'idempotencyKey'),
108
+ discountKeyOrProviderId: ((_a = input.discountKeyOrProviderId) === null || _a === void 0 ? void 0 : _a.trim()) || null,
109
+ returnUrl: requiredString(input.returnUrl, 'returnUrl'),
110
+ successUrl: requiredString(input.successUrl, 'successUrl'),
111
+ failUrl: requiredString(input.failUrl, 'failUrl'),
112
+ }, input.runtimeConfig, input.signal));
113
+ };
114
+ export const retrieveSolidgateCheckoutStatus = async (input) => {
115
+ const attemptId = requiredString(input.attemptId, 'attemptId');
116
+ const result = parseStatus(await postJson('/sdk/public/payments/solidgate/checkout/status', {
117
+ funnelId: resolveFunnelId(input.funnelId, input.runtimeConfig),
118
+ funnelEndUserId: requiredString(input.funnelEndUserId, 'funnelEndUserId'),
119
+ attemptId,
120
+ }, input.runtimeConfig, input.signal));
121
+ if (result.attemptId !== attemptId) {
122
+ throw new Error('Solidgate checkout status returned the wrong attempt');
123
+ }
124
+ return result;
125
+ };
126
+ const terminalStatuses = new Set(['succeeded', 'failed', 'expired']);
127
+ const abortableDelay = (delayMs, signal) => new Promise((resolve, reject) => {
128
+ const cleanup = () => signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
129
+ const timeout = setTimeout(() => {
130
+ cleanup();
131
+ resolve();
132
+ }, delayMs);
133
+ const onAbort = () => {
134
+ clearTimeout(timeout);
135
+ cleanup();
136
+ reject((signal === null || signal === void 0 ? void 0 : signal.reason) || new DOMException('Aborted', 'AbortError'));
137
+ };
138
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
139
+ onAbort();
140
+ return;
141
+ }
142
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onAbort, { once: true });
143
+ });
144
+ export const pollSolidgateCheckoutStatus = async (input, options = {}) => {
145
+ var _a, _b, _c, _d, _e;
146
+ const maxDurationMs = Math.min(60000, Math.max(0, (_a = options.maxDurationMs) !== null && _a !== void 0 ? _a : 60000));
147
+ const now = options.now || Date.now;
148
+ const delay = options.delay || abortableDelay;
149
+ const retrieveStatus = options.retrieveStatus || retrieveSolidgateCheckoutStatus;
150
+ const startedAt = now();
151
+ let fallbackDelayMs = 1000;
152
+ const deadlineController = new AbortController();
153
+ const forwardAbort = () => {
154
+ var _a;
155
+ return deadlineController.abort(((_a = input.signal) === null || _a === void 0 ? void 0 : _a.reason) || new DOMException('Aborted', 'AbortError'));
156
+ };
157
+ if ((_b = input.signal) === null || _b === void 0 ? void 0 : _b.aborted) {
158
+ forwardAbort();
159
+ }
160
+ else {
161
+ (_c = input.signal) === null || _c === void 0 ? void 0 : _c.addEventListener('abort', forwardAbort, { once: true });
162
+ }
163
+ const deadline = setTimeout(() => deadlineController.abort(new DOMException('Checkout status polling timed out', 'TimeoutError')), maxDurationMs);
164
+ try {
165
+ while (true) {
166
+ const observation = await retrieveStatus(Object.assign(Object.assign({}, input), { signal: deadlineController.signal }));
167
+ if (terminalStatuses.has(observation.status) || observation.status === 'requires_payment') {
168
+ return Object.assign(Object.assign({}, observation), { timedOut: false });
169
+ }
170
+ const elapsedMs = now() - startedAt;
171
+ if (elapsedMs >= maxDurationMs) {
172
+ return Object.assign(Object.assign({}, observation), { timedOut: true });
173
+ }
174
+ const requestedDelayMs = (_d = observation.retryAfterMs) !== null && _d !== void 0 ? _d : fallbackDelayMs;
175
+ const delayMs = Math.min(Math.max(250, requestedDelayMs), 5000, Math.max(0, maxDurationMs - elapsedMs));
176
+ await delay(delayMs, deadlineController.signal);
177
+ fallbackDelayMs = Math.min(fallbackDelayMs * 2, 5000);
178
+ }
179
+ }
180
+ catch (error) {
181
+ if (deadlineController.signal.reason instanceof DOMException
182
+ && deadlineController.signal.reason.name === 'TimeoutError') {
183
+ return {
184
+ attemptId: input.attemptId,
185
+ status: 'verifying',
186
+ retryAfterMs: 3000,
187
+ timedOut: true,
188
+ };
189
+ }
190
+ throw error;
191
+ }
192
+ finally {
193
+ clearTimeout(deadline);
194
+ (_e = input.signal) === null || _e === void 0 ? void 0 : _e.removeEventListener('abort', forwardAbort);
195
+ }
196
+ };
@@ -355,6 +355,9 @@ export function getPaywallPlanSelectionValue(plan) {
355
355
  var _a;
356
356
  return ((_a = plan.providerPlanId) === null || _a === void 0 ? void 0 : _a.trim()) || plan.id;
357
357
  }
358
+ // The API resolves offer-set mappings by the funnel plan key. Stripe's price id travels in
359
+ // providerPlanId; duplicating it into planId bypasses the configured test/live offer mapping.
360
+ const getPaywallPlanRequestId = (plan) => plan.id.trim() || getPaywallPlanSelectionValue(plan);
358
361
  export function findPaywallPlan(plans, selectionValue) {
359
362
  const normalizedSelectionValue = (selectionValue === null || selectionValue === void 0 ? void 0 : selectionValue.trim()) || '';
360
363
  if (!normalizedSelectionValue) {
@@ -554,7 +557,7 @@ export async function createStripeOneTimeCheckout(input) {
554
557
  return funnelSdkService.createOneTimeCheckout({
555
558
  offerSetId: ((_a = input.plan.offerSetId) === null || _a === void 0 ? void 0 : _a.trim()) || undefined,
556
559
  offerSetKey: ((_b = input.plan.offerSetKey) === null || _b === void 0 ? void 0 : _b.trim()) || undefined,
557
- planId: getPaywallPlanSelectionValue(input.plan),
560
+ planId: getPaywallPlanRequestId(input.plan),
558
561
  providerPlanId: ((_c = input.plan.providerPlanId) === null || _c === void 0 ? void 0 : _c.trim()) || undefined,
559
562
  title: input.plan.title,
560
563
  description: ((_d = input.plan.description) === null || _d === void 0 ? void 0 : _d.trim()) || undefined,
@@ -641,7 +644,7 @@ export async function createStripeSubscriptionCheckout(input) {
641
644
  return funnelSdkService.createSubscriptionCheckout({
642
645
  offerSetId: ((_a = input.plan.offerSetId) === null || _a === void 0 ? void 0 : _a.trim()) || undefined,
643
646
  offerSetKey: ((_b = input.plan.offerSetKey) === null || _b === void 0 ? void 0 : _b.trim()) || undefined,
644
- planId: getPaywallPlanSelectionValue(input.plan),
647
+ planId: getPaywallPlanRequestId(input.plan),
645
648
  providerPlanId: ((_c = input.plan.providerPlanId) === null || _c === void 0 ? void 0 : _c.trim()) || undefined,
646
649
  title: input.plan.title,
647
650
  description: ((_d = input.plan.description) === null || _d === void 0 ? void 0 : _d.trim()) || undefined,
@@ -672,7 +675,7 @@ export async function updateStripeSubscriptionCheckoutPlan(input) {
672
675
  checkoutSessionId: input.checkoutSessionId.trim(),
673
676
  offerSetId: ((_a = input.plan.offerSetId) === null || _a === void 0 ? void 0 : _a.trim()) || undefined,
674
677
  offerSetKey: ((_b = input.plan.offerSetKey) === null || _b === void 0 ? void 0 : _b.trim()) || undefined,
675
- planId: getPaywallPlanSelectionValue(input.plan),
678
+ planId: getPaywallPlanRequestId(input.plan),
676
679
  providerPlanId: ((_c = input.plan.providerPlanId) === null || _c === void 0 ? void 0 : _c.trim()) || undefined,
677
680
  title: input.plan.title,
678
681
  description: ((_d = input.plan.description) === null || _d === void 0 ? void 0 : _d.trim()) || undefined,
@@ -712,7 +715,7 @@ export async function createStripeCheckoutSession(input) {
712
715
  return funnelSdkService.createHostedCheckoutSession({
713
716
  offerSetId: ((_a = input.plan.offerSetId) === null || _a === void 0 ? void 0 : _a.trim()) || undefined,
714
717
  offerSetKey: ((_b = input.plan.offerSetKey) === null || _b === void 0 ? void 0 : _b.trim()) || undefined,
715
- planId: getPaywallPlanSelectionValue(input.plan),
718
+ planId: getPaywallPlanRequestId(input.plan),
716
719
  providerPlanId: ((_c = input.plan.providerPlanId) === null || _c === void 0 ? void 0 : _c.trim()) || undefined,
717
720
  title: input.plan.title,
718
721
  description: ((_d = input.plan.description) === null || _d === void 0 ? void 0 : _d.trim()) || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/payments",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",
@@ -34,6 +34,7 @@
34
34
  "dependencies": {
35
35
  "@funnelsgrove/analytics": "^0.1.52",
36
36
  "@funnelsgrove/runtime": "^0.7.3",
37
+ "@solidgate/react-sdk": "1.34.0",
37
38
  "@stripe/react-stripe-js": "^5.6.0",
38
39
  "@stripe/stripe-js": "^8.7.0",
39
40
  "react": "19.2.3",
@@ -44,6 +45,7 @@
44
45
  "@types/node": "^20",
45
46
  "@types/react": "^19",
46
47
  "@types/react-dom": "^19",
48
+ "jsdom": "20.0.3",
47
49
  "typescript": "^5",
48
50
  "vitest": "^3.2.4"
49
51
  }