@pylonsync/stripe 0.10.1 → 0.11.1

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,26 +1,37 @@
1
1
  {
2
- "name": "@pylonsync/stripe",
3
- "publishConfig": {
4
- "access": "public"
5
- },
6
- "version": "0.10.1",
7
- "type": "module",
8
- "main": "src/index.ts",
9
- "types": "src/index.ts",
10
- "scripts": {
11
- "build": "tsc -p tsconfig.json --noEmit",
12
- "check": "tsc -p tsconfig.json --noEmit"
13
- },
14
- "dependencies": {
15
- "@pylonsync/sdk": "0.10.1",
16
- "@pylonsync/functions": "0.10.1"
17
- },
18
- "peerDependencies": {
19
- "bun-types": "*"
20
- },
21
- "peerDependenciesMeta": {
22
- "bun-types": {
23
- "optional": true
24
- }
25
- }
2
+ "name": "@pylonsync/stripe",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.11.1",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json --noEmit",
12
+ "check": "tsc -p tsconfig.json --noEmit"
13
+ },
14
+ "dependencies": {
15
+ "@pylonsync/sdk": "0.11.1",
16
+ "@pylonsync/functions": "0.11.1"
17
+ },
18
+ "peerDependencies": {
19
+ "bun-types": "*"
20
+ },
21
+ "peerDependenciesMeta": {
22
+ "bun-types": {
23
+ "optional": true
24
+ }
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./src/index.ts",
29
+ "default": "./src/index.ts"
30
+ },
31
+ "./entitlement": {
32
+ "types": "./src/entitlement.ts",
33
+ "default": "./src/entitlement.ts"
34
+ },
35
+ "./package.json": "./package.json"
36
+ }
26
37
  }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { hasStripeSubscription } from "./entitlement";
