@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
|
@@ -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
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|