@startsimpli/billing 0.4.21 → 0.4.23

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The tick or cross beside an offer feature. A feature an offer declares as
3
+ * `false` is one that plan does not include, so it gets a cross, never a tick.
4
+ */
5
+ export function FeatureIcon({ value, size = "w-4 h-4" }: { value: unknown; size?: string }) {
6
+ if (value === false) {
7
+ return (
8
+ <svg role="img" aria-label="Not included" className={`${size} text-gray-300 shrink-0`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
9
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
10
+ </svg>
11
+ );
12
+ }
13
+ return (
14
+ <svg role="img" aria-label="Included" className={`${size} text-green-500 shrink-0`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
15
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
16
+ </svg>
17
+ );
18
+ }
@@ -12,6 +12,7 @@ import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer, OfferFeature } from "../types";
14
14
  import { cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
+ import { FeatureIcon } from "./FeatureIcon";
15
16
 
16
17
  export interface PricingDetailPageProps {
17
18
  /** Product slug (e.g., "raise-simpli") */
@@ -190,21 +191,6 @@ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
190
191
  );
191
192
  }
192
193
 
193
- function FeatureIcon({ value }: { value: unknown }) {
194
- if (value === false) {
195
- return (
196
- <svg role="img" aria-label="Not included" className="w-4 h-4 text-gray-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
197
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
198
- </svg>
199
- );
200
- }
201
- return (
202
- <svg role="img" aria-label="Included" className="w-4 h-4 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
203
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
204
- </svg>
205
- );
206
- }
207
-
208
194
  interface ComparisonTableProps {
209
195
  offers: ProductOffer[];
210
196
  featureKeys: string[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A feature an offer declares as `false` is a feature that plan does NOT have.
3
+ * The pricing cards ticked every feature, so crochet's Free plan read as including
4
+ * "AI pattern designs" (crochet_patterns-utk6).
5
+ */
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { render, screen, waitFor, within } from "@testing-library/react";
8
+ import React from "react";
9
+ import { BillingProvider } from "../hooks/BillingProvider";
10
+ import { PricingPage } from "./PricingPage";
11
+ import type { BillingProduct } from "../types";
12
+
13
+ const product: BillingProduct = {
14
+ id: "prod-1",
15
+ slug: "crochet-patterns",
16
+ name: "Crochet",
17
+ description: "",
18
+ offers: [
19
+ {
20
+ id: "free", name: "Free", slug: "crochet-free", unitPrice: "0.00", currency: "USD",
21
+ pricingModel: "flat", intervalUnit: "month", intervalCount: 1, intervalDisplay: "per month",
22
+ isRecurring: true, isFree: true,
23
+ features: [
24
+ { key: "ai_designs", name: "AI pattern designs", value: false, limit: null },
25
+ { key: "save_patterns", name: "Save patterns", value: true, limit: null },
26
+ ],
27
+ isActive: true, isFeatured: false, trialDays: 0, sortOrder: 0, ctaText: "Start free",
28
+ },
29
+ ],
30
+ };
31
+
32
+ function renderModal() {
33
+ const fetcher = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(product) }) as unknown as typeof fetch;
34
+ render(
35
+ <BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
36
+ <PricingPage productId="crochet-patterns" />
37
+ </BillingProvider>
38
+ );
39
+ }
40
+
41
+ describe("PricingPage feature list", () => {
42
+ it("marks a false-valued feature as not included", async () => {
43
+ renderModal();
44
+ const row = (await screen.findByText("AI pattern designs")).closest("li")!;
45
+ expect(within(row).getByRole("img", { name: "Not included" })).toBeDefined();
46
+ expect(within(row).queryByRole("img", { name: "Included" })).toBeNull();
47
+ });
48
+
49
+ it("still ticks an included feature", async () => {
50
+ renderModal();
51
+ const row = (await screen.findByText("Save patterns")).closest("li")!;
52
+ await waitFor(() => expect(within(row).getByRole("img", { name: "Included" })).toBeDefined());
53
+ });
54
+ });
@@ -11,6 +11,7 @@
11
11
  import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer } from "../types";
