@startsimpli/billing 0.4.22 → 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.22",
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",
@@ -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
+ }