@funnelsgrove/payments 0.1.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.
- package/README.md +36 -0
- package/dist/components/shared/StripePlanSelector.d.ts +22 -0
- package/dist/components/shared/StripePlanSelector.js +74 -0
- package/dist/config/billing.config.d.ts +123 -0
- package/dist/config/billing.config.js +38 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/services/preview-frame.service.d.ts +1 -0
- package/dist/services/preview-frame.service.js +1 -0
- package/dist/services/runtime-mode.service.d.ts +1 -0
- package/dist/services/runtime-mode.service.js +1 -0
- package/dist/services/stripe.service.d.ts +38 -0
- package/dist/services/stripe.service.js +283 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @funnelsgrove/payments
|
|
2
|
+
|
|
3
|
+
Shared billing and checkout helpers for funnels.
|
|
4
|
+
|
|
5
|
+
## Build And Publish
|
|
6
|
+
|
|
7
|
+
- Build distributable output with `npm run build --workspace @funnelsgrove/payments`.
|
|
8
|
+
- Publish from the repo root with `npm publish --workspace @funnelsgrove/payments --access public`.
|
|
9
|
+
- The package pins `@funnelsgrove/runtime` to the same exact version and the build resolves against the runtime package's emitted `dist` contract.
|
|
10
|
+
|
|
11
|
+
## Use This Package For
|
|
12
|
+
|
|
13
|
+
- Stripe plan loading and checkout integration
|
|
14
|
+
- global funnel billing catalog contracts
|
|
15
|
+
- reusable plan-selector and checkout UI
|
|
16
|
+
- billing catalog types and runtime helpers
|
|
17
|
+
|
|
18
|
+
## Responsibilities
|
|
19
|
+
|
|
20
|
+
- keep paywall behavior consistent across funnels
|
|
21
|
+
- define the shared billing types used by funnel-local catalogs
|
|
22
|
+
- expose one ordered funnel-local billing catalog contract
|
|
23
|
+
|
|
24
|
+
## What Belongs Here
|
|
25
|
+
|
|
26
|
+
- billing catalog types and normalization helpers
|
|
27
|
+
- Stripe SDK wiring
|
|
28
|
+
- checkout/plan-selector components
|
|
29
|
+
|
|
30
|
+
## What Does Not Belong Here
|
|
31
|
+
|
|
32
|
+
- funnel routing
|
|
33
|
+
- funnel analytics
|
|
34
|
+
- step-specific copy or layout
|
|
35
|
+
- funnel-owned billing catalog entries
|
|
36
|
+
- manifest validation
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { BillingFallbackPlanConfig, BillingPlanCatalog } from '../../config/billing.config';
|
|
3
|
+
import { type PaywallPlan } from '../../services/stripe.service';
|
|
4
|
+
import type { RuntimeMode } from '@funnelsgrove/runtime';
|
|
5
|
+
type StripePlanSelectorProps = {
|
|
6
|
+
planCatalog?: BillingPlanCatalog | readonly BillingFallbackPlanConfig[];
|
|
7
|
+
runtimeMode: RuntimeMode;
|
|
8
|
+
selectedPlanId: string | null;
|
|
9
|
+
onSelectPlan: (planId: string) => void;
|
|
10
|
+
onPlansLoaded?: (plans: readonly PaywallPlan[]) => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
loadingClassName?: string;
|
|
13
|
+
errorClassName?: string;
|
|
14
|
+
renderPlan: (input: {
|
|
15
|
+
plan: PaywallPlan;
|
|
16
|
+
index: number;
|
|
17
|
+
isSelected: boolean;
|
|
18
|
+
onSelect: () => void;
|
|
19
|
+
}) => ReactNode;
|
|
20
|
+
};
|
|
21
|
+
export declare function StripePlanSelector({ planCatalog, runtimeMode, selectedPlanId, onSelectPlan, onPlansLoaded, className, loadingClassName, errorClassName, renderPlan, }: StripePlanSelectorProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { getDefaultPlanId, getPaywallPlans, getFallbackPlans, } from '../../services/stripe.service';
|
|
5
|
+
import { isPreviewFrameRuntime } from '@funnelsgrove/runtime';
|
|
6
|
+
export function StripePlanSelector({ planCatalog, runtimeMode, selectedPlanId, onSelectPlan, onPlansLoaded, className, loadingClassName, errorClassName, renderPlan, }) {
|
|
7
|
+
const [plans, setPlans] = useState(() => getFallbackPlans(planCatalog));
|
|
8
|
+
const [loading, setLoading] = useState(true);
|
|
9
|
+
const [error, setError] = useState(null);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let isActive = true;
|
|
12
|
+
const load = async () => {
|
|
13
|
+
setLoading(true);
|
|
14
|
+
setError(null);
|
|
15
|
+
if (isPreviewFrameRuntime()) {
|
|
16
|
+
const fallbackPlans = getFallbackPlans(planCatalog);
|
|
17
|
+
setPlans(fallbackPlans);
|
|
18
|
+
onPlansLoaded === null || onPlansLoaded === void 0 ? void 0 : onPlansLoaded(fallbackPlans);
|
|
19
|
+
setLoading(false);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const nextPlans = await getPaywallPlans(runtimeMode, planCatalog);
|
|
24
|
+
if (!isActive) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
setPlans(nextPlans);
|
|
28
|
+
onPlansLoaded === null || onPlansLoaded === void 0 ? void 0 : onPlansLoaded(nextPlans);
|
|
29
|
+
}
|
|
30
|
+
catch (nextError) {
|
|
31
|
+
if (!isActive) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const fallbackPlans = getFallbackPlans(planCatalog);
|
|
35
|
+
setPlans(fallbackPlans);
|
|
36
|
+
onPlansLoaded === null || onPlansLoaded === void 0 ? void 0 : onPlansLoaded(fallbackPlans);
|
|
37
|
+
setError(nextError instanceof Error ? nextError.message : 'Unable to load plans');
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
if (isActive) {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
void load();
|
|
46
|
+
return () => {
|
|
47
|
+
isActive = false;
|
|
48
|
+
};
|
|
49
|
+
}, [onPlansLoaded, planCatalog, runtimeMode]);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (plans.length === 0) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (selectedPlanId && plans.some((plan) => plan.id === selectedPlanId)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const defaultPlanId = getDefaultPlanId(plans);
|
|
58
|
+
if (defaultPlanId) {
|
|
59
|
+
onSelectPlan(defaultPlanId);
|
|
60
|
+
}
|
|
61
|
+
}, [onSelectPlan, plans, selectedPlanId]);
|
|
62
|
+
const renderedPlans = useMemo(() => {
|
|
63
|
+
return plans.map((plan, index) => {
|
|
64
|
+
const isSelected = selectedPlanId === plan.id;
|
|
65
|
+
return renderPlan({
|
|
66
|
+
plan,
|
|
67
|
+
index,
|
|
68
|
+
isSelected,
|
|
69
|
+
onSelect: () => onSelectPlan(plan.id),
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}, [onSelectPlan, plans, renderPlan, selectedPlanId]);
|
|
73
|
+
return (_jsxs(_Fragment, { children: [loading ? _jsx("p", { className: loadingClassName, children: "Loading plans..." }) : null, error ? _jsx("p", { className: errorClassName, children: error }) : null, _jsx("div", { className: className, children: renderedPlans })] }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type BillingPlanComparison = {
|
|
2
|
+
perDay: string;
|
|
3
|
+
perWeek: string;
|
|
4
|
+
perMonth: string;
|
|
5
|
+
perYear: string;
|
|
6
|
+
};
|
|
7
|
+
export type BillingFallbackPlanConfig = {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
providerPlanId?: string;
|
|
12
|
+
priceLabel: string;
|
|
13
|
+
oldPriceLabel?: string;
|
|
14
|
+
featuredTag?: string;
|
|
15
|
+
perDayAmount: string;
|
|
16
|
+
perDayLabel: string;
|
|
17
|
+
amountCents: number;
|
|
18
|
+
comparison?: BillingPlanComparison;
|
|
19
|
+
checkoutOriginalAmountCents?: number;
|
|
20
|
+
checkoutSummaryLabel?: string;
|
|
21
|
+
};
|
|
22
|
+
export type BillingPlanCatalog = Record<string, BillingFallbackPlanConfig>;
|
|
23
|
+
export declare const billing: {
|
|
24
|
+
readonly plans: {
|
|
25
|
+
firstPlan: {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
providerPlanId: string;
|
|
29
|
+
priceLabel: string;
|
|
30
|
+
featuredTag: string;
|
|
31
|
+
perDayAmount: string;
|
|
32
|
+
perDayLabel: string;
|
|
33
|
+
amountCents: number;
|
|
34
|
+
comparison: {
|
|
35
|
+
perDay: string;
|
|
36
|
+
perWeek: string;
|
|
37
|
+
perMonth: string;
|
|
38
|
+
perYear: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
secondPlan: {
|
|
42
|
+
id: string;
|
|
43
|
+
title: string;
|
|
44
|
+
providerPlanId: string;
|
|
45
|
+
priceLabel: string;
|
|
46
|
+
oldPriceLabel: string;
|
|
47
|
+
perDayAmount: string;
|
|
48
|
+
perDayLabel: string;
|
|
49
|
+
amountCents: number;
|
|
50
|
+
comparison: {
|
|
51
|
+
perDay: string;
|
|
52
|
+
perWeek: string;
|
|
53
|
+
perMonth: string;
|
|
54
|
+
perYear: string;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
export declare const billingPlans: {
|
|
60
|
+
firstPlan: {
|
|
61
|
+
id: string;
|
|
62
|
+
title: string;
|
|
63
|
+
providerPlanId: string;
|
|
64
|
+
priceLabel: string;
|
|
65
|
+
featuredTag: string;
|
|
66
|
+
perDayAmount: string;
|
|
67
|
+
perDayLabel: string;
|
|
68
|
+
amountCents: number;
|
|
69
|
+
comparison: {
|
|
70
|
+
perDay: string;
|
|
71
|
+
perWeek: string;
|
|
72
|
+
perMonth: string;
|
|
73
|
+
perYear: string;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
secondPlan: {
|
|
77
|
+
id: string;
|
|
78
|
+
title: string;
|
|
79
|
+
providerPlanId: string;
|
|
80
|
+
priceLabel: string;
|
|
81
|
+
oldPriceLabel: string;
|
|
82
|
+
perDayAmount: string;
|
|
83
|
+
perDayLabel: string;
|
|
84
|
+
amountCents: number;
|
|
85
|
+
comparison: {
|
|
86
|
+
perDay: string;
|
|
87
|
+
perWeek: string;
|
|
88
|
+
perMonth: string;
|
|
89
|
+
perYear: string;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
export declare const billingPlanList: ({
|
|
94
|
+
id: string;
|
|
95
|
+
title: string;
|
|
96
|
+
providerPlanId: string;
|
|
97
|
+
priceLabel: string;
|
|
98
|
+
featuredTag: string;
|
|
99
|
+
perDayAmount: string;
|
|
100
|
+
perDayLabel: string;
|
|
101
|
+
amountCents: number;
|
|
102
|
+
comparison: {
|
|
103
|
+
perDay: string;
|
|
104
|
+
perWeek: string;
|
|
105
|
+
perMonth: string;
|
|
106
|
+
perYear: string;
|
|
107
|
+
};
|
|
108
|
+
} | {
|
|
109
|
+
id: string;
|
|
110
|
+
title: string;
|
|
111
|
+
providerPlanId: string;
|
|
112
|
+
priceLabel: string;
|
|
113
|
+
oldPriceLabel: string;
|
|
114
|
+
perDayAmount: string;
|
|
115
|
+
perDayLabel: string;
|
|
116
|
+
amountCents: number;
|
|
117
|
+
comparison: {
|
|
118
|
+
perDay: string;
|
|
119
|
+
perWeek: string;
|
|
120
|
+
perMonth: string;
|
|
121
|
+
perYear: string;
|
|
122
|
+
};
|
|
123
|
+
})[];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const billing = {
|
|
2
|
+
plans: {
|
|
3
|
+
firstPlan: {
|
|
4
|
+
id: 'monthly',
|
|
5
|
+
title: '1-Month Plan',
|
|
6
|
+
providerPlanId: 'price_monthly',
|
|
7
|
+
priceLabel: '$29.95/month',
|
|
8
|
+
featuredTag: '🔥 Most Popular Choice',
|
|
9
|
+
perDayAmount: '$0.99',
|
|
10
|
+
perDayLabel: 'per day',
|
|
11
|
+
amountCents: 2995,
|
|
12
|
+
comparison: {
|
|
13
|
+
perDay: '$0.99/day',
|
|
14
|
+
perWeek: '$6.99/week',
|
|
15
|
+
perMonth: '$29.95/month',
|
|
16
|
+
perYear: '$363.34/year',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
secondPlan: {
|
|
20
|
+
id: 'yearly',
|
|
21
|
+
title: '1-Year Plan',
|
|
22
|
+
providerPlanId: 'price_yearly',
|
|
23
|
+
priceLabel: '$191.40/year',
|
|
24
|
+
oldPriceLabel: '$359.40',
|
|
25
|
+
perDayAmount: '$0.53',
|
|
26
|
+
perDayLabel: 'per day',
|
|
27
|
+
amountCents: 19140,
|
|
28
|
+
comparison: {
|
|
29
|
+
perDay: '$0.53/day',
|
|
30
|
+
perWeek: '$3.67/week',
|
|
31
|
+
perMonth: '$15.95/month',
|
|
32
|
+
perYear: '$191.40/year',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
export const billingPlans = billing.plans;
|
|
38
|
+
export const billingPlanList = Object.values(billing.plans);
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@funnelsgrove/runtime';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@funnelsgrove/runtime';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@funnelsgrove/runtime';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@funnelsgrove/runtime';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Stripe } from '@stripe/stripe-js';
|
|
2
|
+
import { type BillingFallbackPlanConfig, type BillingPlanCatalog } from '../config/billing.config';
|
|
3
|
+
import type { RuntimeMode } from '@funnelsgrove/runtime';
|
|
4
|
+
export type PaywallPlan = {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
providerPlanId?: string;
|
|
9
|
+
priceLabel: string;
|
|
10
|
+
oldPriceLabel?: string;
|
|
11
|
+
featuredTag?: string;
|
|
12
|
+
perDayAmount: string;
|
|
13
|
+
perDayLabel: string;
|
|
14
|
+
amountCents: number;
|
|
15
|
+
comparison?: BillingFallbackPlanConfig['comparison'];
|
|
16
|
+
checkoutOriginalAmountCents?: number;
|
|
17
|
+
checkoutSummaryLabel?: string;
|
|
18
|
+
source: 'stripe' | 'config';
|
|
19
|
+
};
|
|
20
|
+
export declare function getFallbackPlans(planCatalog?: BillingPlanCatalog | readonly BillingFallbackPlanConfig[]): readonly PaywallPlan[];
|
|
21
|
+
export declare function getDefaultPlanId(plans: readonly PaywallPlan[]): string | null;
|
|
22
|
+
export declare function getStripeSyncedPlans(mode: RuntimeMode): Promise<readonly PaywallPlan[]>;
|
|
23
|
+
export declare function getPaywallPlans(mode: RuntimeMode, planCatalog?: BillingPlanCatalog | readonly BillingFallbackPlanConfig[]): Promise<readonly PaywallPlan[]>;
|
|
24
|
+
export declare function isStripeConfigured(mode: RuntimeMode): boolean;
|
|
25
|
+
export declare function getStripePromise(mode: RuntimeMode, runtimePublishableKey?: string | null): Promise<Stripe | null> | null;
|
|
26
|
+
type CreatePaymentIntentInput = {
|
|
27
|
+
planId: string;
|
|
28
|
+
amountCents: number;
|
|
29
|
+
externalUserId?: string | null;
|
|
30
|
+
environment?: RuntimeMode;
|
|
31
|
+
};
|
|
32
|
+
type CreatePaymentIntentResponse = {
|
|
33
|
+
clientSecret: string;
|
|
34
|
+
stripePublishableKey?: string;
|
|
35
|
+
environment?: 'test' | 'live';
|
|
36
|
+
};
|
|
37
|
+
export declare function createStripePaymentIntent(input: CreatePaymentIntentInput): Promise<CreatePaymentIntentResponse>;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
var _a, _b, _c, _d;
|
|
2
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
3
|
+
import { billingPlanList, } from '../config/billing.config';
|
|
4
|
+
import { buildMainApiUrl, buildSdkHeaders, FUNNEL_ID, FUNNEL_SDK_PUBLISHABLE_KEY, } from '@funnelsgrove/runtime';
|
|
5
|
+
const STRIPE_TEST_PUBLISHABLE_KEY = ((_b = (_a = process.env.NEXT_PUBLIC_STRIPE_TEST_PUBLISHABLE_KEY) !== null && _a !== void 0 ? _a : process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) !== null && _b !== void 0 ? _b : '').trim();
|
|
6
|
+
const STRIPE_LIVE_PUBLISHABLE_KEY = ((_d = (_c = process.env.NEXT_PUBLIC_STRIPE_LIVE_PUBLISHABLE_KEY) !== null && _c !== void 0 ? _c : process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) !== null && _d !== void 0 ? _d : '').trim();
|
|
7
|
+
const stripePromiseByKey = new Map();
|
|
8
|
+
const syncedPlansPromiseByMode = new Map();
|
|
9
|
+
const fallbackPaywallPlans = billingPlanList.map((plan) => (Object.assign(Object.assign({}, plan), { source: 'config' })));
|
|
10
|
+
const isRecord = (value) => {
|
|
11
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
+
};
|
|
13
|
+
const asTrimmedStringOrNull = (value) => {
|
|
14
|
+
if (typeof value !== 'string') {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const trimmed = value.trim();
|
|
18
|
+
return trimmed || null;
|
|
19
|
+
};
|
|
20
|
+
const asFiniteNumberOrNull = (value) => {
|
|
21
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
const asPositiveIntOrNull = (value) => {
|
|
27
|
+
const numeric = asFiniteNumberOrNull(value);
|
|
28
|
+
if (numeric === null || numeric <= 0) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return Math.round(numeric);
|
|
32
|
+
};
|
|
33
|
+
const toMinorAmount = (value) => {
|
|
34
|
+
const numeric = asFiniteNumberOrNull(value);
|
|
35
|
+
if (numeric === null || numeric <= 0) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return Math.round(numeric);
|
|
39
|
+
};
|
|
40
|
+
const toCurrencyCode = (value) => {
|
|
41
|
+
const raw = asTrimmedStringOrNull(value);
|
|
42
|
+
if (!raw) {
|
|
43
|
+
return 'USD';
|
|
44
|
+
}
|
|
45
|
+
return raw.toUpperCase();
|
|
46
|
+
};
|
|
47
|
+
const formatMoneyFromMinor = (amountMinor, currencyCode) => {
|
|
48
|
+
return new Intl.NumberFormat('en-US', {
|
|
49
|
+
style: 'currency',
|
|
50
|
+
currency: currencyCode,
|
|
51
|
+
minimumFractionDigits: 2,
|
|
52
|
+
maximumFractionDigits: 2,
|
|
53
|
+
}).format(amountMinor / 100);
|
|
54
|
+
};
|
|
55
|
+
const formatMoneyFromMajor = (amountMajor, currencyCode) => {
|
|
56
|
+
return new Intl.NumberFormat('en-US', {
|
|
57
|
+
style: 'currency',
|
|
58
|
+
currency: currencyCode,
|
|
59
|
+
minimumFractionDigits: 2,
|
|
60
|
+
maximumFractionDigits: 2,
|
|
61
|
+
}).format(amountMajor);
|
|
62
|
+
};
|
|
63
|
+
const getIntervalLabel = (interval, intervalCount) => {
|
|
64
|
+
if (!interval) {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
const normalized = interval.toLowerCase();
|
|
68
|
+
const count = intervalCount && intervalCount > 0 ? intervalCount : 1;
|
|
69
|
+
if (count === 1) {
|
|
70
|
+
return normalized;
|
|
71
|
+
}
|
|
72
|
+
return `${count} ${normalized}s`;
|
|
73
|
+
};
|
|
74
|
+
const getIntervalDays = (interval, intervalCount) => {
|
|
75
|
+
if (!interval) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const normalized = interval.toLowerCase();
|
|
79
|
+
const count = intervalCount && intervalCount > 0 ? intervalCount : 1;
|
|
80
|
+
if (normalized === 'day') {
|
|
81
|
+
return count;
|
|
82
|
+
}
|
|
83
|
+
if (normalized === 'week') {
|
|
84
|
+
return count * 7;
|
|
85
|
+
}
|
|
86
|
+
if (normalized === 'month') {
|
|
87
|
+
return count * 30;
|
|
88
|
+
}
|
|
89
|
+
if (normalized === 'year') {
|
|
90
|
+
return count * 365;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
const toPlanFromSynced = (rawPlan) => {
|
|
95
|
+
const planId = asTrimmedStringOrNull(rawPlan.providerPlanId);
|
|
96
|
+
if (!planId) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (rawPlan.isActive === false) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const amountMinor = toMinorAmount(rawPlan.amountMinor);
|
|
103
|
+
if (!amountMinor) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const metadata = isRecord(rawPlan.metadata) ? rawPlan.metadata : {};
|
|
107
|
+
const currencyCode = toCurrencyCode(rawPlan.currency);
|
|
108
|
+
const interval = asTrimmedStringOrNull(rawPlan.interval);
|
|
109
|
+
const intervalCount = asPositiveIntOrNull(rawPlan.intervalCount);
|
|
110
|
+
const intervalLabel = getIntervalLabel(interval, intervalCount);
|
|
111
|
+
const intervalDays = getIntervalDays(interval, intervalCount);
|
|
112
|
+
const title = asTrimmedStringOrNull(rawPlan.displayName) || planId;
|
|
113
|
+
const priceLabel = intervalLabel
|
|
114
|
+
? `${formatMoneyFromMinor(amountMinor, currencyCode)}/${intervalLabel}`
|
|
115
|
+
: formatMoneyFromMinor(amountMinor, currencyCode);
|
|
116
|
+
const perDayAmount = intervalDays
|
|
117
|
+
? formatMoneyFromMajor(amountMinor / 100 / intervalDays, currencyCode)
|
|
118
|
+
: formatMoneyFromMinor(amountMinor, currencyCode);
|
|
119
|
+
return {
|
|
120
|
+
id: planId,
|
|
121
|
+
title,
|
|
122
|
+
description: asTrimmedStringOrNull(metadata.description) || undefined,
|
|
123
|
+
providerPlanId: planId,
|
|
124
|
+
priceLabel,
|
|
125
|
+
oldPriceLabel: asTrimmedStringOrNull(metadata.oldPriceLabel) || undefined,
|
|
126
|
+
featuredTag: asTrimmedStringOrNull(metadata.featuredTag) || undefined,
|
|
127
|
+
perDayAmount,
|
|
128
|
+
perDayLabel: 'per day',
|
|
129
|
+
amountCents: amountMinor,
|
|
130
|
+
comparison: intervalDays
|
|
131
|
+
? {
|
|
132
|
+
perDay: `${perDayAmount}/day`,
|
|
133
|
+
perWeek: `${formatMoneyFromMajor(amountMinor / 100 / intervalDays * 7, currencyCode)}/week`,
|
|
134
|
+
perMonth: interval === 'month' || interval === 'year'
|
|
135
|
+
? `${formatMoneyFromMajor(interval === 'year'
|
|
136
|
+
? amountMinor / 100 / ((intervalCount || 1) * 12)
|
|
137
|
+
: amountMinor / 100 / (intervalCount || 1), currencyCode)}/month`
|
|
138
|
+
: `${formatMoneyFromMajor(amountMinor / 100 / intervalDays * 30, currencyCode)}/month`,
|
|
139
|
+
perYear: interval === 'year'
|
|
140
|
+
? `${formatMoneyFromMinor(amountMinor / (intervalCount || 1), currencyCode)}/year`
|
|
141
|
+
: `${formatMoneyFromMajor(amountMinor / 100 / intervalDays * 365, currencyCode)}/year`,
|
|
142
|
+
}
|
|
143
|
+
: undefined,
|
|
144
|
+
checkoutOriginalAmountCents: asPositiveIntOrNull(metadata.checkoutOriginalAmountCents) || undefined,
|
|
145
|
+
checkoutSummaryLabel: asTrimmedStringOrNull(metadata.checkoutSummaryLabel) || undefined,
|
|
146
|
+
source: 'stripe',
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
const addFeaturedTagFallback = (plans) => {
|
|
150
|
+
if (plans.length === 0) {
|
|
151
|
+
return plans;
|
|
152
|
+
}
|
|
153
|
+
if (plans.some((plan) => Boolean(plan.featuredTag))) {
|
|
154
|
+
return plans;
|
|
155
|
+
}
|
|
156
|
+
return plans.map((plan, index) => index === 0
|
|
157
|
+
? Object.assign(Object.assign({}, plan), { featuredTag: '🔥 Most Popular Choice' }) : plan);
|
|
158
|
+
};
|
|
159
|
+
const getStripePublishableKeyFromEnv = (mode) => {
|
|
160
|
+
if (mode === 'live') {
|
|
161
|
+
return STRIPE_LIVE_PUBLISHABLE_KEY || STRIPE_TEST_PUBLISHABLE_KEY;
|
|
162
|
+
}
|
|
163
|
+
return STRIPE_TEST_PUBLISHABLE_KEY || STRIPE_LIVE_PUBLISHABLE_KEY;
|
|
164
|
+
};
|
|
165
|
+
const toFallbackPaywallPlans = (planCatalog) => {
|
|
166
|
+
const sourcePlans = Array.isArray(planCatalog)
|
|
167
|
+
? planCatalog
|
|
168
|
+
: planCatalog
|
|
169
|
+
? Object.values(planCatalog)
|
|
170
|
+
: billingPlanList;
|
|
171
|
+
return sourcePlans.map((plan) => (Object.assign(Object.assign({}, plan), { source: 'config' })));
|
|
172
|
+
};
|
|
173
|
+
const parseErrorMessage = async (response, fallbackMessage) => {
|
|
174
|
+
try {
|
|
175
|
+
const payload = (await response.json());
|
|
176
|
+
const apiError = asTrimmedStringOrNull(payload.error);
|
|
177
|
+
if (apiError) {
|
|
178
|
+
return apiError;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (_a) {
|
|
182
|
+
// ignore JSON parsing errors
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const errorText = await response.text();
|
|
186
|
+
if (errorText.trim()) {
|
|
187
|
+
return errorText;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (_b) {
|
|
191
|
+
// ignore text parsing errors
|
|
192
|
+
}
|
|
193
|
+
return fallbackMessage;
|
|
194
|
+
};
|
|
195
|
+
export function getFallbackPlans(planCatalog) {
|
|
196
|
+
return planCatalog ? toFallbackPaywallPlans(planCatalog) : fallbackPaywallPlans;
|
|
197
|
+
}
|
|
198
|
+
export function getDefaultPlanId(plans) {
|
|
199
|
+
var _a;
|
|
200
|
+
if (plans.length === 0) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return ((_a = plans[0]) === null || _a === void 0 ? void 0 : _a.id) || null;
|
|
204
|
+
}
|
|
205
|
+
export async function getStripeSyncedPlans(mode) {
|
|
206
|
+
const cacheKey = `${mode}:${FUNNEL_ID || 'no-funnel'}:${FUNNEL_SDK_PUBLISHABLE_KEY}`;
|
|
207
|
+
const cachedPromise = syncedPlansPromiseByMode.get(cacheKey);
|
|
208
|
+
if (cachedPromise) {
|
|
209
|
+
return cachedPromise;
|
|
210
|
+
}
|
|
211
|
+
const nextPromise = (async () => {
|
|
212
|
+
const url = new URL(buildMainApiUrl('/sdk/public/payments/plans'));
|
|
213
|
+
url.searchParams.set('environment', mode);
|
|
214
|
+
if (FUNNEL_ID) {
|
|
215
|
+
url.searchParams.set('funnelId', FUNNEL_ID);
|
|
216
|
+
}
|
|
217
|
+
const response = await fetch(url.toString(), {
|
|
218
|
+
method: 'GET',
|
|
219
|
+
headers: buildSdkHeaders(),
|
|
220
|
+
});
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
const errorMessage = await parseErrorMessage(response, 'Unable to load Stripe plans');
|
|
223
|
+
throw new Error(errorMessage);
|
|
224
|
+
}
|
|
225
|
+
const payload = (await response.json());
|
|
226
|
+
const rawPlans = Array.isArray(payload.plans) ? payload.plans : [];
|
|
227
|
+
const mappedPlans = rawPlans
|
|
228
|
+
.map((raw) => (isRecord(raw) ? toPlanFromSynced(raw) : null))
|
|
229
|
+
.filter((plan) => Boolean(plan))
|
|
230
|
+
.sort((a, b) => a.amountCents - b.amountCents);
|
|
231
|
+
return addFeaturedTagFallback(mappedPlans);
|
|
232
|
+
})();
|
|
233
|
+
syncedPlansPromiseByMode.set(cacheKey, nextPromise);
|
|
234
|
+
return nextPromise;
|
|
235
|
+
}
|
|
236
|
+
export async function getPaywallPlans(mode, planCatalog) {
|
|
237
|
+
try {
|
|
238
|
+
const syncedPlans = await getStripeSyncedPlans(mode);
|
|
239
|
+
if (syncedPlans.length > 0) {
|
|
240
|
+
return syncedPlans;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (_a) {
|
|
244
|
+
// fall through to config fallback plans
|
|
245
|
+
}
|
|
246
|
+
return getFallbackPlans(planCatalog);
|
|
247
|
+
}
|
|
248
|
+
export function isStripeConfigured(mode) {
|
|
249
|
+
return getStripePublishableKeyFromEnv(mode).length > 0 || FUNNEL_SDK_PUBLISHABLE_KEY.length > 0;
|
|
250
|
+
}
|
|
251
|
+
export function getStripePromise(mode, runtimePublishableKey) {
|
|
252
|
+
const resolvedPublishableKey = (runtimePublishableKey !== null && runtimePublishableKey !== void 0 ? runtimePublishableKey : getStripePublishableKeyFromEnv(mode)).trim();
|
|
253
|
+
if (!resolvedPublishableKey) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
let stripePromise = stripePromiseByKey.get(resolvedPublishableKey) || null;
|
|
257
|
+
if (!stripePromise) {
|
|
258
|
+
stripePromise = loadStripe(resolvedPublishableKey);
|
|
259
|
+
stripePromiseByKey.set(resolvedPublishableKey, stripePromise);
|
|
260
|
+
}
|
|
261
|
+
return stripePromise;
|
|
262
|
+
}
|
|
263
|
+
export async function createStripePaymentIntent(input) {
|
|
264
|
+
const response = await fetch(buildMainApiUrl('/sdk/public/payments/intents'), {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: buildSdkHeaders({
|
|
267
|
+
'Content-Type': 'application/json',
|
|
268
|
+
}),
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
publishableKey: FUNNEL_SDK_PUBLISHABLE_KEY || undefined,
|
|
271
|
+
funnelId: FUNNEL_ID || undefined,
|
|
272
|
+
planId: input.planId,
|
|
273
|
+
amountCents: input.amountCents,
|
|
274
|
+
externalUserId: input.externalUserId || undefined,
|
|
275
|
+
environment: input.environment || undefined,
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
278
|
+
if (!response.ok) {
|
|
279
|
+
const errorMessage = await parseErrorMessage(response, 'Unable to create payment intent');
|
|
280
|
+
throw new Error(errorMessage);
|
|
281
|
+
}
|
|
282
|
+
return (await response.json());
|
|
283
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@funnelsgrove/payments",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
24
|
+
"lint": "eslint .",
|
|
25
|
+
"prepublishOnly": "npm run build",
|
|
26
|
+
"test:run": "vitest run --passWithNoTests"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@funnelsgrove/runtime": "0.1.0",
|
|
30
|
+
"@stripe/react-stripe-js": "^5.6.0",
|
|
31
|
+
"@stripe/stripe-js": "^8.7.0",
|
|
32
|
+
"react": "19.2.3",
|
|
33
|
+
"react-dom": "19.2.3",
|
|
34
|
+
"stripe": "^20.3.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^20",
|
|
38
|
+
"@types/react": "^19",
|
|
39
|
+
"@types/react-dom": "^19",
|
|
40
|
+
"typescript": "^5",
|
|
41
|
+
"vitest": "^3.2.4"
|
|
42
|
+
}
|
|
43
|
+
}
|