@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/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
|
+
}
|