@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
package/src/utils/plan.ts
CHANGED
|
@@ -201,3 +201,125 @@ export function resolveUsageWindow(
|
|
|
201
201
|
);
|
|
202
202
|
return { start: monthStart, end: monthEnd };
|
|
203
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What a plan picker knows about the viewer. Omit it (signed-out pages) and
|
|
207
|
+
* no card is marked current.
|
|
208
|
+
*/
|
|
209
|
+
export interface CurrentPlanState {
|
|
210
|
+
signedIn: boolean;
|
|
211
|
+
subscription: SubscriptionInfo | null | undefined;
|
|
212
|
+
/** The subscription is still being fetched. */
|
|
213
|
+
loading?: boolean;
|
|
214
|
+
/** The subscription fetch failed. */
|
|
215
|
+
error?: unknown;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The offer to mark "Current plan" on a picker (crochet_patterns-34n7).
|
|
220
|
+
*
|
|
221
|
+
* A signed-in user with no subscription row is on the free plan — that is
|
|
222
|
+
* what every gate already assumes — so the free card is theirs. A paid plan
|
|
223
|
+
* counts while it is active, trialing or past due; a lapsed one falls back to
|
|
224
|
+
* free so the user can buy it again. Nothing is marked while the plan is
|
|
225
|
+
* unknown (loading / failed): a paying user must never see Free flash as
|
|
226
|
+
* current with their real plan offered as an upgrade.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveCurrentOfferId(
|
|
229
|
+
offers: ProductOffer[],
|
|
230
|
+
state: CurrentPlanState | null | undefined,
|
|
231
|
+
): string | null {
|
|
232
|
+
if (!state || !state.signedIn || state.loading || state.error) return null;
|
|
233
|
+
const offer = state.subscription?.offer ?? null;
|
|
234
|
+
if (offer) {
|
|
235
|
+
if (offer.isFree) return offer.id;
|
|
236
|
+
if (isPaidSubscription(state.subscription) || state.subscription?.status === "past_due") {
|
|
237
|
+
return offer.id;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return offers.find((o) => o.isFree)?.id ?? null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The plan to treat as the viewer's own on a picker: their subscription's
|
|
245
|
+
* offer while it counts (see resolveCurrentOfferId), else the free offer for a
|
|
246
|
+
* signed-in viewer. The offer may be one the picker does not list (a hidden
|
|
247
|
+
* complimentary plan); `isUnlistedOffer` says so. Null while unknown.
|
|
248
|
+
*/
|
|
249
|
+
export function resolveCurrentOffer(
|
|
250
|
+
offers: ProductOffer[],
|
|
251
|
+
state: CurrentPlanState | null | undefined,
|
|
252
|
+
): ProductOffer | null {
|
|
253
|
+
const id = resolveCurrentOfferId(offers, state);
|
|
254
|
+
if (!id) return null;
|
|
255
|
+
const own = state?.subscription?.offer ?? null;
|
|
256
|
+
if (own && own.id === id) return own;
|
|
257
|
+
return offers.find((o) => o.id === id) ?? null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** True when `offer` is not one of the offers for sale (e.g. a complimentary plan). */
|
|
261
|
+
export function isUnlistedOffer(offers: ProductOffer[], offer: ProductOffer | null | undefined): boolean {
|
|
262
|
+
return !!offer && !offers.some((o) => o.id === offer.id);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* True while the viewer pays for a plan that is still running (active,
|
|
267
|
+
* trialing or past due). Choosing another paid plan then changes that
|
|
268
|
+
* subscription in place — an upgrade is charged now, prorated, and never
|
|
269
|
+
* starts a trial — so pickers must not sell it as one (crochet_patterns-anw61).
|
|
270
|
+
*/
|
|
271
|
+
export function isPayingSubscription(sub: SubscriptionInfo | null | undefined): boolean {
|
|
272
|
+
if (!sub?.offer || sub.offer.isFree) return false;
|
|
273
|
+
return PAID_STATUSES.includes(sub.status) || sub.status === "past_due";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A card's direction relative to the viewer's plan:
|
|
278
|
+
* - "current" their exact offer
|
|
279
|
+
* - "switch" the same plan on the other billing interval (Lite ↔ Lite Annual)
|
|
280
|
+
* - "upgrade" paid over free, or a higher sortOrder
|
|
281
|
+
* - "downgrade" free under paid, or a lower sortOrder
|
|
282
|
+
* Unknown plan (null) → every card is an upgrade.
|
|
283
|
+
*/
|
|
284
|
+
export type PlanDirection = "current" | "upgrade" | "downgrade" | "switch";
|
|
285
|
+
|
|
286
|
+
export function planDirection(
|
|
287
|
+
card: ProductOffer,
|
|
288
|
+
current: ProductOffer | null | undefined,
|
|
289
|
+
tierKeyOf: (offerId: string) => string | null = () => null,
|
|
290
|
+
): PlanDirection {
|
|
291
|
+
if (!current) return "upgrade";
|
|
292
|
+
if (card.id === current.id) return "current";
|
|
293
|
+
const tierKey = tierKeyOf(card.id);
|
|
294
|
+
if (tierKey !== null && tierKey === tierKeyOf(current.id)) return "switch";
|
|
295
|
+
if (card.isFree && !current.isFree) return "downgrade";
|
|
296
|
+
if (!card.isFree && current.isFree) return "upgrade";
|
|
297
|
+
return card.sortOrder > current.sortOrder ? "upgrade" : "downgrade";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Whether the offer's free trial applies to this viewer: only an upgrade by
|
|
302
|
+
* someone not already paying goes through a checkout that starts one.
|
|
303
|
+
*/
|
|
304
|
+
export function trialApplies(card: ProductOffer, direction: PlanDirection, paying: boolean): boolean {
|
|
305
|
+
return direction === "upgrade" && !paying && (card.trialDays ?? 0) > 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The CTA for a card. `offer.ctaText` ("Start Free Trial") is the pitch to a
|
|
310
|
+
* new buyer; someone already paying is upgraded in place, so they get
|
|
311
|
+
* "Upgrade to X" (startsim-21f, crochet_patterns-anw61).
|
|
312
|
+
*/
|
|
313
|
+
export function planCtaLabel(card: ProductOffer, direction: PlanDirection, paying = false): string {
|
|
314
|
+
if (direction === "current") return "Current plan";
|
|
315
|
+
if (direction === "switch") return card.intervalUnit === "year" ? "Switch to annual" : "Switch to monthly";
|
|
316
|
+
if (direction === "downgrade") return `Downgrade to ${card.name}`;
|
|
317
|
+
if (paying) return `Upgrade to ${card.name}`;
|
|
318
|
+
return card.ctaText || `Upgrade to ${card.name}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A feature row that only describes the trial (key `trial_note`, e.g. "7-day
|
|
323
|
+
* free trial with AI designs included") is not true when the trial does not apply.
|
|
324
|
+
*/
|
|
325
|
+
export const TRIAL_NOTE_FEATURE_KEY = "trial_note";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing monthly and annual offers into one card per tier
|
|
3
|
+
* (crochet_patterns-34n7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { groupOffersByTier, offersForInterval, annualSavings } from "./tiers";
|
|
7
|
+
import {
|
|
8
|
+
crochetProduct,
|
|
9
|
+
crochetProductWithTiers,
|
|
10
|
+
crochetLite,
|
|
11
|
+
crochetLiteAnnual,
|
|
12
|
+
crochetPro,
|
|
13
|
+
} from "../components/__fixtures__/crochetProduct";
|
|
14
|
+
|
|
15
|
+
const slugs = (offers: { slug: string }[]) => offers.map((o) => o.slug);
|
|
16
|
+
|
|
17
|
+
describe("groupOffersByTier", () => {
|
|
18
|
+
it("pairs Lite with Lite Annual without a tier field (same features)", () => {
|
|
19
|
+
const tiers = groupOffersByTier(crochetProduct.offers);
|
|
20
|
+
expect(tiers.map((t) => t.name)).toEqual(["Free", "Lite", "Pro"]);
|
|
21
|
+
const lite = tiers[1];
|
|
22
|
+
expect(lite.monthly?.slug).toBe("crochet-lite");
|
|
23
|
+
expect(lite.annual?.slug).toBe("crochet-lite-annual");
|
|
24
|
+
expect(tiers[2].annual).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("pairs by tier when the API sends it", () => {
|
|
28
|
+
const tiers = groupOffersByTier(crochetProductWithTiers.offers);
|
|
29
|
+
expect(tiers.map((t) => [t.monthly?.slug ?? null, t.annual?.slug ?? null])).toEqual([
|
|
30
|
+
["crochet-free", null],
|
|
31
|
+
["crochet-lite", "crochet-lite-annual"],
|
|
32
|
+
["crochet-pro", "crochet-pro-annual"],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("falls back to the name prefix when features differ", () => {
|
|
37
|
+
const annual = { ...crochetLiteAnnual, features: [] };
|
|
38
|
+
const tiers = groupOffersByTier([crochetLite, annual, crochetPro]);
|
|
39
|
+
expect(tiers.map((t) => t.name)).toEqual(["Lite", "Pro"]);
|
|
40
|
+
expect(tiers[0].annual?.slug).toBe("crochet-lite-annual");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("never pairs two monthly offers", () => {
|
|
44
|
+
const tiers = groupOffersByTier([crochetLite, crochetPro]);
|
|
45
|
+
expect(tiers).toHaveLength(2);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("offersForInterval", () => {
|
|
50
|
+
it("monthly shows Free, Lite, Pro", () => {
|
|
51
|
+
expect(slugs(offersForInterval(crochetProduct.offers, "monthly")))
|
|
52
|
+
.toEqual(["crochet-free", "crochet-lite", "crochet-pro"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("annual shows Free in both modes and keeps a tier with no annual price", () => {
|
|
56
|
+
expect(slugs(offersForInterval(crochetProduct.offers, "annual")))
|
|
57
|
+
.toEqual(["crochet-free", "crochet-lite-annual", "crochet-pro"]);
|
|
58
|
+
expect(slugs(offersForInterval(crochetProductWithTiers.offers, "annual")))
|
|
59
|
+
.toEqual(["crochet-free", "crochet-lite-annual", "crochet-pro-annual"]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("annualSavings", () => {
|
|
64
|
+
it("gives the per-month equivalent and the saving against 12x monthly", () => {
|
|
65
|
+
const lite = groupOffersByTier(crochetProduct.offers)[1];
|
|
66
|
+
const s = annualSavings(lite);
|
|
67
|
+
expect(s).not.toBeNull();
|
|
68
|
+
expect(s!.perMonth).toBeCloseTo(29 / 12, 5);
|
|
69
|
+
expect(s!.percent).toBe(19); // 29 vs 35.88
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("is null without a monthly sibling to compare against", () => {
|
|
73
|
+
const pro = groupOffersByTier(crochetProduct.offers)[2];
|
|
74
|
+
expect(annualSavings(pro)).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/utils/tiers.ts
CHANGED
|
@@ -53,3 +53,108 @@ export function tierBelow(offers: ProductOffer[], offer: ProductOffer): ProductO
|
|
|
53
53
|
const below = index > 0 ? cheapestFirst[index - 1] : null;
|
|
54
54
|
return below && below.features.length > 0 ? below : null;
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
/** Which price a plan picker is showing. */
|
|
58
|
+
export type BillingInterval = "monthly" | "annual";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One plan tier as the picker shows it: the same plan billed monthly and/or
|
|
62
|
+
* yearly. Free has only a monthly offer and shows in both modes.
|
|
63
|
+
*/
|
|
64
|
+
export interface OfferTier {
|
|
65
|
+
key: string;
|
|
66
|
+
/** The tier's name without the interval: "Lite", never "Lite Annual". */
|
|
67
|
+
name: string;
|
|
68
|
+
monthly: ProductOffer | null;
|
|
69
|
+
annual: ProductOffer | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const isAnnual = (o: ProductOffer) => o.intervalUnit === "year";
|
|
73
|
+
|
|
74
|
+
const featureSignature = (o: ProductOffer) =>
|
|
75
|
+
JSON.stringify(o.features.map((f) => [f.key, f.value, f.limit ?? null]));
|
|
76
|
+
|
|
77
|
+
const stripInterval = (name: string) =>
|
|
78
|
+
name.replace(/\s*[-·(]?\s*(annual|annually|yearly|year)\)?\s*$/i, "").trim() || name;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pair each annual offer with the monthly offer of the same plan
|
|
82
|
+
* (crochet_patterns-34n7). Evidence, strongest first: the API's `tier`, an
|
|
83
|
+
* identical feature list, then the annual name starting with the monthly
|
|
84
|
+
* name ("Lite Annual" → "Lite"). Two monthly offers are never paired, and an
|
|
85
|
+
* annual offer with no match becomes a tier of its own.
|
|
86
|
+
*/
|
|
87
|
+
export function groupOffersByTier(offers: ProductOffer[]): OfferTier[] {
|
|
88
|
+
const tiers: Array<OfferTier & { position: number }> = [];
|
|
89
|
+
|
|
90
|
+
offers.forEach((offer, position) => {
|
|
91
|
+
if (isAnnual(offer)) return;
|
|
92
|
+
tiers.push({
|
|
93
|
+
key: offer.tier ? `tier:${offer.tier}` : `offer:${offer.slug}`,
|
|
94
|
+
name: offer.name,
|
|
95
|
+
monthly: offer,
|
|
96
|
+
annual: null,
|
|
97
|
+
position,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
offers.forEach((offer, position) => {
|
|
102
|
+
if (!isAnnual(offer)) return;
|
|
103
|
+
const open = tiers.filter((t) => t.annual === null && t.monthly && !t.monthly.isFree);
|
|
104
|
+
const signature = featureSignature(offer);
|
|
105
|
+
const lowerName = offer.name.toLowerCase();
|
|
106
|
+
const match =
|
|
107
|
+
(offer.tier ? open.find((t) => t.monthly?.tier === offer.tier) : undefined) ??
|
|
108
|
+
open.find((t) => offer.features.length > 0 && featureSignature(t.monthly!) === signature) ??
|
|
109
|
+
open
|
|
110
|
+
.filter((t) => lowerName.startsWith(`${t.monthly!.name.toLowerCase()} `))
|
|
111
|
+
.sort((a, b) => b.monthly!.name.length - a.monthly!.name.length)[0];
|
|
112
|
+
|
|
113
|
+
if (match) {
|
|
114
|
+
match.annual = offer;
|
|
115
|
+
} else {
|
|
116
|
+
tiers.push({
|
|
117
|
+
key: offer.tier ? `tier:${offer.tier}` : `offer:${offer.slug}`,
|
|
118
|
+
name: stripInterval(offer.name),
|
|
119
|
+
monthly: null,
|
|
120
|
+
annual: offer,
|
|
121
|
+
position,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return tiers
|
|
127
|
+
.sort((a, b) => a.position - b.position)
|
|
128
|
+
.map(({ position: _position, ...tier }) => tier);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The offer a tier shows for `interval`, falling back to the other one so a
|
|
132
|
+
* plan with no annual price stays on the page. */
|
|
133
|
+
export function tierOffer(tier: OfferTier, interval: BillingInterval): ProductOffer {
|
|
134
|
+
return (interval === "annual" ? tier.annual ?? tier.monthly : tier.monthly ?? tier.annual)!;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One offer per tier for the chosen interval, in catalogue order. */
|
|
138
|
+
export function offersForInterval(offers: ProductOffer[], interval: BillingInterval): ProductOffer[] {
|
|
139
|
+
return groupOffersByTier(offers).map((tier) => tierOffer(tier, interval));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True when at least one plan can be bought both monthly and yearly. */
|
|
143
|
+
export function hasIntervalChoice(offers: ProductOffer[]): boolean {
|
|
144
|
+
return groupOffersByTier(offers).some((t) => t.monthly !== null && t.annual !== null);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What yearly billing works out to per month, and the whole-percent saving
|
|
149
|
+
* against paying monthly for twelve months. Null when the tier has no
|
|
150
|
+
* monthly price to compare against.
|
|
151
|
+
*/
|
|
152
|
+
export function annualSavings(tier: OfferTier): { perMonth: number; percent: number } | null {
|
|
153
|
+
if (!tier.monthly || !tier.annual) return null;
|
|
154
|
+
const yearly = parseFloat(tier.annual.unitPrice);
|
|
155
|
+
const monthly = parseFloat(tier.monthly.unitPrice);
|
|
156
|
+
if (!Number.isFinite(yearly) || !Number.isFinite(monthly) || monthly <= 0) return null;
|
|
157
|
+
const twelve = monthly * 12;
|
|
158
|
+
const percent = yearly < twelve ? Math.floor(((twelve - yearly) / twelve) * 100) : 0;
|
|
159
|
+
return { perMonth: yearly / 12, percent };
|
|
160
|
+
}
|