@funnelsgrove/payments 0.31.0 → 0.33.0

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.
@@ -46,6 +46,7 @@ type ManageSubscription = RuntimeSubscription & {
46
46
  };
47
47
  export declare function ManageSubscriptionScreen({ stepId, content, homeStepId, nodeId, plans, }: ManageSubscriptionScreenProps): import("react/jsx-runtime").JSX.Element;
48
48
  export declare const __manageSubscriptionScreenTestables: {
49
+ isCancelledActiveSubscription: (subscription: ManageSubscription) => boolean;
49
50
  resolveBillingDateLabel: (subscription: Pick<ManageSubscription, "cancelAtPeriodEnd" | "currentPeriodEnd" | "status">, options?: {
50
51
  cancelled?: boolean;
51
52
  past?: boolean;
@@ -8,7 +8,8 @@ const pastSubscriptionsNote = 'Past subscriptions are already canceled or expire
8
8
  const cancelledSubscriptionsNote = 'These subscriptions are cancelled and remain active until the date shown below.';
9
9
  const getNormalizedStatus = (subscription) => subscription.status.trim().toLowerCase();
10
10
  const isCancelledActiveSubscription = (subscription) => {
11
- return subscription.cancelAtPeriodEnd && getNormalizedStatus(subscription) !== 'canceled';
11
+ return subscription.cancelAtPeriodEnd && (getNormalizedStatus(subscription) !== 'canceled'
12
+ || Date.parse(subscription.currentPeriodEnd || '') > Date.now());
12
13
  };
13
14
  const isCancellableSubscription = (subscription) => {
14
15
  return getNormalizedStatus(subscription) !== 'canceled' && !subscription.cancelAtPeriodEnd;
@@ -243,6 +244,7 @@ function SubscriptionSection({ title, subscriptions, plans, onSelect, descriptio
243
244
  }) })] }));
244
245
  }
245
246
  export const __manageSubscriptionScreenTestables = {
247
+ isCancelledActiveSubscription,
246
248
  resolveBillingDateLabel,
247
249
  resolveSubscriptionStatusLabel,
248
250
  };
@@ -1 +1 @@
1
- export declare const PAYMENTS_PACKAGE_VERSION = "0.31.0";
1
+ export declare const PAYMENTS_PACKAGE_VERSION = "0.33.0";
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/generate-package-version.mjs.
2
- export const PAYMENTS_PACKAGE_VERSION = "0.31.0";
2
+ export const PAYMENTS_PACKAGE_VERSION = "0.33.0";
package/dist/index.d.ts CHANGED
@@ -15,3 +15,5 @@ export * from './providers/solidgate/index.js';
15
15
  export { ManageSubscriptionScreen, type ManageSubscriptionContent, type ManageSubscriptionScreenProps, type ManageSubscriptionLabels, } from './components/ManageSubscriptionScreen.js';
16
16
  export { isCheckoutEmailError, isValidCheckoutEmail } from './services/checkoutEmail.js';
17
17
  export * from './components/CheckoutEmailDialog.js';
18
+ export { PayPalCheckout, type PayPalCheckoutProps } from './providers/paypal/PayPalCheckout.js';
19
+ export { readPendingPayPalCheckout } from './providers/paypal/paypalCheckout.service.js';
package/dist/index.js CHANGED
@@ -18,3 +18,5 @@ export * from './providers/solidgate/index.js';
18
18
  export { ManageSubscriptionScreen, } from './components/ManageSubscriptionScreen.js';
19
19
  export { isCheckoutEmailError, isValidCheckoutEmail } from './services/checkoutEmail.js';
20
20
  export * from './components/CheckoutEmailDialog.js';