14
+ import { FeatureIcon } from "./FeatureIcon";
14
15
 
15
16
  export interface PricingPageProps {
16
17
  /** Product slug (e.g., "raise-simpli") */
@@ -127,9 +128,7 @@ function PricingCard({ offer, onSelect }: PricingCardProps) {
127
128
  key={feature.key}
128
129
  className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
129
130
  >
130
- <svg className="w-4 h-4 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
131
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
132
- </svg>
131
+ <FeatureIcon value={feature.value} />
133
132
  <span>{feature.name}</span>
134
133
  </li>
135
134
  ))}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A feature an offer declares as `false` is a feature that plan does NOT have.
3
+ * The modal ticked every feature, so crochet's Free plan read as including
4
+ * "AI pattern designs" (crochet_patterns-utk6).
5
+ */
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { render, screen, waitFor, within } from "@testing-library/react";
8
+ import React from "react";
9
+ import { BillingProvider } from "../hooks/BillingProvider";
10
+ import { UpgradeModal } from "./UpgradeModal";
11
+ import type { BillingProduct } from "../types";
12
+
13
+ const product: BillingProduct = {
14
+ id: "prod-1",
15
+ slug: "crochet-patterns",
16
+ name: "Crochet",
17
+ description: "",
18
+ offers: [
19
+ {
20
+ id: "free", name: "Free", slug: "crochet-free", unitPrice: "0.00", currency: "USD",
21
+ pricingModel: "flat", intervalUnit: "month", intervalCount: 1, intervalDisplay: "per month",
22
+ isRecurring: true, isFree: true,
23
+ features: [
24
+ { key: "ai_designs", name: "AI pattern designs", value: false, limit: null },
25
+ { key: "save_patterns", name: "Save patterns", value: true, limit: null },
26
+ ],
27
+ isActive: true, isFeatured: false, trialDays: 0, sortOrder: 0, ctaText: "Start free",
28
+ },
29
+ ],
30
+ };
31
+
32
+ function renderModal() {
33
+ const fetcher = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(product) }) as unknown as typeof fetch;
34
+ render(
35
+ <BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
36
+ <UpgradeModal productId="crochet-patterns" open={true} onClose={() => {}} />
37
+ </BillingProvider>
38
+ );
39
+ }
40
+
41
+ describe("UpgradeModal feature list", () => {
42
+ it("marks a false-valued feature as not included", async () => {
43
+ renderModal();
44
+ const row = (await screen.findByText("AI pattern designs")).closest("li")!;
45
+ expect(within(row).getByRole("img", { name: "Not included" })).toBeDefined();
46
+ expect(within(row).queryByRole("img", { name: "Included" })).toBeNull();
47
+ });
48
+
49
+ it("still ticks an included feature", async () => {
50
+ renderModal();
51
+ const row = (await screen.findByText("Save patterns")).closest("li")!;
52
+ await waitFor(() => expect(within(row).getByRole("img", { name: "Included" })).toBeDefined());
53
+ });
54
+ });
@@ -12,6 +12,7 @@
12
12
  import React, { useEffect, useRef, useCallback } from "react";
13
13
  import { useProduct } from "../hooks/useProduct";
14
14
  import type { ProductOffer } from "../types";
15
+ import { FeatureIcon } from "./FeatureIcon";
15
16
 
