@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/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
+ });
@@ -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
+ });
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["bun-types"]
5
+ },
6
+ "include": [
7
+ "src"
8
+ ]
9
+ }