21
+ export { PayPalCheckout } from './providers/paypal/PayPalCheckout.js';
22
+ export { readPendingPayPalCheckout } from './providers/paypal/paypalCheckout.service.js';
@@ -0,0 +1,8 @@
1
+ import { type PayPalCheckoutInput } from './paypalCheckout.service.js';
2
+ export type PayPalCheckoutProps = PayPalCheckoutInput & {
3
+ onSuccess: () => void;
4
+ onPreparationStart?: () => void;
5
+ onPendingChange?: (pending: boolean) => void;
6
+ };
7
+ /** A keyed instance owns one merchant/plan selection throughout buyer approval. */
8
+ export declare function PayPalCheckout(props: PayPalCheckoutProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,210 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { completePaywallCheckout, retainPaywallCheckout } from '@funnelsgrove/runtime';
4
+ import { loadPayPalSdk } from './paypalSdk.js';
5
+ import { PayPalCheckoutRequestError, readPendingPayPalCheckout, storePendingPayPalCheckout, preparePayPalCheckout, verifyPayPalCheckout } from './paypalCheckout.service.js';
6
+ /** A keyed instance owns one merchant/plan selection throughout buyer approval. */
7
+ export function PayPalCheckout(props) {
8
+ const container = useRef(null);
9
+ const attemptKey = useRef(crypto.randomUUID());
10
+ const callbacks = useRef(props);
11
+ callbacks.current = props;
12
+ const [error, setError] = useState(null);
13
+ const [state, setState] = useState('loading');
14
+ const [retry, setRetry] = useState(0);
15
+ const [summary, setSummary] = useState(props.option.summary);
16
+ const verifyRef = useRef(null);
17
+ const inputKey = JSON.stringify([props.option, props.environment, props.funnelEndUserId, props.offerSetId, props.funnelPlanKey, props.runtimeConfig]);
18
+ useEffect(() => {
19
+ var _a, _b, _c, _d, _e;
20
+ const pending = readPendingPayPalCheckout(callbacks.current);
21
+ const input = pending ? Object.assign(Object.assign({}, callbacks.current), { option: pending.option, offerSetId: pending.offerSetId, funnelPlanKey: pending.funnelPlanKey }) : callbacks.current;
22
+ setSummary(input.option.summary);
23
+ const controller = new AbortController();
24
+ let button;
25
+ let attempt = null;
26
+ let verifying = false;
27
+ let approved = false;
28
+ let blocked = false;
29
+ let completed = false;
30
+ let idempotencyKey = attemptKey.current;
31
+ const storageKey = `fg-paypal:${input.environment}:${((_a = input.runtimeConfig) === null || _a === void 0 ? void 0 : _a.funnelId) || ''}:${input.funnelEndUserId}:${input.offerSetId}:${input.funnelPlanKey}:${input.option.paymentProfileId}:${input.option.providerPlanId}`;
32
+ try {
33
+ const saved = sessionStorage.getItem(storageKey);
34
+ if (saved === null || saved === void 0 ? void 0 : saved.startsWith('{')) {
35
+ const record = JSON.parse(saved);
36
+ if (typeof record.key === 'string')
37
+ idempotencyKey = record.key;
38
+ if (typeof ((_b = record.attempt) === null || _b === void 0 ? void 0 : _b.attemptId) === 'string' && typeof ((_c = record.attempt) === null || _c === void 0 ? void 0 : _c.subscriptionId) === 'string') {
39
+ attempt = record.attempt;
40
+ approved = record.approved === true;
41
+ }
42
+ }
43
+ else if (saved)
44
+ idempotencyKey = saved;
45
+ }
46
+ catch ( /* In-memory key still protects retries when browser storage is disabled. */_f) { /* In-memory key still protects retries when browser storage is disabled. */ }
47
+ if (pending) {
48
+ attempt = pending.attempt;
49
+ approved = pending.approved !== false;
50
+ idempotencyKey = pending.idempotencyKey;
51
+ }
52
+ const persist = () => {
53
+ try {
54
+ sessionStorage.setItem(storageKey, JSON.stringify({ key: idempotencyKey, attempt, approved }));
55
+ }
56
+ catch ( /* Optional persistence. */_a) { /* Optional persistence. */ }
57
+ if (attempt)
58
+ storePendingPayPalCheckout(input, { option: input.option, offerSetId: input.offerSetId, funnelPlanKey: input.funnelPlanKey, attempt, idempotencyKey, approved });
59
+ };
60
+ persist();
61
+ const showError = (message) => {
62
+ var _a, _b;
63
+ if (controller.signal.aborted || blocked || completed)
64
+ return;
65
+ setError(message);
66
+ setState(attempt ? 'pending' : 'ready');
67
+ if (!attempt)
68
+ (_b = (_a = callbacks.current).onPendingChange) === null || _b === void 0 ? void 0 : _b.call(_a, false);
69
+ };
70
+ const showRequestError = (error, fallback) => {
71
+ var _a, _b;
72
+ if (controller.signal.aborted || completed)
73
+ return;
74
+ if (error instanceof PayPalCheckoutRequestError) {
75
+ blocked = true;
76
+ setError(error.message);
77
+ setState('blocked');
78
+ (_b = (_a = callbacks.current).onPendingChange) === null || _b === void 0 ? void 0 : _b.call(_a, true);
79
+ return;
80
+ }
81
+ showError(fallback);
82
+ };
83
+ const verify = async () => {
84
+ var _a, _b;
85
+ if (!attempt || verifying || controller.signal.aborted)
86
+ return;
87
+ verifying = true;
88
+ setError(null);
89
+ setState('verifying');
90
+ try {
91
+ // Polling is bounded; a pending payment retains the same attempt for a manual recheck.
92
+ for (let index = 0; index < 20 && !controller.signal.aborted; index += 1) {
93
+ const status = await verifyPayPalCheckout(input, attempt, controller.signal);
94
+ if (controller.signal.aborted)
95
+ return;
96
+ if (status === 'succeeded') {
97
+ const embedded = await completePaywallCheckout('paypal', attempt.attemptId, { signal: controller.signal });
98
+ if (controller.signal.aborted)
99
+ return;
100
+ completed = true;
101
+ setState('success');
102
+ storePendingPayPalCheckout(input, null);
103
+ try {
104
+ sessionStorage.removeItem(storageKey);
105
+ }
106
+ catch ( /* Optional persistence. */_c) { /* Optional persistence. */ }
107
+ (_b = (_a = callbacks.current).onPendingChange) === null || _b === void 0 ? void 0 : _b.call(_a, false);
108
+ if (!embedded)
109
+ callbacks.current.onSuccess();
110
+ return;
111
+ }
112
+ if (status === 'requires_payment' && !approved) {
113
+ await renderButton();
114
+ return;
115
+ }
116
+ await new Promise(resolve => {
117
+ const done = () => { clearTimeout(timeout); controller.signal.removeEventListener('abort', done); resolve(); };
118
+ const timeout = setTimeout(done, 1500);
119
+ controller.signal.addEventListener('abort', done, { once: true });
120
+ });
121
+ }
122
+ showError('PayPal is still confirming your subscription. Check its status before trying another purchase.');
123
+ }
124
+ catch (error) {
125
+ showRequestError(error, 'Unable to confirm your subscription yet. Check payment status to continue.');
126
+ }
127
+ finally {
128
+ verifying = false;
129
+ }
130
+ };
131
+ verifyRef.current = verify;
132
+ setState('loading');
133
+ setError(null);
134
+ const renderButton = async () => {
135
+ if (controller.signal.aborted)
136
+ return;
137
+ if (button) {
138
+ setState('ready');
139
+ return;
140
+ }
141
+ const sdk = await loadPayPalSdk(input.option.clientId, input.environment, input.option.currency);
142
+ if (controller.signal.aborted || !container.current)
143
+ return;
144
+ button = sdk.Buttons({
145
+ fundingSource: sdk.FUNDING.PAYPAL, style: { label: 'paypal', layout: 'vertical', height: 48 },
146
+ createSubscription: async () => {
147
+ var _a, _b, _c, _d;
148
+ if (controller.signal.aborted || completed || blocked)
149
+ throw new Error('This PayPal checkout is no longer accepting approval.');
150
+ (_b = (_a = callbacks.current).onPreparationStart) === null || _b === void 0 ? void 0 : _b.call(_a);
151
+ (_d = (_c = callbacks.current).onPendingChange) === null || _d === void 0 ? void 0 : _d.call(_c, true);
152
+ setError(null);
153
+ setState('approving');
154
+ try {
155
+ if (attempt) {
156
+ const status = await verifyPayPalCheckout(input, attempt, controller.signal);
157
+ if (status !== 'requires_payment') {
158
+ await verify();
159
+ throw new Error('Check payment status before approving this subscription.');
160
+ }
161
+ }
162
+ else {
163
+ attempt = await preparePayPalCheckout(input, idempotencyKey, controller.signal);
164
+ }
165
+ persist();
166
+ retainPaywallCheckout('paypal', attempt.attemptId, input.option.paymentProfileId);
167
+ return attempt.subscriptionId;
168
+ }
169
+ catch (error) {
170
+ showRequestError(error, 'PayPal is temporarily unavailable. Please try again.');
171
+ throw error;
172
+ }
173
+ },
174
+ onApprove: async (data) => {
175
+ if (controller.signal.aborted || completed || blocked)
176
+ return;
177
+ if (!attempt || data.subscriptionID !== attempt.subscriptionId) {
178
+ showError('Unable to match this PayPal approval. Check payment status.');
179
+ return;
180
+ }
181
+ approved = true;
182
+ persist();
183
+ await verify();
184
+ },
185
+ onCancel: () => { var _a, _b; if (!controller.signal.aborted && !approved && !completed && !blocked) {
186
+ attempt = null;
187
+ persist();
188
+ storePendingPayPalCheckout(input, null);
189
+ setState('ready');
190
+ (_b = (_a = callbacks.current).onPendingChange) === null || _b === void 0 ? void 0 : _b.call(_a, false);
191
+ } },
192
+ onError: () => showError('PayPal is temporarily unavailable. Please try again.'),
193
+ });
194
+ await button.render(container.current);
195
+ if (!controller.signal.aborted)
196
+ setState('ready');
197
+ };
198
+ if (attempt) {
199
+ (_e = (_d = callbacks.current).onPendingChange) === null || _e === void 0 ? void 0 : _e.call(_d, true);
200
+ retainPaywallCheckout('paypal', attempt.attemptId, input.option.paymentProfileId);
201
+ void verify();
202
+ }
203
+ else {
204
+ void renderButton().catch(() => showError('Unable to load PayPal. Please try again.'));
205
+ }
206
+ return () => { controller.abort(); verifyRef.current = null; void (button === null || button === void 0 ? void 0 : button.close().catch(() => undefined)); };
207
+ }, [inputKey, retry]);
208
+ return _jsxs("section", { "aria-label": 'PayPal payment', className: 'space-y-3', children: [_jsxs("p", { className: 'text-sm', children: ["PayPal \u00B7 ", summary] }), _jsx("div", { ref: container, hidden: state === 'blocked' || state === 'pending' || state === 'verifying' || state === 'success' }), state === 'loading' ? _jsx("p", { role: 'status', children: "Loading PayPal\u2026" }) : null, state === 'verifying' ? _jsx("p", { role: 'status', children: "Confirming your subscription\u2026" }) : null, state === 'success' ? _jsx("p", { role: 'status', children: "Subscription confirmed." }) : null, error ? _jsx("p", { role: 'alert', children: error }) : null, state === 'pending' ? _jsx("button", { type: 'button', onClick: () => { var _a; return void ((_a = verifyRef.current) === null || _a === void 0 ? void 0 : _a.call(verifyRef)); }, children: "Check payment status" })
209
+ : error && state !== 'blocked' ? _jsx("button", { type: 'button', onClick: () => setRetry(value => value + 1), children: "Retry PayPal" }) : null] });
210
+ }
@@ -0,0 +1,35 @@
1
+ import { type FunnelSdkRuntimeConfigOverrides } from '@funnelsgrove/runtime';
2
+ import type { PayPalBillingOption } from '../../services/publishedBillingRuntime.service.js';
3
+ export type PayPalCheckoutInput = {
4
+ option: PayPalBillingOption;
5
+ environment: 'test' | 'live';
6
+ funnelEndUserId: string;
7
+ offerSetId: string;
8
+ funnelPlanKey: string;
9
+ runtimeConfig?: FunnelSdkRuntimeConfigOverrides;
10
+ };
11
+ export type PayPalCheckoutAttempt = {
12
+ attemptId: string;
13
+ subscriptionId: string;
14
+ };
15
+ declare const checkoutErrorMessages: {
16
+ checkout_unavailable: string;
17
+ checkout_outcome_unresolved: string;
18
+ already_subscribed_to_product: string;
19
+ };
20
+ export declare class PayPalCheckoutRequestError extends Error {
21
+ readonly code: keyof typeof checkoutErrorMessages;
22
+ constructor(code: keyof typeof checkoutErrorMessages);
23
+ }
24
+ export declare function preparePayPalCheckout(input: PayPalCheckoutInput, idempotencyKey: string, signal: AbortSignal): Promise<PayPalCheckoutAttempt>;
25
+ export declare function verifyPayPalCheckout(input: PayPalCheckoutInput, attempt: PayPalCheckoutAttempt, signal: AbortSignal): Promise<'succeeded' | 'requires_payment' | 'verifying' | 'preparing'>;
26
+ export type PendingPayPalCheckout = Pick<PayPalCheckoutInput, 'option' | 'offerSetId' | 'funnelPlanKey'> & {
27
+ attempt: PayPalCheckoutAttempt;
28
+ idempotencyKey: string;
29
+ approved?: boolean;
30
+ };
31
+ type PendingScope = Pick<PayPalCheckoutInput, 'runtimeConfig' | 'environment' | 'funnelEndUserId'>;
32
+ /** A prepared checkout must survive a lost approval callback or default-plan change. */
33
+ export declare const readPendingPayPalCheckout: (scope: PendingScope) => PendingPayPalCheckout | null;
34
+ export declare const storePendingPayPalCheckout: (scope: PendingScope, value: PendingPayPalCheckout | null) => void;
35
+ export {};
@@ -0,0 +1,87 @@
1
+ import { FUNNEL_ID, FUNNEL_SDK_PUBLISHABLE_KEY, buildMainApiUrl, resolvePreviewPaymentPublishableKey } from '@funnelsgrove/runtime';
2
+ const record = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
3
+ const checkoutErrorMessages = {
4
+ checkout_unavailable: 'This PayPal checkout is no longer available. Please contact support before starting another purchase.',
5
+ checkout_outcome_unresolved: 'We are still checking this PayPal checkout. Please wait or contact support before starting another purchase.',
6
+ already_subscribed_to_product: 'You already have an active subscription to this product.',
7
+ };
8
+ export class PayPalCheckoutRequestError extends Error {
9
+ constructor(code) {
10
+ super(checkoutErrorMessages[code]);
11
+ this.code = code;
12
+ this.name = 'PayPalCheckoutRequestError';
13
+ }
14
+ }
15
+ async function request(input, path, signal, body) {
16
+ var _a, _b, _c, _d;
17
+ const key = resolvePreviewPaymentPublishableKey((_b = (_a = input.runtimeConfig) === null || _a === void 0 ? void 0 : _a.funnelSdkPublishableKey) !== null && _b !== void 0 ? _b : FUNNEL_SDK_PUBLISHABLE_KEY);
18
+ const base = (_d = (_c = input.runtimeConfig) === null || _c === void 0 ? void 0 : _c.apiBaseUrl) === null || _d === void 0 ? void 0 : _d.trim().replace(/\/+$/, '');
19
+ const response = await fetch(base ? `${base}${path}` : buildMainApiUrl(path), Object.assign({ method: body ? 'POST' : 'GET', signal, headers: Object.assign(Object.assign({}, (body ? { 'content-type': 'application/json' } : {})), (key ? { 'x-sdk-publishable-key': key } : {})) }, (body ? { body: JSON.stringify(body) } : {})));
20
+ if (!response.ok) {
21
+ const failure = await response.json().catch(() => null);
22
+ if (record(failure) && (failure.code === 'checkout_unavailable'
23
+ || failure.code === 'checkout_outcome_unresolved' || failure.code === 'already_subscribed_to_product')) {
24
+ throw new PayPalCheckoutRequestError(failure.code);
25
+ }
26
+ throw new Error('Unable to confirm with PayPal. Please try again.');
27
+ }
28
+ const result = await response.json();
29
+ if (!record(result))
30
+ throw new Error('Invalid PayPal checkout response.');
31
+ return result;
32
+ }
33
+ export async function preparePayPalCheckout(input, idempotencyKey, signal) {
34
+ var _a;
35
+ const result = await request(input, '/sdk/public/v2/payments/checkout-sessions', signal, {
36
+ provider: 'paypal', checkoutType: 'subscription', uiMode: 'custom', environment: input.environment,
37
+ paymentProfileId: input.option.paymentProfileId, providerPlanId: input.option.providerPlanId,
38
+ funnelId: ((_a = input.runtimeConfig) === null || _a === void 0 ? void 0 : _a.funnelId) || FUNNEL_ID, funnelEndUserId: input.funnelEndUserId,
39
+ offerSetId: input.offerSetId, planKey: input.funnelPlanKey, idempotencyKey,
40
+ returnUrl: window.location.href, successUrl: window.location.href, failUrl: window.location.href,
41
+ });
42
+ const initialization = result.initialization;
43
+ if (typeof result.attemptId !== 'string' || !result.attemptId || !record(initialization)
44
+ || initialization.kind !== 'paypal' || initialization.clientId !== input.option.clientId
45
+ || typeof initialization.subscriptionId !== 'string' || !initialization.subscriptionId)
46
+ throw new Error('Invalid PayPal checkout response.');
47
+ return { attemptId: result.attemptId, subscriptionId: initialization.subscriptionId };
48
+ }
49
+ export async function verifyPayPalCheckout(input, attempt, signal) {
50
+ var _a;
51
+ const query = new URLSearchParams({ provider: 'paypal', funnelId: ((_a = input.runtimeConfig) === null || _a === void 0 ? void 0 : _a.funnelId) || FUNNEL_ID, funnelEndUserId: input.funnelEndUserId });
52
+ const result = await request(input, `/sdk/public/v2/payments/checkout-sessions/${encodeURIComponent(attempt.attemptId)}?${query}`, signal);
53
+ if (result.attemptId !== attempt.attemptId)
54
+ throw new Error('Invalid PayPal checkout response.');
55
+ if (result.status === 'failed' || result.status === 'expired')
56
+ throw new PayPalCheckoutRequestError('checkout_unavailable');
57
+ if (result.status !== 'succeeded' && result.status !== 'requires_payment'
58
+ && result.status !== 'verifying' && result.status !== 'preparing')
59
+ throw new Error('Invalid PayPal checkout response.');
60
+ return result.status;
61
+ }
62
+ const pendingKey = (scope) => { var _a; return `fg-paypal-pending:${scope.environment}:${((_a = scope.runtimeConfig) === null || _a === void 0 ? void 0 : _a.funnelId) || FUNNEL_ID}:${scope.funnelEndUserId}`; };
63
+ /** A prepared checkout must survive a lost approval callback or default-plan change. */
64
+ export const readPendingPayPalCheckout = (scope) => {
65
+ try {
66
+ const value = JSON.parse(sessionStorage.getItem(pendingKey(scope)) || 'null');
67
+ if (!record(value) || !record(value.attempt) || !record(value.option)
68
+ || ![value.offerSetId, value.funnelPlanKey, value.idempotencyKey, value.attempt.attemptId, value.attempt.subscriptionId,
69
+ value.option.paymentProfileId, value.option.providerPlanId, value.option.clientId, value.option.currency, value.option.summary]
70
+ .every(field => typeof field === 'string' && field.length > 0)
71
+ || typeof value.option.amountMinor !== 'number')
72
+ return null;
73
+ return value;
74
+ }
75
+ catch (_a) {
76
+ return null;
77
+ }
78
+ };
79
+ export const storePendingPayPalCheckout = (scope, value) => {
80
+ try {
81
+ if (value)
82
+ sessionStorage.setItem(pendingKey(scope), JSON.stringify(value));
83
+ else
84
+ sessionStorage.removeItem(pendingKey(scope));
85
+ }
86
+ catch ( /* Browser storage can be unavailable; the mounted checkout still retains ownership. */_a) { /* Browser storage can be unavailable; the mounted checkout still retains ownership. */ }
87
+ };
@@ -0,0 +1,25 @@
1
+ type PayPalButton = {
2
+ render: (container: HTMLElement) => Promise<void>;
3
+ close: () => Promise<void>;
4
+ };
5
+ export type PayPalSdk = {
6
+ FUNDING: {
7
+ PAYPAL: string;
8
+ };
9
+ Buttons: (options: {
10
+ fundingSource: string;
11
+ style: {
12
+ label: string;
13
+ layout: string;
14
+ height: number;
15
+ };
16
+ createSubscription: () => Promise<string>;
17
+ onApprove: (data: {
18
+ subscriptionID: string;
19
+ }) => Promise<void>;
20
+ onCancel: () => void;
21
+ onError: () => void;
22
+ }) => PayPalButton;
23
+ };
24
+ export declare function loadPayPalSdk(clientId: string, environment: 'test' | 'live', currency: string): Promise<PayPalSdk>;
25
+ export {};
@@ -0,0 +1,33 @@
1
+ const instances = new Map();
2
+ let sequence = 0;
3
+ export function loadPayPalSdk(clientId, environment, currency) {
4
+ const key = JSON.stringify([clientId, environment, currency]);
5
+ const existing = instances.get(key);
6
+ if (existing)
7
+ return existing;
8
+ const namespace = `fgPayPal${++sequence}`;
9
+ const pending = new Promise((resolve, reject) => {
10
+ const script = document.createElement('script');
11
+ const query = new URLSearchParams({ 'client-id': clientId, components: 'buttons', intent: 'subscription', vault: 'true', currency,
12
+ 'integration-date': '2026-09-30' });
13
+ script.src = `https://www.paypal.com/sdk/js?${query}`;
14
+ script.dataset.namespace = namespace;
15
+ script.async = true;
16
+ const failed = () => { clearTimeout(timeout); script.remove(); reject(new Error('Unable to load PayPal. Please try again.')); };
17
+ const timeout = setTimeout(failed, 20000);
18
+ script.onerror = failed;
19
+ script.onload = () => {
20
+ clearTimeout(timeout);
21
+ const sdk = window[namespace];
22
+ if (!(sdk === null || sdk === void 0 ? void 0 : sdk.Buttons)) {
23
+ failed();
24
+ return;
25
+ }
26
+ resolve(sdk);
27
+ };
28
+ document.head.appendChild(script);
29
+ });
30
+ instances.set(key, pending);
31
+ void pending.catch(() => instances.delete(key));
32
+ return pending;
33
+ }
@@ -2,14 +2,23 @@ import { type FunnelExperimentDefinition, type FunnelManifestExperiment, type Of
2
2
  import type { BillingDiscountCatalog, BillingDiscountList, BillingFallbackPlanConfig, BillingPlanCatalog } from '../config/billing.config.js';
3
3
  import { type PaywallPlan } from './paywallPlans.service.js';
4
4
  export type PublishedBillingPlan = BillingFallbackPlanConfig & OfferSetRuntimePlanOverrides;
5
+ export type PayPalBillingOption = {
6
+ paymentProfileId: string;
7
+ clientId: string;
8
+ providerPlanId: string;
9
+ amountMinor: number;
10
+ currency: string;
11
+ summary: string;
12
+ };
5
13
  export type ResolvedPublishedBillingRuntime = {
14
+ paypalOptionsByPlanKey?: Readonly<Record<string, PayPalBillingOption>>;
6
15
  revisionId: string | null;
7
16
  sourceVersionId: string | null;
8
17
  paymentsApiVersion: 'v2';
9
18
  mode: RuntimeMode;
10
19
  offerSetId: string;
11
20
  paymentProfileId: string | null;
12
- provider: 'stripe' | 'solidgate';
21
+ provider: 'stripe' | 'solidgate' | 'paypal';
13
22
  stripePublishableKey: string | null;
14
23
  experimentId: string | null;
15
24
  variantKey: string | null;
@@ -10,7 +10,7 @@ export const EMPTY_PUBLISHED_BILLING_RUNTIME = Object.freeze({
10
10
  unavailablePlanKeys: [],
11
11
  });
12
12
  export const resolvePublishedBillingRuntime = ({ config, featureFlags, mode, search = '', defaultOfferSetId, locale, baseCatalogsByMode, fallbackDiscounts, resolvedExperiments = resolvePublishedRuntimeExperiments(config), }) => {
13
- var _a, _b, _c, _d, _e, _f, _g, _h;
13
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
14
14
  const offerSets = (_a = config === null || config === void 0 ? void 0 : config.offerSets) !== null && _a !== void 0 ? _a : [];
15
15
  const selection = resolvePublishedOfferSetSelection({
16
16
  offerSets,
@@ -37,19 +37,36 @@ export const resolvePublishedBillingRuntime = ({ config, featureFlags, mode, sea
37
37
  const discountList = runtime.getDiscountList(mode);
38
38
  const provider = runtime.getPaymentProvider(mode);
39
39
  const paymentProviderConfig = (_c = (_b = runtime.offerSet) === null || _b === void 0 ? void 0 : _b.paymentProviderConfigByMode) === null || _c === void 0 ? void 0 : _c[mode];
40
+ const paypalOptionsByPlanKey = {};
41
+ for (const item of ((_d = runtime.offerSet) === null || _d === void 0 ? void 0 : _d.items) || []) {
42
+ const binding = (_e = item.paymentProfileBindings) === null || _e === void 0 ? void 0 : _e.find(candidate => candidate.provider === 'paypal');
43
+ const profileId = (binding === null || binding === void 0 ? void 0 : binding.paymentProfileId) || (provider === 'paypal' ? (_f = runtime.offerSet) === null || _f === void 0 ? void 0 : _f.paymentProfileId : null);
44
+ if (!profileId)
45
+ continue;
46
+ const mapping = binding ? (_g = binding.providerMappingsByMode) === null || _g === void 0 ? void 0 : _g[mode] : (_h = item.providerMappingsByMode) === null || _h === void 0 ? void 0 : _h[mode];
47
+ const profileConfig = ((_l = (_k = (_j = runtime.offerSet) === null || _j === void 0 ? void 0 : _j.paymentProviderConfigByProfileId) === null || _k === void 0 ? void 0 : _k[profileId]) === null || _l === void 0 ? void 0 : _l[mode])
48
+ || (provider === 'paypal' ? paymentProviderConfig : null);
49
+ const clientId = (profileConfig === null || profileConfig === void 0 ? void 0 : profileConfig.provider) === 'paypal' ? profileConfig.paypalClientId : null;
50
+ if (!(mapping === null || mapping === void 0 ? void 0 : mapping.providerPlanId) || !clientId || !mapping.checkoutSummaryLabel || mapping.amountCents == null || !mapping.currency)
51
+ continue;
52
+ paypalOptionsByPlanKey[item.funnelPlanKey] = { paymentProfileId: profileId, clientId,
53
+ providerPlanId: mapping.providerPlanId, summary: mapping.checkoutSummaryLabel,
54
+ amountMinor: mapping.amountCents, currency: mapping.currency };
55
+ }
40
56
  const pinnedFeatureFlags = Object.assign(Object.assign({}, featureFlags), (selection.experimentId && selection.variantKey
41
57
  ? { [selection.experimentId]: selection.variantKey }
42
58
  : {}));
43
59
  return {
44
- revisionId: (_d = config === null || config === void 0 ? void 0 : config.revisionId) !== null && _d !== void 0 ? _d : null,
45
- sourceVersionId: (_e = config === null || config === void 0 ? void 0 : config.sourceVersionId) !== null && _e !== void 0 ? _e : null,
60
+ paypalOptionsByPlanKey,
61
+ revisionId: (_m = config === null || config === void 0 ? void 0 : config.revisionId) !== null && _m !== void 0 ? _m : null,
62
+ sourceVersionId: (_o = config === null || config === void 0 ? void 0 : config.sourceVersionId) !== null && _o !== void 0 ? _o : null,
46
63
  paymentsApiVersion: 'v2',
47
64
  mode,
48
65
  offerSetId: selection.offerSetId,
49
- paymentProfileId: ((_g = (_f = runtime.offerSet) === null || _f === void 0 ? void 0 : _f.paymentProfileId) === null || _g === void 0 ? void 0 : _g.trim()) || null,
66
+ paymentProfileId: ((_q = (_p = runtime.offerSet) === null || _p === void 0 ? void 0 : _p.paymentProfileId) === null || _q === void 0 ? void 0 : _q.trim()) || null,
50
67
  provider,
51
68
  stripePublishableKey: provider === 'stripe' && (paymentProviderConfig === null || paymentProviderConfig === void 0 ? void 0 : paymentProviderConfig.provider) === 'stripe'
52
- ? ((_h = paymentProviderConfig === null || paymentProviderConfig === void 0 ? void 0 : paymentProviderConfig.stripePublishableKey) === null || _h === void 0 ? void 0 : _h.trim()) || null
69
+ ? ((_r = paymentProviderConfig === null || paymentProviderConfig === void 0 ? void 0 : paymentProviderConfig.stripePublishableKey) === null || _r === void 0 ? void 0 : _r.trim()) || null
53
70
  : null,
54
71
  experimentId: selection.experimentId,
55
72
  variantKey: selection.variantKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/payments",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",
@@ -46,11 +46,11 @@
46
46
  "jsdom": "20.0.3",
47
47
  "typescript": "^5",
48
48
  "vitest": "^3.2.4",
49
- "@funnelsgrove/runtime": "0.26.0",
50
- "@funnelsgrove/analytics": "0.8.0"
49
+ "@funnelsgrove/runtime": "0.28.0",
50
+ "@funnelsgrove/analytics": "0.10.0"
51
51
  },
52
52
  "peerDependencies": {
53
- "@funnelsgrove/runtime": "^0.26.0",
54
- "@funnelsgrove/analytics": "^0.8.0"
53
+ "@funnelsgrove/runtime": "^0.28.0",
54
+ "@funnelsgrove/analytics": "^0.10.0"
55
55
  }
56
56
  }