@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.
@@ -0,0 +1,194 @@
1
+ import { mutation, query, v } from "@pylonsync/functions";
2
+
3
+ import type { StripeConfig } from "../types";
4
+ import { subscriptionEntity } from "./internal";
5
+
6
+ /**
7
+ * Plugin-internal queries + mutations. Names are namespaced with a
8
+ * `_pylonStripe` prefix to avoid colliding with app functions. The
9
+ * underscore + `internal: true` makes them ineligible for direct
10
+ * HTTP invocation — they only fire via `ctx.runQuery` /
11
+ * `ctx.runMutation` from the action handler factories.
12
+ *
13
+ * Each one is parameterized over the entity names from `cfg.entities`
14
+ * so apps can rename `StripeSubscription` → `Subscription` or
15
+ * `Org` → `Workspace` without forking the plugin. Entity names that
16
+ * the framework needs as relation targets (`v.id("Org")`) can't be
17
+ * dynamic, so the lookup helpers take the entity name as an arg and
18
+ * use the generic `ctx.db.query(entity, filter)` API.
19
+ */
20
+
21
+ export function internalHandlers(cfg: StripeConfig): Record<string, unknown> {
22
+ const subEnt = subscriptionEntity(cfg);
23
+
24
+ return {
25
+ _pylonStripeListSubsForReference: query({
26
+ args: { referenceId: v.string() },
27
+ internal: true,
28
+ async handler(ctx, args: { referenceId: string }) {
29
+ return ctx.db.query(subEnt, { referenceId: args.referenceId });
30
+ },
31
+ }),
32
+
33
+ // First active subscription for a reference id (used by cancel
34
+ // + restore). "Active" here means status ∈
35
+ // (active, trialing, past_due) — those are the states a
36
+ // customer can still mutate from. Canceled / unpaid require
37
+ // a new checkout.
38
+ _pylonStripeFindActiveSubForReference: query({
39
+ args: { referenceId: v.string() },
40
+ internal: true,
41
+ async handler(ctx, args: { referenceId: string }) {
42
+ const rows = (await ctx.db.query(subEnt, {
43
+ referenceId: args.referenceId,
44
+ })) as Array<{
45
+ id: string;
46
+ stripeSubscriptionId: string;
47
+ status: string;
48
+ cancelAtPeriodEnd?: boolean;
49
+ }>;
50
+ return (
51
+ rows.find((r) =>
52
+ ["active", "trialing", "past_due"].includes(r.status),
53
+ ) ?? null
54
+ );
55
+ },
56
+ }),
57
+
58
+ // Read the customer-holder row. Used by resolveCustomerForReference
59
+ // to look up the existing stripeCustomerId + carry email/name
60
+ // through to customer creation.
61
+ _pylonStripeGetCustomerHolder: query({
62
+ args: { entity: v.string(), id: v.string() },
63
+ internal: true,
64
+ async handler(ctx, args: { entity: string; id: string }) {
65
+ return ctx.db.get(args.entity, args.id);
66
+ },
67
+ }),
68
+
69
+ // Reverse lookup: customer-id → reference row. Used by the
70
+ // webhook handler to map Stripe events back to our reference.
71
+ _pylonStripeFindByCustomerId: query({
72
+ args: {
73
+ entity: v.string(),
74
+ stripeCustomerId: v.string(),
75
+ },
76
+ internal: true,
77
+ async handler(
78
+ ctx,
79
+ args: { entity: string; stripeCustomerId: string },
80
+ ) {
81
+ const rows = (await ctx.db.query(args.entity, {
82
+ stripeCustomerId: args.stripeCustomerId,
83
+ })) as Array<{ id: string }>;
84
+ return rows[0] ?? null;
85
+ },
86
+ }),
87
+
88
+ // Org membership check for the default `authorizeReference`.
89
+ // Apps with non-default OrgMember entity names pass their own
90
+ // authorizer instead.
91
+ _pylonStripeOrgMembership: query({
92
+ args: { orgId: v.string(), userId: v.string() },
93
+ internal: true,
94
+ async handler(ctx, args: { orgId: string; userId: string }) {
95
+ return ctx.db.query("OrgMember", {
96
+ orgId: args.orgId,
97
+ userId: args.userId,
98
+ });
99
+ },
100
+ }),
101
+
102
+ // Persist a freshly-minted Stripe customer id on the holder row.
103
+ _pylonStripeSetCustomerId: mutation({
104
+ args: {
105
+ entity: v.string(),
106
+ id: v.string(),
107
+ stripeCustomerId: v.string(),
108
+ },
109
+ internal: true,
110
+ async handler(
111
+ ctx,
112
+ args: { entity: string; id: string; stripeCustomerId: string },
113
+ ) {
114
+ await ctx.db.update(args.entity, args.id, {
115
+ stripeCustomerId: args.stripeCustomerId,
116
+ });
117
+ },
118
+ }),
119
+
120
+ // Upsert the subscription row from a webhook event. Looks up
121
+ // by stripeSubscriptionId (unique) and updates in place when
122
+ // it exists; otherwise inserts. Idempotent — Stripe retries
123
+ // webhooks and we want the same end state on repeat delivery.
124
+ _pylonStripeUpsertSubscription: mutation({
125
+ args: {
126
+ referenceId: v.string(),
127
+ stripeCustomerId: v.string(),
128
+ stripeSubscriptionId: v.string(),
129
+ plan: v.string(),
130
+ status: v.string(),
131
+ seats: v.optional(v.number()),
132
+ currentPeriodEnd: v.optional(v.string()),
133
+ cancelAtPeriodEnd: v.optional(v.boolean()),
134
+ canceledAt: v.optional(v.string()),
135
+ trialEnd: v.optional(v.string()),
136
+ limits: v.optional(v.string()),
137
+ createdAt: v.string(),
138
+ updatedAt: v.string(),
139
+ },
140
+ internal: true,
141
+ async handler(
142
+ ctx,
143
+ args: {
144
+ referenceId: string;
145
+ stripeCustomerId: string;
146
+ stripeSubscriptionId: string;
147
+ plan: string;
148
+ status: string;
149
+ seats?: number;
150
+ currentPeriodEnd?: string | null;
151
+ cancelAtPeriodEnd?: boolean;
152
+ canceledAt?: string | null;
153
+ trialEnd?: string | null;
154
+ limits?: string | null;
155
+ createdAt: string;
156
+ updatedAt: string;
157
+ },
158
+ ) {
159
+ const existing = (await ctx.db.query(subEnt, {
160
+ stripeSubscriptionId: args.stripeSubscriptionId,
161
+ })) as Array<{ id: string }>;
162
+ if (existing[0]) {
163
+ await ctx.db.update(subEnt, existing[0].id, {
164
+ plan: args.plan,
165
+ status: args.status,
166
+ seats: args.seats ?? 1,
167
+ currentPeriodEnd: args.currentPeriodEnd ?? null,
168
+ cancelAtPeriodEnd: args.cancelAtPeriodEnd ?? false,
169
+ canceledAt: args.canceledAt ?? null,
170
+ trialEnd: args.trialEnd ?? null,
171
+ limits: args.limits ?? null,
172
+ updatedAt: args.updatedAt,
173
+ });
174
+ } else {
175
+ await ctx.db.insert(subEnt, {
176
+ referenceId: args.referenceId,
177
+ stripeCustomerId: args.stripeCustomerId,
178
+ stripeSubscriptionId: args.stripeSubscriptionId,
179
+ plan: args.plan,
180
+ status: args.status,
181
+ seats: args.seats ?? 1,
182
+ currentPeriodEnd: args.currentPeriodEnd ?? null,
183
+ cancelAtPeriodEnd: args.cancelAtPeriodEnd ?? false,
184
+ canceledAt: args.canceledAt ?? null,
185
+ trialEnd: args.trialEnd ?? null,
186
+ limits: args.limits ?? null,
187
+ createdAt: args.createdAt,
188
+ updatedAt: args.updatedAt,
189
+ });
190
+ }
191
+ },
192
+ }),
193
+ };
194
+ }
@@ -0,0 +1,64 @@
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 undoes a pending cancellation
9
+ * (i.e. a subscription with `cancel_at_period_end: true` that
10
+ * hasn't yet reached its period end). Only works while
11
+ * `subscription.status` is still `active` and the period hasn't
12
+ * expired — once Stripe has actually canceled the sub, restoring
13
+ * requires a new checkout.
14
+ */
15
+ export function restoreSubscriptionHandler(cfg: StripeConfig) {
16
+ return action({
17
+ args: {
18
+ referenceId: v.optional(v.string()),
19
+ },
20
+ async handler(ctx: HandlerCtx, args: { referenceId?: string }) {
21
+ const referenceId = args.referenceId ?? defaultReferenceId(ctx, cfg);
22
+ if (!referenceId) {
23
+ throw ctx.error(
24
+ "NO_REFERENCE",
25
+ "referenceId required (no active tenant or user)",
26
+ );
27
+ }
28
+ await authorizeReference(ctx, cfg, referenceId, "restore");
29
+
30
+ const sub = await ctx.runQuery<{
31
+ stripeSubscriptionId: string;
32
+ cancelAtPeriodEnd?: boolean;
33
+ status: string;
34
+ } | null>("_pylonStripeFindActiveSubForReference", { referenceId });
35
+ if (!sub) {
36
+ throw ctx.error("NOT_FOUND", "no subscription for reference");
37
+ }
38
+ if (!sub.cancelAtPeriodEnd) {
39
+ throw ctx.error(
40
+ "NOT_CANCELING",
41
+ "subscription is not scheduled to cancel",
42
+ );
43
+ }
44
+
45
+ const secretKey = resolveSecretKey(ctx, cfg);
46
+ await stripeRequest(
47
+ { secretKey, apiVersion: cfg.apiVersion },
48
+ "POST",
49
+ `/subscriptions/${sub.stripeSubscriptionId}`,
50
+ { cancel_at_period_end: false },
51
+ );
52
+ return { restored: true };
53
+ },
54
+ });
55
+ }
56
+
57
+ function defaultReferenceId(
58
+ ctx: HandlerCtx,
59
+ cfg: StripeConfig,
60
+ ): string | null {
61
+ if (cfg.referenceType === "user") return ctx.auth.userId ?? null;
62
+ if (cfg.referenceType === "org") return ctx.auth.tenantId ?? null;
63
+ return null;
64
+ }
@@ -0,0 +1,156 @@
1
+ import { action } from "@pylonsync/functions";
2
+
3
+ import { periodEndFromSubscription, planFromSubscription } from "../plans";
4
+ import { verifyStripeSignature } from "../signature";
5
+ import type {
6
+ HandlerCtx,
7
+ StripeConfig,
8
+ StripeEvent,
9
+ StripeInvoice,
10
+ StripeSubscription,
11
+ } from "../types";
12
+ import { resolveWebhookSecret } from "./internal";
13
+
14
+ /**
15
+ * Factory: returns the unified webhook handler. Verifies signature,
16
+ * routes by event type, upserts the canonical Subscription row,
17
+ * fires lifecycle hooks.
18
+ *
19
+ * Event coverage (matches the better-auth Stripe plugin):
20
+ *
21
+ * customer.subscription.created → upsert row, status=active|trialing
22
+ * customer.subscription.updated → upsert row, reflect new plan/seats/period
23
+ * customer.subscription.deleted → row → status=canceled
24
+ * customer.subscription.trial_will_end → onEvent hook only
25
+ * invoice.created/finalized/paid/payment_failed/voided → onInvoice hook
26
+ * anything else → cfg.hooks.onEvent
27
+ *
28
+ * Mounted at `POST /api/webhooks/stripeWebhook` by Pylon's webhook
29
+ * router. Configure your Stripe dashboard endpoint with that URL
30
+ * and the signing secret as `STRIPE_WEBHOOK_SECRET`.
31
+ */
32
+ export function stripeWebhookHandler(cfg: StripeConfig) {
33
+ return action({
34
+ args: {},
35
+ async handler(ctx: HandlerCtx) {
36
+ if (!ctx.request) {
37
+ throw ctx.error(
38
+ "BAD_INVOCATION",
39
+ "stripeWebhook requires HTTP request context",
40
+ );
41
+ }
42
+ const secret = resolveWebhookSecret(ctx, cfg);
43
+ const sigHeader = ctx.request.headers["stripe-signature"];
44
+ const ok = await verifyStripeSignature(
45
+ secret,
46
+ ctx.request.rawBody,
47
+ sigHeader,
48
+ );
49
+ if (ok !== true) {
50
+ throw ctx.error("INVALID_SIGNATURE", `stripe sig: ${ok}`);
51
+ }
52
+
53
+ let event: StripeEvent;
54
+ try {
55
+ event = JSON.parse(ctx.request.rawBody) as StripeEvent;
56
+ } catch {
57
+ throw ctx.error("BAD_BODY", "invalid JSON");
58
+ }
59
+
60
+ switch (event.type) {
61
+ case "customer.subscription.created":
62
+ case "customer.subscription.updated":
63
+ case "customer.subscription.deleted": {
64
+ const sub = event.data.object as unknown as StripeSubscription;
65
+ await handleSubscriptionEvent(ctx, cfg, event, sub);
66
+ return { ok: true, type: event.type };
67
+ }
68
+ case "invoice.created":
69
+ case "invoice.finalized":
70
+ case "invoice.paid":
71
+ case "invoice.payment_failed":
72
+ case "invoice.voided": {
73
+ const inv = event.data.object as unknown as StripeInvoice;
74
+ await handleInvoiceEvent(ctx, cfg, event, inv);
75
+ return { ok: true, type: event.type };
76
+ }
77
+ default:
78
+ if (cfg.hooks?.onEvent) await cfg.hooks.onEvent(ctx, event);
79
+ return { ignored: event.type };
80
+ }
81
+ },
82
+ });
83
+ }
84
+
85
+ async function handleSubscriptionEvent(
86
+ ctx: HandlerCtx,
87
+ cfg: StripeConfig,
88
+ event: StripeEvent,
89
+ sub: StripeSubscription,
90
+ ): Promise<void> {
91
+ const referenceId = await referenceFromCustomerId(ctx, cfg, sub.customer);
92
+ if (!referenceId) {
93
+ // No mapping — log + drop. The app likely deleted the
94
+ // reference; Stripe will retry the webhook for a while and
95
+ // eventually mark it as failed.
96
+ return;
97
+ }
98
+ const { plan } = planFromSubscription(cfg.plans, sub);
99
+ const planName = plan?.name ?? "pending";
100
+ const now = new Date().toISOString();
101
+ await ctx.runMutation("_pylonStripeUpsertSubscription", {
102
+ referenceId,
103
+ stripeCustomerId: sub.customer,
104
+ stripeSubscriptionId: sub.id,
105
+ plan: planName,
106
+ status: sub.status,
107
+ seats: sub.items.data[0]?.quantity ?? 1,
108
+ currentPeriodEnd: periodEndFromSubscription(sub),
109
+ cancelAtPeriodEnd: !!sub.cancel_at_period_end,
110
+ canceledAt: sub.canceled_at
111
+ ? new Date(sub.canceled_at * 1000).toISOString()
112
+ : null,
113
+ trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000).toISOString() : null,
114
+ limits: plan?.limits ? JSON.stringify(plan.limits) : null,
115
+ createdAt: now,
116
+ updatedAt: now,
117
+ });
118
+ if (event.type === "customer.subscription.created" && cfg.hooks?.onSubscriptionActivate) {
119
+ await cfg.hooks.onSubscriptionActivate(ctx, { referenceId, plan: planName, subscription: sub });
120
+ } else if (event.type === "customer.subscription.updated" && cfg.hooks?.onSubscriptionUpdate) {
121
+ await cfg.hooks.onSubscriptionUpdate(ctx, { referenceId, plan: planName, subscription: sub });
122
+ } else if (event.type === "customer.subscription.deleted" && cfg.hooks?.onSubscriptionCancel) {
123
+ await cfg.hooks.onSubscriptionCancel(ctx, { referenceId, subscription: sub });
124
+ }
125
+ }
126
+
127
+ async function handleInvoiceEvent(
128
+ ctx: HandlerCtx,
129
+ cfg: StripeConfig,
130
+ event: StripeEvent,
131
+ inv: StripeInvoice,
132
+ ): Promise<void> {
133
+ const referenceId = await referenceFromCustomerId(ctx, cfg, inv.customer);
134
+ if (!referenceId) return;
135
+ if (cfg.hooks?.onInvoice) {
136
+ await cfg.hooks.onInvoice(ctx, {
137
+ referenceId,
138
+ eventType: event.type,
139
+ invoice: inv,
140
+ });
141
+ }
142
+ }
143
+
144
+ async function referenceFromCustomerId(
145
+ ctx: HandlerCtx,
146
+ cfg: StripeConfig,
147
+ customerId: string,
148
+ ): Promise<string | null> {
149
+ const holder = cfg.entities?.customerHolder ??
150
+ (cfg.referenceType === "user" ? "User" : "Org");
151
+ const row = await ctx.runQuery<{ id: string } | null>(
152
+ "_pylonStripeFindByCustomerId",
153
+ { entity: holder, stripeCustomerId: customerId },
154
+ );
155
+ return row?.id ?? null;
156
+ }
package/src/index.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `@pylonsync/stripe` — declarative Stripe billing for Pylon apps.
3
+ *
4
+ * Replaces the per-app rewrite of customer creation, checkout
5
+ * session minting, billing portal, webhook signature verification,
6
+ * plan derivation, and subscription state management.
7
+ *
8
+ * Usage (in your app.ts):
9
+ *
10
+ * ```ts
11
+ * import { buildManifest } from "@pylonsync/sdk";
12
+ * import { stripe } from "@pylonsync/stripe";
13
+ *
14
+ * const billing = stripe({
15
+ * referenceType: "org",
16
+ * plans: [
17
+ * { name: "starter", priceId: "price_...", limits: { recordings: 50 } },
18
+ * { name: "pro", priceId: "price_...", limits: { recordings: 500 } },
19
+ * { name: "scale", priceId: "price_...", limits: { recordings: -1 } },
20
+ * ],
21
+ * hooks: {
22
+ * onSubscriptionActivate: async (ctx, { referenceId, plan }) => {
23
+ * // analytics, welcome email, etc.
24
+ * },
25
+ * },
26
+ * });
27
+ *
28
+ * console.log(JSON.stringify(buildManifest({
29
+ * name: "myapp",
30
+ * entities: [Org, User, ...billing.manifest.entities],
31
+ * actions: [...billing.manifest.actions],
32
+ * queries: [...billing.manifest.queries],
33
+ * policies: [...billing.manifest.policies],
34
+ * }), null, 2));
35
+ * ```
36
+ *
37
+ * Then create one-line wrapper files in `functions/`:
38
+ *
39
+ * ```ts
40
+ * // functions/createCheckoutSession.ts
41
+ * export { createCheckoutSession as default } from "../billing";
42
+ * ```
43
+ *
44
+ * Where `../billing.ts` is:
45
+ *
46
+ * ```ts
47
+ * // billing.ts
48
+ * import { stripe } from "@pylonsync/stripe";
49
+ * export const billingConfig = stripe({ ... });
50
+ * export const {
51
+ * createCheckoutSession,
52
+ * createBillingPortalSession,
53
+ * cancelSubscription,
54
+ * restoreSubscription,
55
+ * stripeWebhook,
56
+ * ...internals
57
+ * } = billingConfig.handlers;
58
+ * ```
59
+ *
60
+ * Pylon's file-based function loader requires one default export
61
+ * per function file, which is why the per-function wrappers exist.
62
+ * A future SDK version may expose a `plugins: [...]` field on
63
+ * `buildManifest()` that auto-registers handler factories without
64
+ * the wrapper files; until then, the wrapper layer keeps the
65
+ * boilerplate to 1 line per function.
66
+ */
67
+
68
+ import { buildStripeManifest, type StripeManifestFragment } from "./manifest";
69
+ import {
70
+ cancelSubscriptionHandler,
71
+ createBillingPortalSessionHandler,
72
+ createCheckoutSessionHandler,
73
+ internalHandlers,
74
+ restoreSubscriptionHandler,
75
+ stripeWebhookHandler,
76
+ } from "./handlers";
77
+ import type { StripeConfig } from "./types";
78
+
79
+ export type {
80
+ StripeConfig,
81
+ StripePlan,
82
+ StripeHooks,
83
+ StripeEvent,
84
+ StripeSubscription,
85
+ StripeInvoice,
86
+ HandlerCtx,
87
+ ReferenceType,
88
+ SubscriptionAction,
89
+ } from "./types";
90
+ export type { StripeManifestFragment } from "./manifest";
91
+ export { StripeError } from "./client";
92
+ export { verifyStripeSignature } from "./signature";
93
+ export { assertSafeRedirectUrl } from "./safe-url";
94
+
95
+ export interface StripePlugin {
96
+ /** Manifest fragment to merge into `buildManifest()`. */
97
+ manifest: StripeManifestFragment;
98
+ /**
99
+ * Handler exports. One per function file: re-export as the
100
+ * default export of a file under `functions/<name>.ts`. The
101
+ * internal `_pylonStripe*` handlers live under `internals` and
102
+ * also need re-exporting (one wrapper per name).
103
+ */
104
+ handlers: {
105
+ createCheckoutSession: ReturnType<typeof createCheckoutSessionHandler>;
106
+ createBillingPortalSession: ReturnType<
107
+ typeof createBillingPortalSessionHandler
108
+ >;
109
+ cancelSubscription: ReturnType<typeof cancelSubscriptionHandler>;
110
+ restoreSubscription: ReturnType<typeof restoreSubscriptionHandler>;
111
+ stripeWebhook: ReturnType<typeof stripeWebhookHandler>;
112
+ } & Record<string, unknown>;
113
+ }
114
+
115
+ export function stripe(cfg: StripeConfig): StripePlugin {
116
+ return {
117
+ manifest: buildStripeManifest(cfg),
118
+ handlers: {
119
+ createCheckoutSession: createCheckoutSessionHandler(cfg),
120
+ createBillingPortalSession: createBillingPortalSessionHandler(cfg),
121
+ cancelSubscription: cancelSubscriptionHandler(cfg),
122
+ restoreSubscription: restoreSubscriptionHandler(cfg),
123
+ stripeWebhook: stripeWebhookHandler(cfg),
124
+ ...internalHandlers(cfg),
125
+ },
126
+ };
127
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Manifest fragments — entities + action declarations the plugin
3
+ * contributes to an app's manifest. App code spreads these into
4
+ * its `buildManifest({ entities, actions })` call.
5
+ */
6
+
7
+ import {
8
+ type ActionDefinition,
9
+ type EntityDefinition,
10
+ type PolicyDefinition,
11
+ type QueryDefinition,
12
+ action,
13
+ entity,
14
+ field,
15
+ policy,
16
+ query,
17
+ } from "@pylonsync/sdk";
18
+
19
+ import type { StripeConfig } from "./types";
20
+
21
+ export interface StripeManifestFragment {
22
+ entities: EntityDefinition[];
23
+ actions: ActionDefinition[];
24
+ queries: QueryDefinition[];
25
+ policies: PolicyDefinition[];
26
+ }
27
+
28
+ /**
29
+ * Build the manifest fragment for the Stripe plugin. App composes
30
+ * this into its top-level manifest:
31
+ *
32
+ * ```ts
33
+ * const billing = stripe({ referenceType: "org", plans: [...] });
34
+ *
35
+ * buildManifest({
36
+ * entities: [Org, User, ...billing.manifest.entities],
37
+ * actions: [...billing.manifest.actions],
38
+ * queries: [...billing.manifest.queries],
39
+ * policies: [...billing.manifest.policies],
40
+ * });
41
+ * ```
42
+ */
43
+ export function buildStripeManifest(
44
+ cfg: StripeConfig,
45
+ ): StripeManifestFragment {
46
+ const subscriptionEntity = cfg.entities?.subscription ?? "StripeSubscription";
47
+
48
+ // Single source of truth for billing state. One row per
49
+ // referenceId — when a customer upgrades/cancels the row updates
50
+ // in place. Indexed by stripeSubscriptionId for webhook lookups.
51
+ const Subscription = entity(subscriptionEntity, {
52
+ referenceId: field.string(),
53
+ stripeCustomerId: field.string(),
54
+ stripeSubscriptionId: field.string().unique(),
55
+ plan: field.string(),
56
+ status: field.string(),
57
+ seats: field.number().optional(),
58
+ currentPeriodEnd: field.string().optional(),
59
+ cancelAtPeriodEnd: field.boolean().optional(),
60
+ canceledAt: field.string().optional(),
61
+ trialEnd: field.string().optional(),
62
+ annual: field.boolean().optional(),
63
+ limits: field.string().optional(),
64
+ createdAt: field.string(),
65
+ updatedAt: field.string(),
66
+ });
67
+
68
+ // Policy: subscriptions are tenant-scoped via referenceId. App
69
+ // auth context decides whether that referenceId is a user or
70
+ // org — the policy treats it opaquely. When referenceType is
71
+ // "org", the active tenant maps 1:1; when "user", the auth
72
+ // userId maps 1:1.
73
+ const subscriptionPolicy = policy({
74
+ name: `${subscriptionEntity.toLowerCase()}_read`,
75
+ entity: subscriptionEntity,
76
+ allowRead:
77
+ cfg.referenceType === "org"
78
+ ? "auth.tenantId == data.referenceId"
79
+ : "auth.userId == data.referenceId",
80
+ allowInsert: "false",
81
+ allowUpdate: "false",
82
+ allowDelete: "false",
83
+ });
84
+
85
+ return {
86
+ entities: [Subscription],
87
+ policies: [subscriptionPolicy],
88
+ queries: [
89
+ query("listSubscriptions"),
90
+ query("getSubscription", {
91
+ input: [{ name: "referenceId", type: "string" }],
92
+ }),
93
+ ],
94
+ actions: [
95
+ action("createCheckoutSession", {
96
+ input: [
97
+ { name: "plan", type: "string" },
98
+ { name: "referenceId", type: "string", optional: true },
99
+ { name: "successUrl", type: "string" },
100
+ { name: "cancelUrl", type: "string" },
101
+ { name: "annual", type: "bool", optional: true },
102
+ { name: "seats", type: "int", optional: true },
103
+ ],
104
+ }),
105
+ action("createBillingPortalSession", {
106
+ input: [
107
+ { name: "referenceId", type: "string", optional: true },
108
+ { name: "returnUrl", type: "string" },
109
+ ],
110
+ }),
111
+ action("cancelSubscription", {
112
+ input: [
113
+ { name: "referenceId", type: "string", optional: true },
114
+ { name: "scheduleAtPeriodEnd", type: "bool", optional: true },
115
+ ],
116
+ }),
117
+ action("restoreSubscription", {
118
+ input: [{ name: "referenceId", type: "string", optional: true }],
119
+ }),
120
+ action("stripeWebhook"),
121
+ ],
122
+ };
123
+ }