@pylonsync/stripe 0.3.83
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 +26 -0
- package/src/client.ts +100 -0
- package/src/handlers/cancel.ts +79 -0
- package/src/handlers/checkout.ts +154 -0
- package/src/handlers/index.ts +6 -0
- package/src/handlers/internal.ts +154 -0
- package/src/handlers/portal.ts +69 -0
- package/src/handlers/queries.ts +194 -0
- package/src/handlers/restore.ts +64 -0
- package/src/handlers/webhook.ts +156 -0
- package/src/index.ts +127 -0
- package/src/manifest.ts +123 -0
- package/src/plans.ts +57 -0
- package/src/safe-url.test.ts +71 -0
- package/src/safe-url.ts +82 -0
- package/src/signature.test.ts +93 -0
- package/src/signature.ts +88 -0
- package/src/types.ts +259 -0
- package/tsconfig.json +9 -0
package/src/plans.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan resolution helpers — map a Stripe subscription item back to
|
|
3
|
+
* the app's plan name, and the reverse (plan name → price id) for
|
|
4
|
+
* checkout session creation.
|
|
5
|
+
*
|
|
6
|
+
* Stripe sometimes elides `nickname` / `lookup_key` on webhook
|
|
7
|
+
* payloads (the price object is shallow-expanded), which made
|
|
8
|
+
* yapless's old plan-derivation function silently demote orgs to
|
|
9
|
+
* "pending" after a successful checkout. We try three signals in
|
|
10
|
+
* order so the resolver still works when any one is missing:
|
|
11
|
+
*
|
|
12
|
+
* 1. price.lookup_key (operator-set, most stable)
|
|
13
|
+
* 2. price.nickname (often elided on webhooks)
|
|
14
|
+
* 3. price.id matched against the plan catalog's priceId/annualPriceId
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { StripePlan, StripeSubscription } from "./types";
|
|
18
|
+
|
|
19
|
+
export function findPlanByName(
|
|
20
|
+
plans: StripePlan[],
|
|
21
|
+
name: string,
|
|
22
|
+
): StripePlan | undefined {
|
|
23
|
+
return plans.find((p) => p.name === name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ResolvedPlan {
|
|
27
|
+
plan: StripePlan | null;
|
|
28
|
+
annual: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function planFromSubscription(
|
|
32
|
+
plans: StripePlan[],
|
|
33
|
+
sub: StripeSubscription,
|
|
34
|
+
): ResolvedPlan {
|
|
35
|
+
const item = sub.items.data[0];
|
|
36
|
+
if (!item) return { plan: null, annual: false };
|
|
37
|
+
const priceId = item.price.id;
|
|
38
|
+
const key = item.price.lookup_key ?? item.price.nickname ?? undefined;
|
|
39
|
+
if (key) {
|
|
40
|
+
const byKey = plans.find((p) => p.name === key);
|
|
41
|
+
if (byKey) return { plan: byKey, annual: byKey.annualPriceId === priceId };
|
|
42
|
+
}
|
|
43
|
+
for (const p of plans) {
|
|
44
|
+
if (p.priceId === priceId) return { plan: p, annual: false };
|
|
45
|
+
if (p.annualPriceId === priceId) return { plan: p, annual: true };
|
|
46
|
+
}
|
|
47
|
+
return { plan: null, annual: false };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function periodEndFromSubscription(
|
|
51
|
+
sub: StripeSubscription,
|
|
52
|
+
): string | null {
|
|
53
|
+
const ts =
|
|
54
|
+
sub.items.data[0]?.current_period_end ?? sub.current_period_end ?? null;
|
|
55
|
+
if (!ts) return null;
|
|
56
|
+
return new Date(ts * 1000).toISOString();
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { assertSafeRedirectUrl } from "./safe-url";
|
|
4
|
+
|
|
5
|
+
test("accepts URL on the PYLON_PUBLIC_URL host", () => {
|
|
6
|
+
expect(() =>
|
|
7
|
+
assertSafeRedirectUrl(
|
|
8
|
+
"https://api.getyapless.com/dashboard?welcome=1",
|
|
9
|
+
"https://api.getyapless.com",
|
|
10
|
+
),
|
|
11
|
+
).not.toThrow();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("accepts subdomain of the PYLON_PUBLIC_URL host", () => {
|
|
15
|
+
// PYLON_PUBLIC_URL=https://api.acme.com → www.acme.com allowed.
|
|
16
|
+
// (The check is suffix-based on the trailing dot, so api.acme.com
|
|
17
|
+
// allows *.acme.com? — we actually allow only hosts ending in
|
|
18
|
+
// `.<host>`, so we accept "ext.api.acme.com" but NOT "acme.com"
|
|
19
|
+
// or "www.acme.com". This is intentional — the SyncProjectCors
|
|
20
|
+
// helper in pylon-cloud emits the apex/www variants explicitly.)
|
|
21
|
+
expect(() =>
|
|
22
|
+
assertSafeRedirectUrl(
|
|
23
|
+
"https://ext.api.getyapless.com/cb",
|
|
24
|
+
"https://api.getyapless.com",
|
|
25
|
+
),
|
|
26
|
+
).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects unrelated host", () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
assertSafeRedirectUrl(
|
|
32
|
+
"https://attacker.example.com/steal",
|
|
33
|
+
"https://api.getyapless.com",
|
|
34
|
+
),
|
|
35
|
+
).toThrow(/not on the allowed host/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("accepts localhost in dev", () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
assertSafeRedirectUrl("http://localhost:3000/cb", "https://api.x.com"),
|
|
41
|
+
).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("explicit extraHost widens the allowlist", () => {
|
|
45
|
+
expect(() =>
|
|
46
|
+
assertSafeRedirectUrl(
|
|
47
|
+
"https://www.getyapless.com/dashboard",
|
|
48
|
+
"https://api.getyapless.com",
|
|
49
|
+
{ extraHost: "www.getyapless.com" },
|
|
50
|
+
),
|
|
51
|
+
).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("extraOrigins (PYLON_CORS_ORIGIN-style) parses comma list", () => {
|
|
55
|
+
expect(() =>
|
|
56
|
+
assertSafeRedirectUrl(
|
|
57
|
+
"https://www.getyapless.com/dashboard",
|
|
58
|
+
"https://api.getyapless.com",
|
|
59
|
+
{
|
|
60
|
+
extraOrigins:
|
|
61
|
+
"https://getyapless.com,https://www.getyapless.com,https://pylon-yapless.fly.dev",
|
|
62
|
+
},
|
|
63
|
+
),
|
|
64
|
+
).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("malformed URLs throw a clear error", () => {
|
|
68
|
+
expect(() => assertSafeRedirectUrl("not-a-url", "https://x.com")).toThrow(
|
|
69
|
+
/invalid URL/,
|
|
70
|
+
);
|
|
71
|
+
});
|
package/src/safe-url.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL allowlist for Stripe Checkout `success_url` + `cancel_url`.
|
|
3
|
+
*
|
|
4
|
+
* The bug we're closing: hardcoded allowlists ("yapless.com" vs the
|
|
5
|
+
* actual "getyapless.com") cause every checkout button to throw
|
|
6
|
+
* "URL is not on the allowed host" in prod. Reading allowlist from
|
|
7
|
+
* `ctx.env.PYLON_PUBLIC_URL` (which Pylon Cloud auto-sets) eliminates
|
|
8
|
+
* the whole bug class — the same env that drives OAuth callbacks +
|
|
9
|
+
* SAML responses also drives Stripe redirect validation.
|
|
10
|
+
*
|
|
11
|
+
* Defense:
|
|
12
|
+
* - Allowed origins = [PYLON_PUBLIC_URL host] + extras provided
|
|
13
|
+
* by the app (e.g. its marketing root if different from the API
|
|
14
|
+
* host) + localhost/127.0.0.1 in dev.
|
|
15
|
+
* - Subdomain match (api.acme.com → also allows www.acme.com via
|
|
16
|
+
* suffix logic) is intentional: SaaS apps almost always serve
|
|
17
|
+
* frontend + backend under sibling subdomains, and forcing the
|
|
18
|
+
* app to list every subdomain creates the same hardcoding bug
|
|
19
|
+
* this is trying to prevent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface AssertSafeUrlOptions {
|
|
23
|
+
/** Comma-separated list pulled from `ctx.env.PYLON_CORS_ORIGIN` */
|
|
24
|
+
extraOrigins?: string;
|
|
25
|
+
/** Single hostname override (legacy `YAPLESS_BASE_HOST`-style env). */
|
|
26
|
+
extraHost?: string;
|
|
27
|
+
/** Default: PYLON_DEV_MODE → true. Apps can force-disable for tests. */
|
|
28
|
+
allowLocalhost?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function assertSafeRedirectUrl(
|
|
32
|
+
url: string,
|
|
33
|
+
publicUrl: string | undefined,
|
|
34
|
+
opts: AssertSafeUrlOptions = {},
|
|
35
|
+
): void {
|
|
36
|
+
let parsed: URL;
|
|
37
|
+
try {
|
|
38
|
+
parsed = new URL(url);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(`invalid URL: ${url}`);
|
|
41
|
+
}
|
|
42
|
+
const host = parsed.hostname;
|
|
43
|
+
const allowed = collectAllowedHosts(publicUrl, opts);
|
|
44
|
+
for (const a of allowed) {
|
|
45
|
+
if (host === a) return;
|
|
46
|
+
if (host.endsWith(`.${a}`)) return;
|
|
47
|
+
}
|
|
48
|
+
const allowList = allowed.length > 0 ? allowed.join(", ") : "(none)";
|
|
49
|
+
throw new Error(`URL ${url} is not on the allowed host (${allowList})`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectAllowedHosts(
|
|
53
|
+
publicUrl: string | undefined,
|
|
54
|
+
opts: AssertSafeUrlOptions,
|
|
55
|
+
): string[] {
|
|
56
|
+
const out = new Set<string>();
|
|
57
|
+
if (publicUrl) {
|
|
58
|
+
try {
|
|
59
|
+
out.add(new URL(publicUrl).hostname);
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore — malformed env shouldn't crash checkout
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (opts.extraHost) out.add(opts.extraHost);
|
|
65
|
+
if (opts.extraOrigins) {
|
|
66
|
+
for (const o of opts.extraOrigins.split(",")) {
|
|
67
|
+
const trimmed = o.trim();
|
|
68
|
+
if (!trimmed) continue;
|
|
69
|
+
try {
|
|
70
|
+
out.add(new URL(trimmed).hostname);
|
|
71
|
+
} catch {
|
|
72
|
+
// Bare hostname (no scheme) — accept as-is.
|
|
73
|
+
out.add(trimmed);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (opts.allowLocalhost ?? true) {
|
|
78
|
+
out.add("localhost");
|
|
79
|
+
out.add("127.0.0.1");
|
|
80
|
+
}
|
|
81
|
+
return Array.from(out);
|
|
82
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { verifyStripeSignature } from "./signature";
|
|
4
|
+
|
|
5
|
+
const SECRET = "whsec_test_secret_value";
|
|
6
|
+
const RAW_BODY = '{"id":"evt_test","type":"customer.subscription.created"}';
|
|
7
|
+
|
|
8
|
+
async function hmacHex(secret: string, payload: string): Promise<string> {
|
|
9
|
+
const enc = new TextEncoder();
|
|
10
|
+
const key = await crypto.subtle.importKey(
|
|
11
|
+
"raw",
|
|
12
|
+
enc.encode(secret),
|
|
13
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
14
|
+
false,
|
|
15
|
+
["sign"],
|
|
16
|
+
);
|
|
17
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload));
|
|
18
|
+
return [...new Uint8Array(sig)]
|
|
19
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
20
|
+
.join("");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function signedHeader(
|
|
24
|
+
secret: string,
|
|
25
|
+
rawBody: string,
|
|
26
|
+
tsSecs: number,
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const sig = await hmacHex(secret, `${tsSecs}.${rawBody}`);
|
|
29
|
+
return `t=${tsSecs},v1=${sig}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("verify accepts a valid signature within tolerance", async () => {
|
|
33
|
+
const now = 1_700_000_000;
|
|
34
|
+
const header = await signedHeader(SECRET, RAW_BODY, now);
|
|
35
|
+
const result = await verifyStripeSignature(SECRET, RAW_BODY, header, {
|
|
36
|
+
nowSecs: now,
|
|
37
|
+
});
|
|
38
|
+
expect(result).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("verify rejects a stale timestamp (replay window)", async () => {
|
|
42
|
+
const eventTs = 1_700_000_000;
|
|
43
|
+
const now = eventTs + 10 * 60; // 10 min later
|
|
44
|
+
const header = await signedHeader(SECRET, RAW_BODY, eventTs);
|
|
45
|
+
const result = await verifyStripeSignature(SECRET, RAW_BODY, header, {
|
|
46
|
+
nowSecs: now,
|
|
47
|
+
});
|
|
48
|
+
expect(result).toBe("REPLAYED");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("verify rejects a tampered body", async () => {
|
|
52
|
+
const now = 1_700_000_000;
|
|
53
|
+
const header = await signedHeader(SECRET, RAW_BODY, now);
|
|
54
|
+
const result = await verifyStripeSignature(
|
|
55
|
+
SECRET,
|
|
56
|
+
`${RAW_BODY}x`, // body changed after signing
|
|
57
|
+
header,
|
|
58
|
+
{ nowSecs: now },
|
|
59
|
+
);
|
|
60
|
+
expect(result).toBe("INVALID_SIGNATURE");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("verify rejects a wrong secret", async () => {
|
|
64
|
+
const now = 1_700_000_000;
|
|
65
|
+
const header = await signedHeader(SECRET, RAW_BODY, now);
|
|
66
|
+
const result = await verifyStripeSignature(
|
|
67
|
+
"whsec_different",
|
|
68
|
+
RAW_BODY,
|
|
69
|
+
header,
|
|
70
|
+
{ nowSecs: now },
|
|
71
|
+
);
|
|
72
|
+
expect(result).toBe("INVALID_SIGNATURE");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("verify reports MISSING_HEADER on null/undefined", async () => {
|
|
76
|
+
expect(await verifyStripeSignature(SECRET, RAW_BODY, null)).toBe(
|
|
77
|
+
"MISSING_HEADER",
|
|
78
|
+
);
|
|
79
|
+
expect(await verifyStripeSignature(SECRET, RAW_BODY, undefined)).toBe(
|
|
80
|
+
"MISSING_HEADER",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("verify accepts either of multiple v1 signatures (secret rotation)", async () => {
|
|
85
|
+
const now = 1_700_000_000;
|
|
86
|
+
const sigOld = await hmacHex("whsec_old", `${now}.${RAW_BODY}`);
|
|
87
|
+
const sigNew = await hmacHex(SECRET, `${now}.${RAW_BODY}`);
|
|
88
|
+
const header = `t=${now},v1=${sigOld},v1=${sigNew}`;
|
|
89
|
+
const result = await verifyStripeSignature(SECRET, RAW_BODY, header, {
|
|
90
|
+
nowSecs: now,
|
|
91
|
+
});
|
|
92
|
+
expect(result).toBe(true);
|
|
93
|
+
});
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe webhook signature verification.
|
|
3
|
+
*
|
|
4
|
+
* Stripe signs every webhook payload with HMAC-SHA256 over
|
|
5
|
+
* `<timestamp>.<rawBody>` and includes the signature + timestamp in
|
|
6
|
+
* the `Stripe-Signature` header (format: `t=<unix>,v1=<sig>,...`).
|
|
7
|
+
*
|
|
8
|
+
* Spec: https://stripe.com/docs/webhooks/signatures
|
|
9
|
+
*
|
|
10
|
+
* Constants:
|
|
11
|
+
* - 5-minute replay window (matches Stripe's CLI tool default).
|
|
12
|
+
* - Constant-time signature compare to defeat timing oracles.
|
|
13
|
+
*
|
|
14
|
+
* Implementation uses the WebCrypto API (Bun + Node 20+) — no
|
|
15
|
+
* third-party crypto dep.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TOLERANCE_SECS = 300;
|
|
19
|
+
|
|
20
|
+
export type SignatureError =
|
|
21
|
+
| "MISSING_HEADER"
|
|
22
|
+
| "MISSING_TIMESTAMP"
|
|
23
|
+
| "MISSING_SIGNATURE"
|
|
24
|
+
| "REPLAYED"
|
|
25
|
+
| "INVALID_SIGNATURE";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns `true` when the signature header matches an HMAC of the
|
|
29
|
+
* raw body under the given secret AND the timestamp is within the
|
|
30
|
+
* replay window. Returns a string error code otherwise.
|
|
31
|
+
*
|
|
32
|
+
* `nowSecs` lets tests inject a fixed clock; production should leave
|
|
33
|
+
* it undefined to use `Date.now()`.
|
|
34
|
+
*/
|
|
35
|
+
export async function verifyStripeSignature(
|
|
36
|
+
secret: string,
|
|
37
|
+
rawBody: string,
|
|
38
|
+
headerValue: string | undefined | null,
|
|
39
|
+
opts: { toleranceSecs?: number; nowSecs?: number } = {},
|
|
40
|
+
): Promise<true | SignatureError> {
|
|
41
|
+
if (!headerValue) return "MISSING_HEADER";
|
|
42
|
+
const tolerance = opts.toleranceSecs ?? DEFAULT_TOLERANCE_SECS;
|
|
43
|
+
const now = opts.nowSecs ?? Math.floor(Date.now() / 1000);
|
|
44
|
+
|
|
45
|
+
// Header shape: `t=<unix>,v1=<sig>[,v1=<sig2>...]`. Multiple v1s
|
|
46
|
+
// appear during signing-secret rotation — accept either.
|
|
47
|
+
let timestamp: number | null = null;
|
|
48
|
+
const signatures: string[] = [];
|
|
49
|
+
for (const part of headerValue.split(",")) {
|
|
50
|
+
const [k, v] = part.split("=");
|
|
51
|
+
if (!k || !v) continue;
|
|
52
|
+
if (k.trim() === "t") timestamp = Number.parseInt(v.trim(), 10);
|
|
53
|
+
else if (k.trim() === "v1") signatures.push(v.trim());
|
|
54
|
+
}
|
|
55
|
+
if (timestamp === null || Number.isNaN(timestamp)) return "MISSING_TIMESTAMP";
|
|
56
|
+
if (signatures.length === 0) return "MISSING_SIGNATURE";
|
|
57
|
+
if (Math.abs(now - timestamp) > tolerance) return "REPLAYED";
|
|
58
|
+
|
|
59
|
+
const expected = await hmacSha256Hex(secret, `${timestamp}.${rawBody}`);
|
|
60
|
+
for (const sig of signatures) {
|
|
61
|
+
if (constantTimeEqualHex(sig, expected)) return true;
|
|
62
|
+
}
|
|
63
|
+
return "INVALID_SIGNATURE";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function hmacSha256Hex(secret: string, payload: string): Promise<string> {
|
|
67
|
+
const enc = new TextEncoder();
|
|
68
|
+
const key = await crypto.subtle.importKey(
|
|
69
|
+
"raw",
|
|
70
|
+
enc.encode(secret),
|
|
71
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
72
|
+
false,
|
|
73
|
+
["sign"],
|
|
74
|
+
);
|
|
75
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload));
|
|
76
|
+
return [...new Uint8Array(sig)]
|
|
77
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
78
|
+
.join("");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function constantTimeEqualHex(a: string, b: string): boolean {
|
|
82
|
+
if (a.length !== b.length) return false;
|
|
83
|
+
let diff = 0;
|
|
84
|
+
for (let i = 0; i < a.length; i++) {
|
|
85
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
86
|
+
}
|
|
87
|
+
return diff === 0;
|
|
88
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public type surface for `@pylonsync/stripe`.
|
|
3
|
+
*
|
|
4
|
+
* Shapes mirror Stripe's REST payloads at the level we actually use —
|
|
5
|
+
* we don't pull in the official `stripe` SDK's types because they
|
|
6
|
+
* change cadence + breadth doesn't match what apps need. If you need
|
|
7
|
+
* a field that's missing, add it here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface StripePlan {
|
|
11
|
+
/**
|
|
12
|
+
* Stable internal name. Stored on Subscription.plan + used as the
|
|
13
|
+
* `lookup_key` we set on Stripe prices via setup-stripe scripts.
|
|
14
|
+
* Lowercase, dash-separated.
|
|
15
|
+
*/
|
|
16
|
+
name: string;
|
|
17
|
+
/**
|
|
18
|
+
* Stripe Price id (price_...) for the monthly tier. Resolved
|
|
19
|
+
* eagerly when the plan list is built (env-var indirection
|
|
20
|
+
* stays at the call site, not threaded through the plugin).
|
|
21
|
+
*/
|
|
22
|
+
priceId: string;
|
|
23
|
+
/** Optional annual variant; surfaced as `annual: true` on upgrade. */
|
|
24
|
+
annualPriceId?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Free-trial config. When set, checkout sessions for this plan
|
|
27
|
+
* default to `subscription_data.trial_period_days`. The plugin
|
|
28
|
+
* enforces one-trial-per-customer at the Stripe API level — Stripe
|
|
29
|
+
* itself rejects a second trial on the same customer when
|
|
30
|
+
* `trial_from_plan: true` is used, but we additionally short-
|
|
31
|
+
* circuit on the cloud side based on prior subscriptions to avoid
|
|
32
|
+
* the round-trip rejection.
|
|
33
|
+
*/
|
|
34
|
+
freeTrial?: {
|
|
35
|
+
days: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Per-plan entitlement limits. Stored on the Subscription row's
|
|
39
|
+
* `limits` JSON column so app code can do quota checks without
|
|
40
|
+
* re-reading the plan list. Values are app-defined (recordings,
|
|
41
|
+
* seats, requests/mo — whatever the app meters).
|
|
42
|
+
*/
|
|
43
|
+
limits?: Record<string, number | boolean | string>;
|
|
44
|
+
/**
|
|
45
|
+
* Seat-based billing. When set, `quantity` flows through to
|
|
46
|
+
* Stripe Subscription + the Subscription row stores `seats`.
|
|
47
|
+
*/
|
|
48
|
+
seatPriceId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reference-type for who owns a subscription. Most apps want `org`
|
|
53
|
+
* (multi-seat workspaces); single-user apps set this to `user`. The
|
|
54
|
+
* plugin doesn't care which — `referenceId` is just an opaque string
|
|
55
|
+
* that gets stamped on Subscription.referenceId and looked up via
|
|
56
|
+
* `getReference()` on webhook delivery.
|
|
57
|
+
*/
|
|
58
|
+
export type ReferenceType = "user" | "org" | "custom";
|
|
59
|
+
|
|
60
|
+
export interface StripeConfig {
|
|
61
|
+
/**
|
|
62
|
+
* Resolved at runtime from `ctx.env.STRIPE_SECRET_KEY` unless an
|
|
63
|
+
* override is provided. Apps using multiple Stripe accounts (dev/
|
|
64
|
+
* prod under one binary) can wire a selector here.
|
|
65
|
+
*/
|
|
66
|
+
getSecretKey?: (ctx: HandlerCtx) => string;
|
|
67
|
+
/**
|
|
68
|
+
* Resolved at runtime from `ctx.env.STRIPE_WEBHOOK_SECRET`.
|
|
69
|
+
* Same override pattern as `getSecretKey`.
|
|
70
|
+
*/
|
|
71
|
+
getWebhookSecret?: (ctx: HandlerCtx) => string;
|
|
72
|
+
/**
|
|
73
|
+
* `user` — Subscription.referenceId = User.id. Customer is auto-
|
|
74
|
+
* created on first checkout, stored as `User.stripeCustomerId`.
|
|
75
|
+
* `org` — Subscription.referenceId = Org.id. Customer is on
|
|
76
|
+
* `Org.stripeCustomerId`. RBAC: only owners/admins can
|
|
77
|
+
* upgrade/cancel.
|
|
78
|
+
* `custom` — caller supplies their own referenceId + customer
|
|
79
|
+
* resolution via `resolveCustomer` hook.
|
|
80
|
+
*/
|
|
81
|
+
referenceType: ReferenceType;
|
|
82
|
+
/**
|
|
83
|
+
* The plan catalog. Order matters: the first plan whose
|
|
84
|
+
* `priceId`/`lookupKey` matches a Stripe subscription's items
|
|
85
|
+
* wins on plan-derivation from webhook payloads.
|
|
86
|
+
*/
|
|
87
|
+
plans: StripePlan[];
|
|
88
|
+
/**
|
|
89
|
+
* Which Stripe API version the client pins. Match this against
|
|
90
|
+
* what your dashboard webhook subscription is set to receive,
|
|
91
|
+
* otherwise field shapes drift.
|
|
92
|
+
*/
|
|
93
|
+
apiVersion?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Override the entity names the plugin reads/writes. Defaults
|
|
96
|
+
* mirror the manifest entities exported from this package.
|
|
97
|
+
*/
|
|
98
|
+
entities?: {
|
|
99
|
+
subscription?: string;
|
|
100
|
+
/** Where stripeCustomerId lives. Required. */
|
|
101
|
+
customerHolder?: string;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Lifecycle hooks. Called server-side after the plugin has
|
|
105
|
+
* persisted its own state — apps add analytics, welcome emails,
|
|
106
|
+
* cache invalidation here.
|
|
107
|
+
*/
|
|
108
|
+
hooks?: StripeHooks;
|
|
109
|
+
/**
|
|
110
|
+
* RBAC gate for subscription mutation actions. Returns `true` to
|
|
111
|
+
* allow, throws / returns `false` to deny. Defaults: for `org`
|
|
112
|
+
* referenceType, allows owners + admins; for `user`, allows the
|
|
113
|
+
* caller iff their auth.userId === referenceId.
|
|
114
|
+
*/
|
|
115
|
+
authorizeReference?: (
|
|
116
|
+
ctx: HandlerCtx,
|
|
117
|
+
args: { referenceId: string; action: SubscriptionAction },
|
|
118
|
+
) => Promise<boolean> | boolean;
|
|
119
|
+
/**
|
|
120
|
+
* `custom` referenceType requires this hook to map referenceId
|
|
121
|
+
* to a Stripe customer id (and create one if needed).
|
|
122
|
+
*/
|
|
123
|
+
resolveCustomer?: (
|
|
124
|
+
ctx: HandlerCtx,
|
|
125
|
+
referenceId: string,
|
|
126
|
+
) => Promise<{ customerId: string; email?: string }>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type SubscriptionAction =
|
|
130
|
+
| "upgrade"
|
|
131
|
+
| "cancel"
|
|
132
|
+
| "restore"
|
|
133
|
+
| "billingPortal"
|
|
134
|
+
| "list";
|
|
135
|
+
|
|
136
|
+
export interface StripeHooks {
|
|
137
|
+
/** Fires after a Stripe customer is created. */
|
|
138
|
+
onCustomerCreate?: (
|
|
139
|
+
ctx: HandlerCtx,
|
|
140
|
+
args: { referenceId: string; customerId: string; email?: string },
|
|
141
|
+
) => Promise<void> | void;
|
|
142
|
+
/**
|
|
143
|
+
* Fires after `customer.subscription.created` is persisted.
|
|
144
|
+
* Includes the resolved plan name + the StripeSubscription row.
|
|
145
|
+
*/
|
|
146
|
+
onSubscriptionActivate?: (
|
|
147
|
+
ctx: HandlerCtx,
|
|
148
|
+
args: { referenceId: string; plan: string; subscription: StripeSubscription },
|
|
149
|
+
) => Promise<void> | void;
|
|
150
|
+
/** Fires after `customer.subscription.updated` (plan changes, renewals). */
|
|
151
|
+
onSubscriptionUpdate?: (
|
|
152
|
+
ctx: HandlerCtx,
|
|
153
|
+
args: { referenceId: string; plan: string; subscription: StripeSubscription },
|
|
154
|
+
) => Promise<void> | void;
|
|
155
|
+
/** Fires after `customer.subscription.deleted`. */
|
|
156
|
+
onSubscriptionCancel?: (
|
|
157
|
+
ctx: HandlerCtx,
|
|
158
|
+
args: { referenceId: string; subscription: StripeSubscription },
|
|
159
|
+
) => Promise<void> | void;
|
|
160
|
+
/** Fires after every invoice event the plugin processes. */
|
|
161
|
+
onInvoice?: (
|
|
162
|
+
ctx: HandlerCtx,
|
|
163
|
+
args: { referenceId: string; eventType: string; invoice: StripeInvoice },
|
|
164
|
+
) => Promise<void> | void;
|
|
165
|
+
/**
|
|
166
|
+
* Catch-all for any Stripe webhook event the plugin doesn't have
|
|
167
|
+
* built-in handling for. Useful for `payment_method.attached`,
|
|
168
|
+
* `charge.dispute.created`, etc.
|
|
169
|
+
*/
|
|
170
|
+
onEvent?: (ctx: HandlerCtx, event: StripeEvent) => Promise<void> | void;
|
|
171
|
+
/**
|
|
172
|
+
* Inject extra params into the Stripe Checkout Session create
|
|
173
|
+
* call (tax, promo codes, idempotency key, custom_fields, etc.).
|
|
174
|
+
* Plugin merges over the base shape, so you can override defaults
|
|
175
|
+
* if you really want to.
|
|
176
|
+
*/
|
|
177
|
+
getCheckoutSessionParams?: (
|
|
178
|
+
ctx: HandlerCtx,
|
|
179
|
+
args: {
|
|
180
|
+
referenceId: string;
|
|
181
|
+
plan: StripePlan;
|
|
182
|
+
customerId: string;
|
|
183
|
+
successUrl: string;
|
|
184
|
+
cancelUrl: string;
|
|
185
|
+
},
|
|
186
|
+
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface StripeEvent {
|
|
190
|
+
id: string;
|
|
191
|
+
type: string;
|
|
192
|
+
data: { object: Record<string, unknown> };
|
|
193
|
+
created: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface StripeSubscription {
|
|
197
|
+
id: string;
|
|
198
|
+
status:
|
|
199
|
+
| "active"
|
|
200
|
+
| "past_due"
|
|
201
|
+
| "canceled"
|
|
202
|
+
| "incomplete"
|
|
203
|
+
| "incomplete_expired"
|
|
204
|
+
| "trialing"
|
|
205
|
+
| "unpaid"
|
|
206
|
+
| "paused";
|
|
207
|
+
current_period_end?: number | null;
|
|
208
|
+
cancel_at_period_end?: boolean;
|
|
209
|
+
cancel_at?: number | null;
|
|
210
|
+
canceled_at?: number | null;
|
|
211
|
+
customer: string;
|
|
212
|
+
trial_start?: number | null;
|
|
213
|
+
trial_end?: number | null;
|
|
214
|
+
items: {
|
|
215
|
+
data: Array<{
|
|
216
|
+
id?: string;
|
|
217
|
+
current_period_end?: number | null;
|
|
218
|
+
quantity?: number;
|
|
219
|
+
price: {
|
|
220
|
+
id: string;
|
|
221
|
+
nickname?: string | null;
|
|
222
|
+
lookup_key?: string | null;
|
|
223
|
+
};
|
|
224
|
+
}>;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface StripeInvoice {
|
|
229
|
+
id: string;
|
|
230
|
+
status: string;
|
|
231
|
+
amount_paid?: number;
|
|
232
|
+
amount_due: number;
|
|
233
|
+
period_start: number;
|
|
234
|
+
period_end: number;
|
|
235
|
+
customer: string;
|
|
236
|
+
subscription?: string;
|
|
237
|
+
hosted_invoice_url?: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Handler-side `ctx` shape, narrowed to just the bits the plugin
|
|
242
|
+
* touches. The real ctx from `@pylonsync/functions` is wider; we
|
|
243
|
+
* type-guard the subset we depend on so the package compiles
|
|
244
|
+
* without a tight dep on the functions runtime types.
|
|
245
|
+
*/
|
|
246
|
+
export interface HandlerCtx {
|
|
247
|
+
env: Record<string, string | undefined>;
|
|
248
|
+
auth: { userId?: string | null; tenantId?: string | null; isAdmin?: boolean };
|
|
249
|
+
request?: {
|
|
250
|
+
headers: Record<string, string | undefined>;
|
|
251
|
+
rawBody: string;
|
|
252
|
+
};
|
|
253
|
+
runQuery: <T>(name: string, args: Record<string, unknown>) => Promise<T>;
|
|
254
|
+
runMutation: <T = unknown>(
|
|
255
|
+
name: string,
|
|
256
|
+
args: Record<string, unknown>,
|
|
257
|
+
) => Promise<T>;
|
|
258
|
+
error: (code: string, message: string) => Error;
|
|
259
|
+
}
|