16
17
  export interface UpgradeModalProps {
17
18
  productId: string;
@@ -162,9 +163,7 @@ export function UpgradeModal({
162
163
  <ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
163
164
  {offer.features.map((f) => (
164
165
  <li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
165
- <svg className="w-3.5 h-3.5 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
166
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
167
- </svg>
166
+ <FeatureIcon value={f.value} size="w-3.5 h-3.5" />
168
167
  {f.name}
169
168
  </li>
170
169
  ))}
@@ -76,6 +76,8 @@ export interface SubscriptionInfo {
76
76
  viewerIsOwner?: boolean;
77
77
  /** Email of the team owner so non-owners can be told who to ask. */
78
78
  ownerEmail?: string | null;
79
+ /** The server sends this subscription's GA4 conversion; the browser must not. */
80
+ conversionByServer?: boolean;
79
81
  }
80
82
 
81
83
  export interface PaymentMethod {
@@ -44,6 +44,19 @@ describe("BillingApiClient", () => {
44
44
  });
45
45
  });
46
46
 
47
+ describe("subscription conversion owner", () => {
48
+ it("keeps conversionByServer on both the rich and the flat cache shape", async () => {
49
+ const offer = { id: "o1", slug: "pro", name: "Pro" };
50
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: "s1", offer, status: "trialing", conversionByServer: true }) });
51
+ expect((await createClient().getSubscription("p"))?.conversionByServer).toBe(true);
52
+
53
+ mockFetch
54
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ subscriptionId: "s1", status: "trialing", productSlug: "p", offerId: "o1", conversionByServer: true }) })
55
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ slug: "p", name: "P", offers: [offer] }) });
56
+ expect((await createClient().getSubscription("p"))?.conversionByServer).toBe(true);
57
+ });
58
+ });
59
+
47
60
  describe("createCheckout", () => {
48
61
  it("sends POST without auth header (proxy handles auth server-side)", async () => {
49
62
  mockFetch.mockResolvedValue({
@@ -71,6 +84,19 @@ describe("BillingApiClient", () => {
71
84
  );
72
85
  });
73
86
 
87
+ it("sends the buyer's GA ids so the server can send the conversion", async () => {
88
+ document.cookie = "_ga=GA1.1.1234567890.1760000000; path=/";
89
+ document.cookie = "_ga_K1M560RH1W=GS2.1.s1760600000$o3$g1$t1760600500; path=/";
90
+ mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ sessionId: "cs_1", url: "https://pay.com" }) });
91
+
92
+ await createClient().createCheckout({ offerId: "offer-1", successUrl: "https://ok.com", cancelUrl: "https://nope.com" });
93
+
94
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
95
+ expect(body.analytics).toEqual({ ga_client_id: "1234567890.1760000000", ga_session_id: "1760600000" });
96
+ document.cookie = "_ga=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
97
+ document.cookie = "_ga_K1M560RH1W=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
98
+ });
99
+
74
100
  it("includes quantity in request body", async () => {
75
101
  mockFetch.mockResolvedValue({
76
102
  ok: true,
package/src/utils/api.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  UsageMap,
14
14
  } from "../types";
15
15
  import { billingErrorMessage } from "./error-message";
16
+ import { currentGaIds } from "./ga-ids";
16
17
 
17
18
  export class BillingApiClient {
18
19
  private config: BillingConfig;
@@ -49,6 +50,7 @@ export class BillingApiClient {
49
50
  quantity?: number;
50
51
  }): Promise<CheckoutResult> {
51
52
  const url = `${this.config.apiBaseUrl}/billing/offer-checkout/`;
53
+ const ga = currentGaIds();
52
54
  const resp = await this.fetcher(url, {
53
55
  method: "POST",
54
56
  headers: this.headers,
@@ -57,6 +59,8 @@ export class BillingApiClient {
57
59
  success_url: params.successUrl,
58
60
  cancel_url: params.cancelUrl,
59
61
  quantity: params.quantity ?? 1,
62
+ // For the subscription's conversion, sent from the server.
63
+ ...(ga && { analytics: ga }),
60
64
  }),
61
65
  });
62
66
  if (!resp.ok) {
@@ -200,6 +204,7 @@ export class BillingApiClient {
200
204
  currentPeriodEnd: toIso(r.currentPeriodEnd),
201
205
  cancelAtPeriodEnd: Boolean(r.cancelAtPeriodEnd),
202
206
  trialEnd: toIso(r.trialEnd),
207
+ conversionByServer: Boolean(r.conversionByServer),
203
208
  };
204
209
  } catch {
205
210
  // Product fetch failed — degrade to "no subscription" instead of crashing.
@@ -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
+ }