@funnelsgrove/payments 0.7.3 → 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 +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/providers/solidgate/components/SolidgateCheckoutDialog.d.ts +25 -0
- package/dist/providers/solidgate/components/SolidgateCheckoutDialog.js +250 -0
- package/dist/providers/solidgate/components/index.d.ts +1 -0
- package/dist/providers/solidgate/components/index.js +1 -0
- package/dist/providers/solidgate/index.d.ts +2 -0
- package/dist/providers/solidgate/index.js +2 -0
- package/dist/providers/solidgate/services/index.d.ts +1 -0
- package/dist/providers/solidgate/services/index.js +1 -0
- package/dist/providers/solidgate/services/solidgate.service.d.ts +59 -0
- package/dist/providers/solidgate/services/solidgate.service.js +196 -0
- package/package.json +3 -1
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.
|
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 @@
|
|
|
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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@funnelsgrove/payments",
|
|
3
|
-
"version": "0.7.
|
|
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
|
}
|