@startsimpli/billing 0.4.23 → 0.4.25
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/package.json +1 -1
- package/src/components/CurrentPlanCard.tsx +5 -10
- package/src/components/IntervalToggle.tsx +113 -0
- package/src/components/PlanChange.crochet.test.tsx +274 -0
- package/src/components/PlanChangeConfirm.tsx +193 -0
- package/src/components/PlanPicker.crochet.test.tsx +322 -0
- package/src/components/PricingDetailPage.tsx +144 -47
- package/src/components/PricingPage.test.tsx +2 -2
- package/src/components/PricingPage.tsx +46 -24
- package/src/components/PricingSection.test.tsx +6 -5
- package/src/components/PricingSection.tsx +58 -38
- package/src/components/SubscriptionManager.test.tsx +38 -19
- package/src/components/SubscriptionManager.tsx +168 -99
- package/src/components/UpgradeModal.tsx +89 -27
- package/src/components/__fixtures__/crochetProduct.ts +172 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/usePlanChange.ts +144 -0
- package/src/hooks/useSuccessSync.ts +3 -3
- package/src/index.ts +23 -4
- package/src/types/index.ts +33 -0
- package/src/utils/api.ts +38 -1
- package/src/utils/format.test.ts +57 -0
- package/src/utils/format.ts +54 -0
- package/src/utils/plan.ts +122 -0
- package/src/utils/tiers.interval.test.ts +76 -0
- package/src/utils/tiers.ts +105 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live crochets.site catalogue (GET /billing/products/crochet-patterns/,
|
|
3
|
+
* 2026-10-16), camelised the way the proxy hands it to the components.
|
|
4
|
+
* `tier` is not served yet, so the base fixture omits it on purpose: the
|
|
5
|
+
* picker has to pair Lite with Lite Annual without it.
|
|
6
|
+
*/
|
|
7
|
+
import type { BillingProduct, ProductOffer, SubscriptionInfo } from "../../types";
|
|
8
|
+
|
|
9
|
+
const base = {
|
|
10
|
+
currency: "USD",
|
|
11
|
+
pricingModel: "flat" as const,
|
|
12
|
+
intervalCount: 1,
|
|
13
|
+
isRecurring: true,
|
|
14
|
+
isActive: true,
|
|
15
|
+
isFeatured: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const liteFeatures = [
|
|
19
|
+
{ key: "ai_designs", name: "AI pattern designs", value: true },
|
|
20
|
+
{ key: "ai_designs_per_month", name: "AI designs per month", value: 3 },
|
|
21
|
+
{ key: "ai_refine", name: "AI chat to refine any pattern", value: true },
|
|
22
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true },
|
|
23
|
+
{ key: "save_patterns", name: "Save patterns to your library", value: true },
|
|
24
|
+
{ key: "track_progress", name: "Track your progress", value: true },
|
|
25
|
+
{ key: "patterns_per_month", name: "Saved patterns per month", value: -1 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const proFeatures = [
|
|
29
|
+
{ key: "ai_designs", name: "AI pattern designs, made with our best model and checked by a design review", value: true },
|
|
30
|
+
{ key: "patterns_per_month", name: "AI designs per month", value: -1 },
|
|
31
|
+
{ key: "ai_refine", name: "AI chat to refine any pattern", value: true },
|
|
32
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true },
|
|
33
|
+
{ key: "custom_sizing", name: "Custom sizing", value: true },
|
|
34
|
+
{ key: "trial_note", name: "7-day free trial with AI designs included", value: true },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const crochetFree: ProductOffer = {
|
|
38
|
+
...base,
|
|
39
|
+
id: "free-id",
|
|
40
|
+
name: "Free",
|
|
41
|
+
slug: "crochet-free",
|
|
42
|
+
unitPrice: "0.00",
|
|
43
|
+
intervalUnit: "month",
|
|
44
|
+
intervalDisplay: "per month",
|
|
45
|
+
isFree: true,
|
|
46
|
+
features: [
|
|
47
|
+
{ key: "ai_designs", name: "AI pattern designs", value: false },
|
|
48
|
+
{ key: "browse_patterns", name: "Browse and buy patterns", value: true },
|
|
49
|
+
{ key: "save_patterns", name: "Save patterns to your library", value: true },
|
|
50
|
+
{ key: "track_progress", name: "Track your progress", value: true },
|
|
51
|
+
{ key: "pdf_export", name: "Print-ready PDF export", limit: null, value: true },
|
|
52
|
+
],
|
|
53
|
+
trialDays: 0,
|
|
54
|
+
sortOrder: 0,
|
|
55
|
+
ctaText: "Get Started",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const crochetLite: ProductOffer = {
|
|
59
|
+
...base,
|
|
60
|
+
id: "lite-id",
|
|
61
|
+
name: "Lite",
|
|
62
|
+
slug: "crochet-lite",
|
|
63
|
+
unitPrice: "2.99",
|
|
64
|
+
intervalUnit: "month",
|
|
65
|
+
intervalDisplay: "per month",
|
|
66
|
+
isFree: false,
|
|
67
|
+
features: liteFeatures,
|
|
68
|
+
trialDays: 0,
|
|
69
|
+
sortOrder: 1,
|
|
70
|
+
ctaText: "Get Lite",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const crochetLiteAnnual: ProductOffer = {
|
|
74
|
+
...base,
|
|
75
|
+
id: "lite-annual-id",
|
|
76
|
+
name: "Lite Annual",
|
|
77
|
+
slug: "crochet-lite-annual",
|
|
78
|
+
unitPrice: "29.00",
|
|
79
|
+
intervalUnit: "year",
|
|
80
|
+
intervalDisplay: "per year",
|
|
81
|
+
isFree: false,
|
|
82
|
+
features: liteFeatures,
|
|
83
|
+
trialDays: 0,
|
|
84
|
+
sortOrder: 1,
|
|
85
|
+
ctaText: "Get Lite",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const crochetPro: ProductOffer = {
|
|
89
|
+
...base,
|
|
90
|
+
id: "pro-id",
|
|
91
|
+
name: "Pro",
|
|
92
|
+
slug: "crochet-pro",
|
|
93
|
+
unitPrice: "9.00",
|
|
94
|
+
intervalUnit: "month",
|
|
95
|
+
intervalDisplay: "per month",
|
|
96
|
+
isFree: false,
|
|
97
|
+
features: proFeatures,
|
|
98
|
+
trialDays: 7,
|
|
99
|
+
sortOrder: 2,
|
|
100
|
+
ctaText: "Start Free Trial",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const crochetProAnnual: ProductOffer = {
|
|
104
|
+
...base,
|
|
105
|
+
id: "pro-annual-id",
|
|
106
|
+
name: "Pro Annual",
|
|
107
|
+
slug: "crochet-pro-annual",
|
|
108
|
+
unitPrice: "89.00",
|
|
109
|
+
intervalUnit: "year",
|
|
110
|
+
intervalDisplay: "per year",
|
|
111
|
+
isFree: false,
|
|
112
|
+
features: proFeatures,
|
|
113
|
+
trialDays: 7,
|
|
114
|
+
sortOrder: 2,
|
|
115
|
+
ctaText: "Start Free Trial",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** What production serves today: no Pro Annual, no `tier`. */
|
|
119
|
+
export const crochetProduct: BillingProduct = {
|
|
120
|
+
id: "034a56c9",
|
|
121
|
+
slug: "crochet-patterns",
|
|
122
|
+
name: "Crochet Patterns",
|
|
123
|
+
description: "AI-powered crochet pattern generator with step-by-step instructions.",
|
|
124
|
+
offers: [crochetFree, crochetLite, crochetLiteAnnual, crochetPro],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** After the in-flight deploy: `tier` on every paid offer, plus Pro Annual. */
|
|
128
|
+
export const crochetProductWithTiers: BillingProduct = {
|
|
129
|
+
...crochetProduct,
|
|
130
|
+
offers: [
|
|
131
|
+
crochetFree,
|
|
132
|
+
{ ...crochetLite, tier: "lite" },
|
|
133
|
+
{ ...crochetLiteAnnual, tier: "lite" },
|
|
134
|
+
{ ...crochetPro, tier: "pro" },
|
|
135
|
+
{ ...crochetProAnnual, tier: "pro" },
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function subscriptionTo(offer: ProductOffer, status: SubscriptionInfo["status"] = "active"): SubscriptionInfo {
|
|
140
|
+
return {
|
|
141
|
+
id: `sub-${offer.slug}`,
|
|
142
|
+
offer,
|
|
143
|
+
status,
|
|
144
|
+
currentPeriodStart: "2026-10-01T00:00:00Z",
|
|
145
|
+
currentPeriodEnd: "2026-11-01T00:00:00Z",
|
|
146
|
+
cancelAtPeriodEnd: false,
|
|
147
|
+
trialEnd: null,
|
|
148
|
+
viewerIsOwner: true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A fetch that serves the product and the given subscription (null = 404). */
|
|
153
|
+
export function crochetFetcher(
|
|
154
|
+
product: BillingProduct,
|
|
155
|
+
subscription: SubscriptionInfo | null,
|
|
156
|
+
): typeof fetch {
|
|
157
|
+
return ((url: string) => {
|
|
158
|
+
if (url.includes("/billing/subscription/current/")) {
|
|
159
|
+
if (subscription === null) {
|
|
160
|
+
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ detail: "none" }) });
|
|
161
|
+
}
|
|
162
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(subscription) });
|
|
163
|
+
}
|
|
164
|
+
if (url.includes("/billing/usage/")) {
|
|
165
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) });
|
|
166
|
+
}
|
|
167
|
+
if (url.includes("/billing/products/")) {
|
|
168
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(product) });
|
|
169
|
+
}
|
|
170
|
+
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
|
171
|
+
}) as unknown as typeof fetch;
|
|
172
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -16,3 +16,5 @@ export { CurrentPlanCard } from "./CurrentPlanCard";
|
|
|
16
16
|
export type { CurrentPlanCardProps } from "./CurrentPlanCard";
|
|
17
17
|
export { LimitNudge } from "./LimitNudge";
|
|
18
18
|
export type { LimitNudgeProps } from "./LimitNudge";
|
|
19
|
+
export { PlanChangeConfirm, planChangeCopy } from "./PlanChangeConfirm";
|
|
20
|
+
export type { PlanChangeConfirmProps } from "./PlanChangeConfirm";
|
package/src/hooks/index.ts
CHANGED
|
@@ -6,3 +6,5 @@ export { usePortal } from "./usePortal";
|
|
|
6
6
|
export { useSubscription } from "./useSubscription";
|
|
7
7
|
export { useSuccessSync } from "./useSuccessSync";
|
|
8
8
|
export { useUsage, notifyUsageChanged, USAGE_CHANGED_EVENT } from "./useUsage";
|
|
9
|
+
export { usePlanChange } from "./usePlanChange";
|
|
10
|
+
export type { PlanChangeContext, PlanChangeConfirmState, PlanChangeProceed, UsePlanChangeResult } from "./usePlanChange";
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePlanChange — choose a plan, confirming first when the click changes or
|
|
3
|
+
* ends a plan the viewer already has (crochet_patterns-anw61, -kigcj, -lr7mj).
|
|
4
|
+
*
|
|
5
|
+
* A new buyer choosing a paid plan goes straight on: the provider's checkout
|
|
6
|
+
* page is their confirmation. Anyone already paying, or choosing a free plan,
|
|
7
|
+
* is asked first, with what the backend says will happen (charged now and how
|
|
8
|
+
* much, or the date it changes, or that an unlisted plan ends for good).
|
|
9
|
+
*
|
|
10
|
+
* While a preview or the change itself is in flight `busy` is true, so
|
|
11
|
+
* callers disable their plan buttons — a second click used to start a second
|
|
12
|
+
* checkout (crochet_patterns-9e9o).
|
|
13
|
+
*
|
|
14
|
+
* `proceed(offer, preview)` does the change (checkout, subscribe-free, …).
|
|
15
|
+
* Return "navigating" when it has sent the browser away, so the buttons stay
|
|
16
|
+
* disabled until the page unloads. Throw to show the message.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useCallback, useRef, useState } from "react";
|
|
20
|
+
import type { PlanChangePreview, ProductOffer } from "../types";
|
|
21
|
+
import { useBillingContext } from "./BillingProvider";
|
|
22
|
+
|
|
23
|
+
export type PlanChangeProceed = (
|
|
24
|
+
offer: ProductOffer,
|
|
25
|
+
preview: PlanChangePreview | null,
|
|
26
|
+
) => Promise<unknown> | unknown;
|
|
27
|
+
|
|
28
|
+
export interface PlanChangeContext {
|
|
29
|
+
/** The viewer pays for a running plan (isPayingSubscription). */
|
|
30
|
+
paying: boolean;
|
|
31
|
+
/** The viewer's plan is not one of the offers for sale (a complimentary plan). */
|
|
32
|
+
leavingUnlisted?: boolean;
|
|
33
|
+
/** The viewer's plan, for the dialog copy when the preview can't be had. */
|
|
34
|
+
currentOffer?: ProductOffer | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PlanChangeConfirmState {
|
|
38
|
+
offer: ProductOffer;
|
|
39
|
+
preview: PlanChangePreview | null;
|
|
40
|
+
currentName: string | null;
|
|
41
|
+
leavingUnlisted: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UsePlanChangeResult {
|
|
45
|
+
choose: (offer: ProductOffer, context: PlanChangeContext) => Promise<void>;
|
|
46
|
+
confirm: () => Promise<void>;
|
|
47
|
+
cancel: () => void;
|
|
48
|
+
/** Open when the viewer must confirm. */
|
|
49
|
+
pending: PlanChangeConfirmState | null;
|
|
50
|
+
busy: boolean;
|
|
51
|
+
/** The confirm step itself is running. */
|
|
52
|
+
confirming: boolean;
|
|
53
|
+
error: string | null;
|
|
54
|
+
clearError: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
|
|
58
|
+
const { client } = useBillingContext();
|
|
59
|
+
const [pending, setPending] = useState<PlanChangeConfirmState | null>(null);
|
|
60
|
+
const [busy, setBusy] = useState(false);
|
|
61
|
+
const [confirming, setConfirming] = useState(false);
|
|
62
|
+
const [error, setError] = useState<string | null>(null);
|
|
63
|
+
const inFlight = useRef(false);
|
|
64
|
+
|
|
65
|
+
const run = useCallback(
|
|
66
|
+
async (offer: ProductOffer, preview: PlanChangePreview | null) => {
|
|
67
|
+
try {
|
|
68
|
+
const outcome = await proceed(offer, preview);
|
|
69
|
+
if (outcome !== "navigating") {
|
|
70
|
+
inFlight.current = false;
|
|
71
|
+
setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
inFlight.current = false;
|
|
76
|
+
setBusy(false);
|
|
77
|
+
setError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[proceed],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const choose = useCallback(
|
|
85
|
+
async (offer: ProductOffer, context: PlanChangeContext) => {
|
|
86
|
+
if (inFlight.current) return;
|
|
87
|
+
inFlight.current = true;
|
|
88
|
+
setBusy(true);
|
|
89
|
+
setError(null);
|
|
90
|
+
|
|
91
|
+
const leavingUnlisted = !!context.leavingUnlisted;
|
|
92
|
+
// Not paying yet (free, or an unbilled complimentary plan) and choosing a
|
|
93
|
+
// paid plan: that is a checkout, and its page is the confirmation.
|
|
94
|
+
if (!context.paying && !offer.isFree) {
|
|
95
|
+
await run(offer, null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let preview: PlanChangePreview | null = null;
|
|
100
|
+
try {
|
|
101
|
+
preview = await client.previewPlanChange(offer.id);
|
|
102
|
+
} catch {
|
|
103
|
+
preview = null; // still confirm, with the copy we can stand behind
|
|
104
|
+
}
|
|
105
|
+
if (preview?.mode === "current") {
|
|
106
|
+
inFlight.current = false;
|
|
107
|
+
setBusy(false);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (preview?.mode === "checkout") {
|
|
111
|
+
await run(offer, preview);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
setPending({
|
|
115
|
+
offer,
|
|
116
|
+
preview,
|
|
117
|
+
currentName: preview?.currentOffer?.name ?? context.currentOffer?.name ?? null,
|
|
118
|
+
leavingUnlisted: preview?.currentOffer ? !preview.currentOffer.isListed : leavingUnlisted,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
[client, run],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const confirm = useCallback(async () => {
|
|
125
|
+
if (!pending || confirming) return;
|
|
126
|
+
setConfirming(true);
|
|
127
|
+
setError(null);
|
|
128
|
+
const ok = await run(pending.offer, pending.preview);
|
|
129
|
+
setConfirming(false);
|
|
130
|
+
if (ok) setPending(null);
|
|
131
|
+
}, [pending, confirming, run]);
|
|
132
|
+
|
|
133
|
+
const cancel = useCallback(() => {
|
|
134
|
+
if (confirming) return;
|
|
135
|
+
setPending(null);
|
|
136
|
+
setError(null);
|
|
137
|
+
inFlight.current = false;
|
|
138
|
+
setBusy(false);
|
|
139
|
+
}, [confirming]);
|
|
140
|
+
|
|
141
|
+
const clearError = useCallback(() => setError(null), []);
|
|
142
|
+
|
|
143
|
+
return { choose, confirm, cancel, pending, busy, confirming, error, clearError };
|
|
144
|
+
}
|
|
@@ -16,7 +16,7 @@ import type { CachedSubscriptionState } from "../types";
|
|
|
16
16
|
import { useBillingContext } from "./BillingProvider";
|
|
17
17
|
|
|
18
18
|
interface UseSuccessSyncResult {
|
|
19
|
-
sync: () => Promise<CachedSubscriptionState | null>;
|
|
19
|
+
sync: (params?: { sessionId?: string | null; productSlug?: string | null }) => Promise<CachedSubscriptionState | null>;
|
|
20
20
|
data: CachedSubscriptionState | null;
|
|
21
21
|
loading: boolean;
|
|
22
22
|
error: Error | null;
|
|
@@ -28,11 +28,11 @@ export function useSuccessSync(): UseSuccessSyncResult {
|
|
|
28
28
|
const [loading, setLoading] = useState(false);
|
|
29
29
|
const [error, setError] = useState<Error | null>(null);
|
|
30
30
|
|
|
31
|
-
const sync = useCallback(async () => {
|
|
31
|
+
const sync = useCallback(async (params?: { sessionId?: string | null; productSlug?: string | null }) => {
|
|
32
32
|
setLoading(true);
|
|
33
33
|
setError(null);
|
|
34
34
|
try {
|
|
35
|
-
const result = await client.successSync();
|
|
35
|
+
const result = await client.successSync(params);
|
|
36
36
|
setData(result);
|
|
37
37
|
return result;
|
|
38
38
|
} catch (e) {
|
package/src/index.ts
CHANGED
|
@@ -19,10 +19,11 @@ export {
|
|
|
19
19
|
useSubscription,
|
|
20
20
|
useSuccessSync,
|
|
21
21
|
useUsage,
|
|
22
|
+
usePlanChange,
|
|
22
23
|
notifyUsageChanged,
|
|
23
24
|
USAGE_CHANGED_EVENT,
|
|
24
25
|
} from "./hooks";
|
|
25
|
-
export type { BillingProviderProps } from "./hooks";
|
|
26
|
+
export type { BillingProviderProps, PlanChangeContext, PlanChangeConfirmState, PlanChangeProceed, UsePlanChangeResult } from "./hooks";
|
|
26
27
|
|
|
27
28
|
// Types
|
|
28
29
|
export type {
|
|
@@ -34,13 +35,14 @@ export type {
|
|
|
34
35
|
CachedSubscriptionState,
|
|
35
36
|
FreeSubscriptionResult,
|
|
36
37
|
PaymentMethod,
|
|
38
|
+
PlanChangePreview,
|
|
37
39
|
PortalResult,
|
|
38
40
|
SubscriptionInfo,
|
|
39
41
|
UsageMap,
|
|
40
42
|
} from "./types";
|
|
41
43
|
|
|
42
44
|
// Components
|
|
43
|
-
export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge } from "./components";
|
|
45
|
+
export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge, PlanChangeConfirm, planChangeCopy } from "./components";
|
|
44
46
|
export type {
|
|
45
47
|
PricingPageProps,
|
|
46
48
|
PricingSectionProps,
|
|
@@ -51,11 +53,28 @@ export type {
|
|
|
51
53
|
PlanGateProps,
|
|
52
54
|
CurrentPlanCardProps,
|
|
53
55
|
LimitNudgeProps,
|
|
56
|
+
PlanChangeConfirmProps,
|
|
54
57
|
} from "./components";
|
|
55
58
|
|
|
56
59
|
// API client (for advanced usage)
|
|
57
60
|
export { BillingApiClient } from "./utils/api";
|
|
58
61
|
|
|
59
62
|
// Pure helpers
|
|
60
|
-
export {
|
|
61
|
-
|
|
63
|
+
export {
|
|
64
|
+
isPaidSubscription,
|
|
65
|
+
isPayingSubscription,
|
|
66
|
+
isUnlistedOffer,
|
|
67
|
+
getFeatureLimit,
|
|
68
|
+
resolvePlanEntitlement,
|
|
69
|
+
resolveUsageWindow,
|
|
70
|
+
resolveCurrentOfferId,
|
|
71
|
+
resolveCurrentOffer,
|
|
72
|
+
planDirection,
|
|
73
|
+
planCtaLabel,
|
|
74
|
+
trialApplies,
|
|
75
|
+
TRIAL_NOTE_FEATURE_KEY,
|
|
76
|
+
} from "./utils/plan";
|
|
77
|
+
export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow, CurrentPlanState, PlanDirection } from "./utils/plan";
|
|
78
|
+
export { formatPrice, formatFeatureLabel } from "./utils/format";
|
|
79
|
+
export { groupOffersByTier, offersForInterval, hasIntervalChoice, annualSavings, tierOffer } from "./utils/tiers";
|
|
80
|
+
export type { BillingInterval, OfferTier } from "./utils/tiers";
|
package/src/types/index.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface ProductOffer {
|
|
|
23
23
|
trialDays: number;
|
|
24
24
|
sortOrder: number;
|
|
25
25
|
ctaText: string;
|
|
26
|
+
/** Plan tier shared by an offer's monthly and annual prices ("lite",
|
|
27
|
+
* "pro"). Optional: older payloads omit it and the picker pairs by
|
|
28
|
+
* features / name instead. */
|
|
29
|
+
tier?: string | null;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface OfferFeature {
|
|
@@ -48,14 +52,43 @@ export interface BillingProduct {
|
|
|
48
52
|
offers: ProductOffer[];
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
/**
|
|
56
|
+
* What choosing an offer will do, from POST /billing/plan-change-preview/
|
|
57
|
+
* (crochet_patterns-anw61):
|
|
58
|
+
* - "current" already on it
|
|
59
|
+
* - "immediate" changes now; a paid offer is charged `amountDue` now
|
|
60
|
+
* (null when it could not be priced) and never trials;
|
|
61
|
+
* a free offer ends an unbilled plan now
|
|
62
|
+
* - "at_period_end" changes on `effectiveAt`; nothing is charged now
|
|
63
|
+
* - "checkout" a new checkout, with `trialDays`
|
|
64
|
+
*/
|
|
65
|
+
export interface PlanChangePreview {
|
|
66
|
+
mode: "current" | "immediate" | "at_period_end" | "checkout";
|
|
67
|
+
offerId: string;
|
|
68
|
+
amountDue: string | null;
|
|
69
|
+
currency: string;
|
|
70
|
+
effectiveAt: string | null;
|
|
71
|
+
trialDays: number;
|
|
72
|
+
/** The plan being left; `isListed` false = not for sale, cannot be chosen again. */
|
|
73
|
+
currentOffer: { slug: string; name: string; isListed: boolean } | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
export interface CheckoutResult {
|
|
52
77
|
sessionId: string;
|
|
53
78
|
url: string;
|
|
79
|
+
/** Set when the offer was a change to a plan already paid for (no checkout):
|
|
80
|
+
* "immediate", "at_period_end", "payment_required", "pending_change_cancelled".
|
|
81
|
+
* The backend sends it as `plan_change`. */
|
|
82
|
+
plan_change?: string;
|
|
83
|
+
planChange?: string;
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
export interface FreeSubscriptionResult {
|
|
57
87
|
subscriptionId: string;
|
|
58
88
|
status: string;
|
|
89
|
+
/** Leaving a paid plan: it ends at `effective_at` instead of now. */
|
|
90
|
+
scheduled_downgrade?: boolean;
|
|
91
|
+
effective_at?: string | null;
|
|
59
92
|
}
|
|
60
93
|
|
|
61
94
|
export interface PortalResult {
|
package/src/utils/api.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
CachedSubscriptionState,
|
|
9
9
|
CheckoutResult,
|
|
10
10
|
FreeSubscriptionResult,
|
|
11
|
+
PlanChangePreview,
|
|
11
12
|
PortalResult,
|
|
12
13
|
SubscriptionInfo,
|
|
13
14
|
UsageMap,
|
|
@@ -104,11 +105,47 @@ export class BillingApiClient {
|
|
|
104
105
|
* Eagerly sync subscription state after Stripe checkout redirect.
|
|
105
106
|
* Call this from your success_url page to avoid waiting for the webhook.
|
|
106
107
|
*/
|
|
107
|
-
|
|
108
|
+
/** What choosing an offer will do, without doing it (crochet_patterns-anw61). */
|
|
109
|
+
async previewPlanChange(offerId: string): Promise<PlanChangePreview> {
|
|
110
|
+
const url = `${this.config.apiBaseUrl}/billing/plan-change-preview/`;
|
|
111
|
+
const resp = await this.fetcher(url, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: this.headers,
|
|
114
|
+
body: JSON.stringify({ offer_id: offerId }),
|
|
115
|
+
});
|
|
116
|
+
if (!resp.ok) {
|
|
117
|
+
const data = await resp.json().catch(() => ({}));
|
|
118
|
+
throw new Error(billingErrorMessage(data, `Plan preview failed: ${resp.status}`));
|
|
119
|
+
}
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
121
|
+
const raw: any = await resp.json();
|
|
122
|
+
const cur = raw.current_offer ?? raw.currentOffer ?? null;
|
|
123
|
+
return {
|
|
124
|
+
mode: raw.mode,
|
|
125
|
+
offerId: raw.offer_id ?? raw.offerId ?? offerId,
|
|
126
|
+
amountDue: raw.amount_due ?? raw.amountDue ?? null,
|
|
127
|
+
currency: raw.currency ?? "usd",
|
|
128
|
+
effectiveAt: raw.effective_at ?? raw.effectiveAt ?? null,
|
|
129
|
+
trialDays: raw.trial_days ?? raw.trialDays ?? 0,
|
|
130
|
+
currentOffer: cur
|
|
131
|
+
? { slug: cur.slug, name: cur.name, isListed: (cur.is_listed ?? cur.isListed) !== false }
|
|
132
|
+
: null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `sessionId` is the checkout session from the success URL
|
|
137
|
+
* ({CHECKOUT_SESSION_ID}): it sends the sync straight to the checkout that
|
|
138
|
+
* was paid, not to whichever customer the team resolves to first
|
|
139
|
+
* (crochet_patterns-eq11). */
|
|
140
|
+
async successSync(params: { sessionId?: string | null; productSlug?: string | null } = {}): Promise<CachedSubscriptionState | null> {
|
|
108
141
|
const url = `${this.config.apiBaseUrl}/billing/success-sync/`;
|
|
142
|
+
const body: Record<string, string> = {};
|
|
143
|
+
if (params.sessionId) body.session_id = params.sessionId;
|
|
144
|
+
if (params.productSlug) body.product_slug = params.productSlug;
|
|
109
145
|
const resp = await this.fetcher(url, {
|
|
110
146
|
method: "POST",
|
|
111
147
|
headers: this.headers,
|
|
148
|
+
...(Object.keys(body).length > 0 && { body: JSON.stringify(body) }),
|
|
112
149
|
});
|
|
113
150
|
if (resp.status === 404) {
|
|
114
151
|
return null;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price and feature-label formatting shared by every plan card
|
|
3
|
+
* (crochet_patterns-34n7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { formatPrice, formatFeatureLabel } from "./format";
|
|
7
|
+
|
|
8
|
+
describe("formatPrice", () => {
|
|
9
|
+
it("keeps the cents: 2.99 is $2.99, not $2", () => {
|
|
10
|
+
expect(formatPrice("2.99")).toBe("$2.99");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("drops .00 on whole amounts", () => {
|
|
14
|
+
expect(formatPrice("9.00")).toBe("$9");
|
|
15
|
+
expect(formatPrice("29.00")).toBe("$29");
|
|
16
|
+
expect(formatPrice("199.00")).toBe("$199");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("pads a single cent digit", () => {
|
|
20
|
+
expect(formatPrice("2.5")).toBe("$2.50");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("accepts numbers and other currencies", () => {
|
|
24
|
+
expect(formatPrice(2.42)).toBe("$2.42");
|
|
25
|
+
expect(formatPrice("10.00", "EUR")).toBe("€10");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("treats junk as zero rather than NaN", () => {
|
|
29
|
+
expect(formatPrice("")).toBe("$0");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("formatFeatureLabel", () => {
|
|
34
|
+
it("keeps acronyms capitalised in a capped allowance", () => {
|
|
35
|
+
expect(formatFeatureLabel({ key: "ai_designs_per_month", name: "AI designs per month", value: 3 }))
|
|
36
|
+
.toBe("Up to 3 AI designs per month");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not say per month for an unlimited allowance", () => {
|
|
40
|
+
expect(formatFeatureLabel({ key: "patterns_per_month", name: "Saved patterns per month", value: -1 }))
|
|
41
|
+
.toBe("Unlimited saved patterns");
|
|
42
|
+
expect(formatFeatureLabel({ key: "patterns_per_month", name: "AI designs per month", value: -1 }))
|
|
43
|
+
.toBe("Unlimited AI designs");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("prefers limit over value", () => {
|
|
47
|
+
expect(formatFeatureLabel({ key: "seats", name: "Team seats", value: 1, limit: 5 }))
|
|
48
|
+
.toBe("Up to 5 team seats");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves capabilities and string values as their name", () => {
|
|
52
|
+
expect(formatFeatureLabel({ key: "pdf_export", name: "Print-ready PDF export", value: true }))
|
|
53
|
+
.toBe("Print-ready PDF export");
|
|
54
|
+
expect(formatFeatureLabel({ key: "projects", name: "Projects", value: "Unlimited" }))
|
|
55
|
+
.toBe("Projects");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display formatting shared by every plan card, so a price or a feature line
|
|
3
|
+
* reads the same on /pricing, the billing settings page and the upgrade modal
|
|
4
|
+
* (crochet_patterns-34n7).
|
|
5
|
+
*/
|
|
6
|
+
import type { OfferFeature } from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* "$2.99", "$9", "€10". Cents are kept when there are any and dropped when
|
|
10
|
+
* the amount is whole — truncating with Math.floor turned $2.99 into $2.
|
|
11
|
+
*/
|
|
12
|
+
export function formatPrice(amount: string | number, currency = "USD"): string {
|
|
13
|
+
const n = typeof amount === "number" ? amount : parseFloat(amount);
|
|
14
|
+
const value = Number.isFinite(n) ? n : 0;
|
|
15
|
+
const whole = Math.abs(value - Math.round(value)) < 0.005;
|
|
16
|
+
try {
|
|
17
|
+
return new Intl.NumberFormat("en-US", {
|
|
18
|
+
style: "currency",
|
|
19
|
+
currency: currency || "USD",
|
|
20
|
+
minimumFractionDigits: whole ? 0 : 2,
|
|
21
|
+
maximumFractionDigits: whole ? 0 : 2,
|
|
22
|
+
}).format(value);
|
|
23
|
+
} catch {
|
|
24
|
+
return `$${whole ? Math.round(value) : value.toFixed(2)}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Lower-case the first letter for mid-sentence use, unless the first word is
|
|
29
|
+
* an acronym ("AI designs" stays "AI designs", "Team seats" → "team seats"). */
|
|
30
|
+
function midSentence(name: string): string {
|
|
31
|
+
const firstWord = name.split(/\s+/)[0] ?? "";
|
|
32
|
+
if ((firstWord.match(/[A-Z]/g) ?? []).length >= 2) return name;
|
|
33
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** "Saved patterns per month" → "Saved patterns": an unlimited allowance has
|
|
37
|
+
* no period. */
|
|
38
|
+
function withoutPeriod(name: string): string {
|
|
39
|
+
return name.replace(/\s+(per|a|each|\/)\s*(day|week|month|year)$/i, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The line a plan card shows for one feature. Numeric allowances read as
|
|
44
|
+
* "Up to 3 AI designs per month" / "Unlimited saved patterns"; capabilities
|
|
45
|
+
* and string values are shown by name. Callers decide what to do with a
|
|
46
|
+
* feature whose value is `false`.
|
|
47
|
+
*/
|
|
48
|
+
export function formatFeatureLabel(feature: OfferFeature): string {
|
|
49
|
+
const { name, value, limit } = feature;
|
|
50
|
+
const n = typeof limit === "number" ? limit : typeof value === "number" ? value : null;
|
|
51
|
+
if (n === -1) return `Unlimited ${midSentence(withoutPeriod(name))}`;
|
|
52
|
+
if (n !== null && n > 0) return `Up to ${n} ${midSentence(name)}`;
|
|
53
|
+
return name;
|
|
54
|
+
}
|