@startsimpli/billing 0.4.22 → 0.4.24

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.
@@ -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
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readGaIds } from "./ga-ids";
3
+
4
+ describe("readGaIds", () => {
5
+ it("reads the client id from _ga and the session id from a GS2 _ga_<stream> cookie", () => {
6
+ expect(
7
+ readGaIds("foo=1; _ga=GA1.1.1234567890.1760000000; _ga_K1M560RH1W=GS2.1.s1760600000$o3$g1$t1760600500$j0$l0$h0"),
8
+ ).toEqual({ ga_client_id: "1234567890.1760000000", ga_session_id: "1760600000" });
9
+ });
10
+
11
+ it("reads the session id from the older GS1 format", () => {
12
+ expect(readGaIds("_ga=GA1.2.111.222; _ga_ABC=GS1.1.1760600001.4.1.1760600500.0.0.0")).toEqual({
13
+ ga_client_id: "111.222",
14
+ ga_session_id: "1760600001",
15
+ });
16
+ });
17
+
18
+ it("returns a client id without a session, and nothing without _ga", () => {
19
+ expect(readGaIds("_ga=GA1.1.111.222")).toEqual({ ga_client_id: "111.222", ga_session_id: "" });
20
+ expect(readGaIds("other=1")).toBeNull();
21
+ expect(readGaIds("")).toBeNull();
22
+ });
23
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The buyer's GA4 client and session ids, read from gtag's cookies, so the
3
+ * server can send the subscription's conversion to the same GA user and
4
+ * session (and Google Ads can credit the ad click). Sent with checkout.
5
+ *
6
+ * _ga=GA1.1.<client id: two numbers>
7
+ * _ga_<stream>=GS1.1.<session id>.<...> (before 2025)
8
+ * _ga_<stream>=GS2.1.s<session id>$o<n>$... (since 2025)
9
+ */
10
+ export interface GaIds {
11
+ ga_client_id: string;
12
+ ga_session_id: string;
13
+ }
14
+
15
+ export function readGaIds(cookie: string): GaIds | null {
16
+ const jar = new Map<string, string>();
17
+ for (const part of cookie.split(";")) {
18
+ const i = part.indexOf("=");
19
+ if (i > 0) jar.set(part.slice(0, i).trim(), decodeURIComponent(part.slice(i + 1).trim()));
20
+ }
21
+ const ga = jar.get("_ga")?.split(".");
22
+ if (!ga || ga.length < 4) return null;
23
+ const clientId = ga.slice(-2).join(".");
24
+
25
+ let sessionId = "";
26
+ for (const [name, value] of jar) {
27
+ if (!name.startsWith("_ga_")) continue;
28
+ const gs2 = value.match(/^GS2\.\d+\.s(\d+)/);
29
+ const gs1 = value.match(/^GS1\.\d+\.(\d+)\./);
30
+ sessionId = gs2?.[1] ?? gs1?.[1] ?? "";
31
+ if (sessionId) break;
32
+ }
33
+ return { ga_client_id: clientId, ga_session_id: sessionId };
34
+ }
35
+
36
+ /** readGaIds over document.cookie; null outside a browser. */
37
+ export function currentGaIds(): GaIds | null {
38
+ try {
39
+ return typeof document === "undefined" ? null : readGaIds(document.cookie);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
package/src/utils/plan.ts CHANGED
@@ -201,3 +201,41 @@ 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
+ }
@@ -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
+ });
@@ -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
+ }