@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 +35 -24
- package/src/entitlement.test.ts +63 -0
- package/src/entitlement.ts +54 -0
- package/src/handlers/checkout.ts +12 -1
package/package.json
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|
package/src/handlers/checkout.ts
CHANGED
|
@@ -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
|
-
|
|
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 },
|