3
+
4
+ const NOW = Date.parse("2026-09-12T00:00:00Z");
5
+ const future = "2026-10-01T00:00:00Z";
6
+ const past = "2026-09-01T00:00:00Z";
7
+
8
+ describe("stripe entitlement, read from synced rows", () => {
9
+ test("an active subscription inside its period carries access", () => {
10
+ expect(hasStripeSubscription([{ status: "active", currentPeriodEnd: future }], NOW)).toBe(true);
11
+ });
12
+
13
+ test("a trial is access", () => {
14
+ expect(hasStripeSubscription([{ status: "trialing", currentPeriodEnd: future }], NOW)).toBe(
15
+ true,
16
+ );
17
+ });
18
+
19
+ test("past_due keeps access until the paid period actually ends", () => {
20
+ // Every card retry takes days. Cutting a paying customer off over a
21
+ // payment that will clear tomorrow costs more than the days it saves.
22
+ expect(hasStripeSubscription([{ status: "past_due", currentPeriodEnd: future }], NOW)).toBe(
23
+ true,
24
+ );
25
+ expect(hasStripeSubscription([{ status: "past_due", currentPeriodEnd: past }], NOW)).toBe(false);
26
+ });
27
+
28
+ test("an unpaid first invoice is not access", () => {
29
+ // `incomplete` is how someone would otherwise subscribe by abandoning
30
+ // a card form.
31
+ expect(hasStripeSubscription([{ status: "incomplete", currentPeriodEnd: future }], NOW)).toBe(
32
+ false,
33
+ );
34
+ });
35
+
36
+ test("an elapsed period is inactive even before the webhook says so", () => {
37
+ expect(hasStripeSubscription([{ status: "active", currentPeriodEnd: past }], NOW)).toBe(false);
38
+ });
39
+
40
+ test("a row with no period end grants nothing", () => {
41
+ // An undated subscription is a bug upstream; granting on it forever is
42
+ // the wrong way to find out.
43
+ expect(hasStripeSubscription([{ status: "active" }], NOW)).toBe(false);
44
+ expect(hasStripeSubscription([{ status: "active", currentPeriodEnd: null }], NOW)).toBe(false);
45
+ expect(hasStripeSubscription([{ status: "active", currentPeriodEnd: "soon" }], NOW)).toBe(false);
46
+ });
47
+
48
+ test("a lapsed row does not hide a live one", () => {
49
+ expect(
50
+ hasStripeSubscription(
51
+ [
52
+ { status: "canceled", currentPeriodEnd: past },
53
+ { status: "active", currentPeriodEnd: future },
54
+ ],
55
+ NOW,
56
+ ),
57
+ ).toBe(true);
58
+ });
59
+
60
+ test("no rows is no access", () => {
61
+ expect(hasStripeSubscription([], NOW)).toBe(false);
62
+ });
63
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `@pylonsync/stripe/entitlement` — the part of the plugin that runs in app
3
+ * code (browser, React Native).
4
+ *
5
+ * No server imports, so Metro and browser bundlers can take it. The package
6
+ * root and `./client` are server-only: `client.ts` is the REST client and
7
+ * carries the secret key.
8
+ *
9
+ * This exists because an app that sells on the web AND through the stores
10
+ * has two sources of truth — a StripeSubscription row for a web purchase and
11
+ * an RcEntitlement row for a store purchase — and a subscriber has exactly
12
+ * one of them. Reading only the store's rows is how someone pays on the web
13
+ * and gets nothing on their phone.
14
+ */
15
+
16
+ /** One row of the subscription entity, as the client reads it through sync. */
17
+ export interface StripeSubscriptionRow {
18
+ id: string;
19
+ referenceId: string;
20
+ plan: string;
21
+ /** Stripe's own status: active, trialing, past_due, canceled, … */
22
+ status: string;
23
+ currentPeriodEnd?: string | null;
24
+ cancelAtPeriodEnd?: boolean | null;
25
+ }
26
+
27
+ /**
28
+ * Statuses that still carry access.
29
+ *
30
+ * `past_due` does: every card retry takes days, and cutting a paying
31
+ * customer off over a payment that will clear tomorrow costs more than the
32
+ * few days it saves. `trialing` does, because a trial is access. Nothing
33
+ * else — in particular `incomplete`, where the first payment never cleared,
34
+ * which is how someone gets a subscription by abandoning a card form.
35
+ */
36
+ const LIVE_STATUSES = new Set(["active", "trialing", "past_due"]);
37
+
38
+ /**
39
+ * Is there a live Stripe subscription in a list of rows read through sync?
40
+ *
41
+ * A row whose paid period has elapsed is treated as inactive even if the
42
+ * webhook carrying that news has not arrived yet.
43
+ */
44
+ export function hasStripeSubscription(
45
+ rows: ReadonlyArray<{ status: string; currentPeriodEnd?: string | null }>,
46
+ now: number = Date.now(),
47
+ ): boolean {
48
+ return rows.some((r) => {
49
+ if (!LIVE_STATUSES.has(r.status)) return false;
50
+ if (r.currentPeriodEnd == null) return false;
51
+ const end = new Date(r.currentPeriodEnd).getTime();
52
+ return Number.isFinite(end) && end > now;
53
+ });
54
+ }
@@ -33,6 +33,14 @@ export function createCheckoutSessionHandler(cfg: StripeConfig) {
33
33
  cancelUrl: v.string(),
34
34
  annual: v.optional(v.boolean()),
35
35
  seats: v.optional(v.number()),
36
+ /**
37
+ * An analytics visitor id, carried to Stripe as the session's
38
+ * client_reference_id so the eventual payment attributes back to
39
+ * the visit that caused it. Without it every purchase looks like
40
+ * it came from nowhere, since the webhook arrives from Stripe
41
+ * rather than from a browser with a session.
42
+ */
43
+ clientReferenceId: v.optional(v.string()),
36
44
  },
37
45
  async handler(
38
46
  ctx: HandlerCtx,
@@ -43,6 +51,7 @@ export function createCheckoutSessionHandler(cfg: StripeConfig) {
43
51
  cancelUrl: string;
44
52
  annual?: boolean;
45
53
  seats?: number;
54
+ clientReferenceId?: string;
46
55
  },
47
56
  ) {
48
57
  const referenceId = args.referenceId ?? defaultReferenceId(ctx, cfg);
@@ -105,7 +114,9 @@ export function createCheckoutSessionHandler(cfg: StripeConfig) {
105
114
  },
106
115
  ],
107
116
  allow_promotion_codes: true,
108
- client_reference_id: referenceId,
117
+ // The caller's id when given: attribution wants the visitor,
118
+ // not the account, because the visit came first.
119
+ client_reference_id: args.clientReferenceId ?? referenceId,
109
120
  metadata: { referenceId, plan: plan.name },
110
121
  subscription_data: {
111
122
  metadata: { referenceId, plan: plan.name },