@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 ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@pylonsync/stripe",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.3.83",
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.3.81",
16
+ "@pylonsync/functions": "0.3.81"
17
+ },
18
+ "peerDependencies": {
19
+ "bun-types": "*"
20
+ },
21
+ "peerDependenciesMeta": {
22
+ "bun-types": {
23
+ "optional": true
24
+ }
25
+ }
26
+ }
package/src/client.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Stripe REST client over `fetch`. Stripe's API is form-urlencoded
3
+ * with bracket-style nesting (`foo[bar]=1`); JSON is rejected. We
4
+ * skip the official `stripe` SDK because it ships a 90 MB dep tree
5
+ * for what amounts to one fetch + one body encoder.
6
+ */
7
+
8
+ const STRIPE_API_BASE = "https://api.stripe.com/v1";
9
+ const DEFAULT_API_VERSION = "2024-12-18.acacia";
10
+
11
+ export class StripeError extends Error {
12
+ constructor(
13
+ public status: number,
14
+ public code: string,
15
+ public stripeType: string,
16
+ message: string,
17
+ ) {
18
+ super(message);
19
+ this.name = "StripeError";
20
+ }
21
+ }
22
+
23
+ export interface StripeClientConfig {
24
+ secretKey: string;
25
+ apiVersion?: string;
26
+ baseUrl?: string;
27
+ /** Optional idempotency key for non-GET requests. */
28
+ idempotencyKey?: string;
29
+ }
30
+
31
+ /**
32
+ * Encode a nested object as Stripe's form-urlencoded bracket convention:
33
+ * { foo: { bar: 1 } } → foo[bar]=1
34
+ * { items: [{ price: "x" }] } → items[0][price]=x
35
+ *
36
+ * Stripe SDKs all do this internally; we surface it because the
37
+ * webhook + checkout helpers in this package use it directly.
38
+ */
39
+ export function encodeFormBody(input: Record<string, unknown>): string {
40
+ const params = new URLSearchParams();
41
+ const walk = (prefix: string, value: unknown): void => {
42
+ if (value === undefined || value === null) return;
43
+ if (Array.isArray(value)) {
44
+ value.forEach((v, i) => walk(`${prefix}[${i}]`, v));
45
+ return;
46
+ }
47
+ if (typeof value === "object") {
48
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
49
+ walk(`${prefix}[${k}]`, v);
50
+ }
51
+ return;
52
+ }
53
+ params.append(prefix, String(value));
54
+ };
55
+ for (const [k, v] of Object.entries(input)) walk(k, v);
56
+ return params.toString();
57
+ }
58
+
59
+ export async function stripeRequest<T>(
60
+ cfg: StripeClientConfig,
61
+ method: "GET" | "POST" | "DELETE",
62
+ path: string,
63
+ body?: Record<string, unknown>,
64
+ ): Promise<T> {
65
+ const baseUrl = cfg.baseUrl ?? STRIPE_API_BASE;
66
+ const url = `${baseUrl}${path}`;
67
+ const headers: Record<string, string> = {
68
+ Authorization: `Bearer ${cfg.secretKey}`,
69
+ "Stripe-Version": cfg.apiVersion ?? DEFAULT_API_VERSION,
70
+ };
71
+ if (cfg.idempotencyKey) {
72
+ headers["Idempotency-Key"] = cfg.idempotencyKey;
73
+ }
74
+ let init: RequestInit = { method, headers };
75
+ if (method !== "GET" && body) {
76
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
77
+ init = { ...init, body: encodeFormBody(body) };
78
+ }
79
+ const res = await fetch(url, init);
80
+ const text = await res.text();
81
+ const parsed: unknown = text ? safeJson(text) : null;
82
+ if (!res.ok) {
83
+ const errObj = (
84
+ parsed as { error?: { code?: string; type?: string; message?: string } }
85
+ )?.error;
86
+ const code = errObj?.code ?? `HTTP_${res.status}`;
87
+ const type = errObj?.type ?? "api_error";
88
+ const msg = errObj?.message ?? (text || res.statusText);
89
+ throw new StripeError(res.status, code, type, msg);
90
+ }
91
+ return parsed as T;
92
+ }
93
+
94
+ function safeJson(text: string): unknown {
95
+ try {
96
+ return JSON.parse(text);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
@@ -0,0 +1,79 @@
1
+ import { action, v } from "@pylonsync/functions";
2
+
3
+ import { stripeRequest } from "../client";
4
+ import type { HandlerCtx, StripeConfig } from "../types";
5
+ import { authorizeReference, resolveSecretKey } from "./internal";
6
+
7
+ /**
8
+ * Factory: returns an action that cancels the active subscription
9
+ * for a reference. Two modes:
10
+ *
11
+ * - `scheduleAtPeriodEnd: true` (default) — sets
12
+ * `cancel_at_period_end` so the customer keeps access through
13
+ * the paid window. The webhook handler updates the local row
14
+ * to `cancelAtPeriodEnd = true`. `restoreSubscription` can undo
15
+ * this any time before the period ends.
16
+ *
17
+ * - `scheduleAtPeriodEnd: false` — immediate `DELETE`. Refunds
18
+ * are NOT issued automatically; that's a Stripe dashboard
19
+ * toggle. The webhook flips the row's status to `canceled`.
20
+ */
21
+ export function cancelSubscriptionHandler(cfg: StripeConfig) {
22
+ return action({
23
+ args: {
24
+ referenceId: v.optional(v.string()),
25
+ scheduleAtPeriodEnd: v.optional(v.boolean()),
26
+ },
27
+ async handler(
28
+ ctx: HandlerCtx,
29
+ args: { referenceId?: string; scheduleAtPeriodEnd?: boolean },
30
+ ) {
31
+ const referenceId = args.referenceId ?? defaultReferenceId(ctx, cfg);
32
+ if (!referenceId) {
33
+ throw ctx.error(
34
+ "NO_REFERENCE",
35
+ "referenceId required (no active tenant or user)",
36
+ );
37
+ }
38
+ await authorizeReference(ctx, cfg, referenceId, "cancel");
39
+
40
+ const sub = await ctx.runQuery<{
41
+ stripeSubscriptionId: string;
42
+ } | null>("_pylonStripeFindActiveSubForReference", { referenceId });
43
+ if (!sub) {
44
+ throw ctx.error("NOT_FOUND", "no active subscription for reference");
45
+ }
46
+
47
+ const scheduleAtPeriodEnd = args.scheduleAtPeriodEnd ?? true;
48
+ const secretKey = resolveSecretKey(ctx, cfg);
49
+ if (scheduleAtPeriodEnd) {
50
+ await stripeRequest(
51
+ { secretKey, apiVersion: cfg.apiVersion },
52
+ "POST",
53
+ `/subscriptions/${sub.stripeSubscriptionId}`,
54
+ { cancel_at_period_end: true },
55
+ );
56
+ } else {
57
+ await stripeRequest(
58
+ { secretKey, apiVersion: cfg.apiVersion },
59
+ "DELETE",
60
+ `/subscriptions/${sub.stripeSubscriptionId}`,
61
+ );
62
+ }
63
+ // Local row update lands via the webhook; don't double-write
64
+ // here because Stripe's response carries the canonical
65
+ // status + period_end (and webhooks retry, so we want one
66
+ // path that's idempotent).
67
+ return { scheduled: scheduleAtPeriodEnd };
68
+ },
69
+ });
70
+ }
71
+
72
+ function defaultReferenceId(
73
+ ctx: HandlerCtx,
74
+ cfg: StripeConfig,
75
+ ): string | null {
76
+ if (cfg.referenceType === "user") return ctx.auth.userId ?? null;
77
+ if (cfg.referenceType === "org") return ctx.auth.tenantId ?? null;
78
+ return null;
79
+ }
@@ -0,0 +1,154 @@
1
+ import { action, v } from "@pylonsync/functions";
2
+
3
+ import { stripeRequest } from "../client";
4
+ import { findPlanByName } from "../plans";
5
+ import { assertSafeRedirectUrl } from "../safe-url";
6
+ import type { HandlerCtx, StripeConfig } from "../types";
7
+ import {
8
+ authorizeReference,
9
+ resolveCustomerForReference,
10
+ subscriptionEntity,
11
+ } from "./internal";
12
+
13
+ /**
14
+ * Factory: returns an action handler for creating a Checkout Session.
15
+ *
16
+ * Flow:
17
+ * 1. authorize the referenceId for `upgrade`
18
+ * 2. resolve the Stripe customer (create on first call, persist on
19
+ * the customer-holder entity)
20
+ * 3. validate the success/cancel URLs against PYLON_PUBLIC_URL +
21
+ * app-provided extras (closes the "yapless.com vs getyapless.com"
22
+ * bug class)
23
+ * 4. apply free-trial config from the plan catalog
24
+ * 5. POST /v1/checkout/sessions with merged hook params
25
+ * 6. return the URL — caller redirects the user
26
+ */
27
+ export function createCheckoutSessionHandler(cfg: StripeConfig) {
28
+ return action({
29
+ args: {
30
+ plan: v.string(),
31
+ referenceId: v.optional(v.string()),
32
+ successUrl: v.string(),
33
+ cancelUrl: v.string(),
34
+ annual: v.optional(v.boolean()),
35
+ seats: v.optional(v.number()),
36
+ },
37
+ async handler(
38
+ ctx: HandlerCtx,
39
+ args: {
40
+ plan: string;
41
+ referenceId?: string;
42
+ successUrl: string;
43
+ cancelUrl: string;
44
+ annual?: boolean;
45
+ seats?: number;
46
+ },
47
+ ) {
48
+ const referenceId = args.referenceId ?? defaultReferenceId(ctx, cfg);
49
+ if (!referenceId) {
50
+ throw ctx.error(
51
+ "NO_REFERENCE",
52
+ "referenceId required (no active tenant or user)",
53
+ );
54
+ }
55
+ await authorizeReference(ctx, cfg, referenceId, "upgrade");
56
+
57
+ const plan = findPlanByName(cfg.plans, args.plan);
58
+ if (!plan) {
59
+ throw ctx.error("UNKNOWN_PLAN", `plan ${args.plan} not in catalog`);
60
+ }
61
+ const priceId = args.annual ? plan.annualPriceId : plan.priceId;
62
+ if (!priceId) {
63
+ throw ctx.error(
64
+ "NO_PRICE",
65
+ `plan ${args.plan} has no ${args.annual ? "annual" : "monthly"} priceId`,
66
+ );
67
+ }
68
+
69
+ assertSafeRedirectUrl(args.successUrl, ctx.env.PYLON_PUBLIC_URL, {
70
+ extraOrigins: ctx.env.PYLON_CORS_ORIGIN,
71
+ });
72
+ assertSafeRedirectUrl(args.cancelUrl, ctx.env.PYLON_PUBLIC_URL, {
73
+ extraOrigins: ctx.env.PYLON_CORS_ORIGIN,
74
+ });
75
+
76
+ const customer = await resolveCustomerForReference(ctx, cfg, referenceId);
77
+
78
+ const secretKey = resolveSecretKey(ctx, cfg);
79
+
80
+ // Block double-trials: if the customer already has any
81
+ // Subscription row, we set `trial_period_days` to 0
82
+ // regardless of plan config. Stripe also enforces this
83
+ // server-side via the customer's trial history, but
84
+ // short-circuiting here avoids the round-trip rejection
85
+ // AND prevents giving customers a UX "you just got a 14-day
86
+ // trial" message that Stripe later silently strips.
87
+ const priorSubs = await ctx.runQuery<Array<{ id: string }>>(
88
+ "_pylonStripeListSubsForReference",
89
+ { referenceId },
90
+ );
91
+ const trialDays =
92
+ priorSubs.length === 0 && plan.freeTrial?.days
93
+ ? plan.freeTrial.days
94
+ : 0;
95
+
96
+ const baseParams: Record<string, unknown> = {
97
+ mode: "subscription",
98
+ customer: customer.customerId,
99
+ success_url: args.successUrl,
100
+ cancel_url: args.cancelUrl,
101
+ line_items: [
102
+ {
103
+ price: priceId,
104
+ quantity: args.seats ?? 1,
105
+ },
106
+ ],
107
+ allow_promotion_codes: true,
108
+ client_reference_id: referenceId,
109
+ metadata: { referenceId, plan: plan.name },
110
+ subscription_data: {
111
+ metadata: { referenceId, plan: plan.name },
112
+ ...(trialDays > 0 ? { trial_period_days: trialDays } : {}),
113
+ },
114
+ };
115
+
116
+ const extra = cfg.hooks?.getCheckoutSessionParams
117
+ ? await cfg.hooks.getCheckoutSessionParams(ctx, {
118
+ referenceId,
119
+ plan,
120
+ customerId: customer.customerId,
121
+ successUrl: args.successUrl,
122
+ cancelUrl: args.cancelUrl,
123
+ })
124
+ : {};
125
+ const merged = { ...baseParams, ...extra };
126
+
127
+ const session = await stripeRequest<{ id: string; url: string }>(
128
+ { secretKey, apiVersion: cfg.apiVersion },
129
+ "POST",
130
+ "/checkout/sessions",
131
+ merged,
132
+ );
133
+ return { url: session.url, id: session.id };
134
+ },
135
+ });
136
+ }
137
+
138
+ function defaultReferenceId(
139
+ ctx: HandlerCtx,
140
+ cfg: StripeConfig,
141
+ ): string | null {
142
+ if (cfg.referenceType === "user") return ctx.auth.userId ?? null;
143
+ if (cfg.referenceType === "org") return ctx.auth.tenantId ?? null;
144
+ return null;
145
+ }
146
+
147
+ function resolveSecretKey(ctx: HandlerCtx, cfg: StripeConfig): string {
148
+ if (cfg.getSecretKey) return cfg.getSecretKey(ctx);
149
+ const v = ctx.env.STRIPE_SECRET_KEY;
150
+ if (!v) {
151
+ throw ctx.error("MISSING_ENV", "STRIPE_SECRET_KEY not set");
152
+ }
153
+ return v;
154
+ }
@@ -0,0 +1,6 @@
1
+ export { createCheckoutSessionHandler } from "./checkout";
2
+ export { createBillingPortalSessionHandler } from "./portal";
3
+ export { cancelSubscriptionHandler } from "./cancel";
4
+ export { restoreSubscriptionHandler } from "./restore";
5
+ export { stripeWebhookHandler } from "./webhook";
6
+ export { internalHandlers } from "./queries";
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shared internals used by every handler in the package.
3
+ *
4
+ * Why this file exists: the plugin needs three primitives that
5
+ * cross-cut every action — authorize a reference, look up / create
6
+ * a Stripe customer for a reference, and resolve which mutation
7
+ * the app declared for persisting customer-id / subscription rows.
8
+ * Keeping them here means the per-handler files (checkout, portal,
9
+ * cancel, etc.) stay focused on the Stripe-side flow.
10
+ */
11
+
12
+ import { stripeRequest } from "../client";
13
+ import type { HandlerCtx, StripeConfig, SubscriptionAction } from "../types";
14
+
15
+ export function subscriptionEntity(cfg: StripeConfig): string {
16
+ return cfg.entities?.subscription ?? "StripeSubscription";
17
+ }
18
+
19
+ export function customerHolderEntity(cfg: StripeConfig): string {
20
+ if (cfg.entities?.customerHolder) return cfg.entities.customerHolder;
21
+ if (cfg.referenceType === "org") return "Org";
22
+ if (cfg.referenceType === "user") return "User";
23
+ throw new Error(
24
+ `@pylonsync/stripe: customerHolder entity required for referenceType=custom`,
25
+ );
26
+ }
27
+
28
+ /**
29
+ * RBAC gate. Defaults:
30
+ * - `org`: owners + admins allowed (looked up via OrgMember)
31
+ * - `user`: only the caller themselves
32
+ * - `custom`: caller must supply `cfg.authorizeReference`
33
+ * Always throws on deny — callers don't need to handle false return.
34
+ */
35
+ export async function authorizeReference(
36
+ ctx: HandlerCtx,
37
+ cfg: StripeConfig,
38
+ referenceId: string,
39
+ action: SubscriptionAction,
40
+ ): Promise<void> {
41
+ if (!ctx.auth.userId) {
42
+ throw ctx.error("UNAUTHENTICATED", "sign in to manage subscriptions");
43
+ }
44
+ if (cfg.authorizeReference) {
45
+ const ok = await cfg.authorizeReference(ctx, { referenceId, action });
46
+ if (!ok) throw ctx.error("FORBIDDEN", `not authorized to ${action}`);
47
+ return;
48
+ }
49
+ if (ctx.auth.isAdmin) return;
50
+ if (cfg.referenceType === "user") {
51
+ if (ctx.auth.userId !== referenceId) {
52
+ throw ctx.error("FORBIDDEN", "can only manage your own subscription");
53
+ }
54
+ return;
55
+ }
56
+ if (cfg.referenceType === "org") {
57
+ const members = await ctx.runQuery<Array<{ role: string }>>(
58
+ "_pylonStripeOrgMembership",
59
+ { orgId: referenceId, userId: ctx.auth.userId },
60
+ );
61
+ const role = members[0]?.role;
62
+ if (role !== "owner" && role !== "admin") {
63
+ throw ctx.error(
64
+ "FORBIDDEN",
65
+ `only org owners or admins can ${action}`,
66
+ );
67
+ }
68
+ return;
69
+ }
70
+ throw ctx.error(
71
+ "NO_AUTHORIZER",
72
+ "referenceType=custom requires authorizeReference hook",
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Find an existing Stripe customer for the reference, or create one
78
+ * and persist its id on the customer-holder entity. Idempotent.
79
+ */
80
+ export async function resolveCustomerForReference(
81
+ ctx: HandlerCtx,
82
+ cfg: StripeConfig,
83
+ referenceId: string,
84
+ ): Promise<{ customerId: string; email?: string }> {
85
+ if (cfg.referenceType === "custom") {
86
+ if (!cfg.resolveCustomer) {
87
+ throw ctx.error(
88
+ "NO_RESOLVER",
89
+ "referenceType=custom requires resolveCustomer hook",
90
+ );
91
+ }
92
+ return cfg.resolveCustomer(ctx, referenceId);
93
+ }
94
+
95
+ const holder = customerHolderEntity(cfg);
96
+ const row = await ctx.runQuery<{
97
+ id: string;
98
+ stripeCustomerId?: string | null;
99
+ email?: string;
100
+ name?: string;
101
+ } | null>("_pylonStripeGetCustomerHolder", { entity: holder, id: referenceId });
102
+ if (!row) {
103
+ throw ctx.error("NOT_FOUND", `${holder} ${referenceId} not found`);
104
+ }
105
+ if (row.stripeCustomerId) {
106
+ return { customerId: row.stripeCustomerId, email: row.email };
107
+ }
108
+
109
+ const secretKey = resolveSecretKey(ctx, cfg);
110
+ const created = await stripeRequest<{ id: string }>(
111
+ { secretKey, apiVersion: cfg.apiVersion },
112
+ "POST",
113
+ "/customers",
114
+ {
115
+ email: row.email,
116
+ name: row.name,
117
+ metadata: { referenceId, referenceType: cfg.referenceType },
118
+ },
119
+ );
120
+ await ctx.runMutation("_pylonStripeSetCustomerId", {
121
+ entity: holder,
122
+ id: referenceId,
123
+ stripeCustomerId: created.id,
124
+ });
125
+ if (cfg.hooks?.onCustomerCreate) {
126
+ await cfg.hooks.onCustomerCreate(ctx, {
127
+ referenceId,
128
+ customerId: created.id,
129
+ email: row.email,
130
+ });
131
+ }
132
+ return { customerId: created.id, email: row.email };
133
+ }
134
+
135
+ export function resolveSecretKey(ctx: HandlerCtx, cfg: StripeConfig): string {
136
+ if (cfg.getSecretKey) return cfg.getSecretKey(ctx);
137
+ const v = ctx.env.STRIPE_SECRET_KEY;
138
+ if (!v) {
139
+ throw ctx.error("MISSING_ENV", "STRIPE_SECRET_KEY not set");
140
+ }
141
+ return v;
142
+ }
143
+
144
+ export function resolveWebhookSecret(
145
+ ctx: HandlerCtx,
146
+ cfg: StripeConfig,
147
+ ): string {
148
+ if (cfg.getWebhookSecret) return cfg.getWebhookSecret(ctx);
149
+ const v = ctx.env.STRIPE_WEBHOOK_SECRET;
150
+ if (!v) {
151
+ throw ctx.error("MISSING_ENV", "STRIPE_WEBHOOK_SECRET not set");
152
+ }
153
+ return v;
154
+ }
@@ -0,0 +1,69 @@
1
+ import { action, v } from "@pylonsync/functions";
2
+
3
+ import { stripeRequest } from "../client";
4
+ import { assertSafeRedirectUrl } from "../safe-url";
5
+ import type { HandlerCtx, StripeConfig } from "../types";
6
+ import {
7
+ authorizeReference,
8
+ resolveCustomerForReference,
9
+ resolveSecretKey,
10
+ } from "./internal";
11
+
12
+ /**
13
+ * Factory: returns an action handler that opens a Stripe Billing
14
+ * Portal session for the reference. Customer manages their card,
15
+ * downloads invoices, cancels the subscription — all without the
16
+ * app having to build a billing UI.
17
+ *
18
+ * Portal config (return URL allow-list, products, branding) is set
19
+ * once in the Stripe dashboard; this handler just mints sessions.
20
+ */
21
+ export function createBillingPortalSessionHandler(cfg: StripeConfig) {
22
+ return action({
23
+ args: {
24
+ referenceId: v.optional(v.string()),
25
+ returnUrl: v.string(),
26
+ },
27
+ async handler(
28
+ ctx: HandlerCtx,
29
+ args: { referenceId?: string; returnUrl: string },
30
+ ) {
31
+ const referenceId = args.referenceId ?? defaultReferenceId(ctx, cfg);
32
+ if (!referenceId) {
33
+ throw ctx.error(
34
+ "NO_REFERENCE",
35
+ "referenceId required (no active tenant or user)",
36
+ );
37
+ }
38
+ await authorizeReference(ctx, cfg, referenceId, "billingPortal");
39
+
40
+ assertSafeRedirectUrl(args.returnUrl, ctx.env.PYLON_PUBLIC_URL, {
41
+ extraOrigins: ctx.env.PYLON_CORS_ORIGIN,
42
+ });
43
+
44
+ const { customerId } = await resolveCustomerForReference(
45
+ ctx,
46
+ cfg,
47
+ referenceId,
48
+ );
49
+
50
+ const secretKey = resolveSecretKey(ctx, cfg);
51
+ const session = await stripeRequest<{ id: string; url: string }>(
52
+ { secretKey, apiVersion: cfg.apiVersion },
53
+ "POST",
54
+ "/billing_portal/sessions",
55
+ { customer: customerId, return_url: args.returnUrl },
56
+ );
57
+ return { url: session.url, id: session.id };
58
+ },
59
+ });
60
+ }
61
+
62
+ function defaultReferenceId(
63
+ ctx: HandlerCtx,
64
+ cfg: StripeConfig,
65
+ ): string | null {
66
+ if (cfg.referenceType === "user") return ctx.auth.userId ?? null;
67
+ if (cfg.referenceType === "org") return ctx.auth.tenantId ?? null;
68
+ return null;
69
+ }