1import * as Cloudflare from "alchemy/Cloudflare";
2import * as Stripe from "alchemy/Stripe";
3import * as Effect from "effect/Effect";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6
7/**
8 * What the app knows about a customer's subscription. Written by the
9 * webhook handler, read by `GET /subscription/:customerId`. This is the
10 * record your app checks to decide whether to serve paid features —
11 * never call Stripe on the request path for that.
12 */
13interface Entitlement {
14 customerId: string;
15 status: "active" | "past_due" | "canceled";
16 priceId: string | null;
17 subscriptionId: string | null;
18 updatedAt: number;
19}
20
21/**
22 * A SaaS billing Worker.
23 *
24 * The catalog (`Product`, `Price`, `Coupon`) is declared on the Worker
25 * and deployed with it. At runtime the Worker:
26 *
27 * - `POST /checkout` — creates a Stripe Customer and a Checkout Session
28 * for the Pro plan, and returns the hosted payment URL.
29 * - `POST /portal` — opens the Billing Portal so the customer can change
30 * card, switch plan, or cancel without your app building those screens.
31 * - `GET /subscription/:customerId` — returns the entitlement your app
32 * should gate features on.
33 *
34 * Stripe reports what happened via webhooks; `consumeEvents` provisions
35 * the endpoint and the handler keeps the entitlement in KV up to date.
36 */
37export default class Api extends Cloudflare.Worker<Api>()(
38 "Api",
39 {
40 main: import.meta.url,
41 },
42 Effect.gen(function* () {
43 // Catalog — lives with the Worker that sells it.
44 const product = yield* Stripe.Product("Pro", {
45 name: "Pro",
46 description: "Billed monthly",
47 });
48 const price = yield* Stripe.Price("ProMonthly", {
49 product,
50 currency: "usd",
51 unitAmount: 2000,
52 recurring: { interval: "month" },
53 });
54 yield* Stripe.Coupon("Launch20", {
55 percentOff: 20,
56 duration: "once",
57 name: "Launch 20%",
58 });
59
60 // What customers can do for themselves in the hosted portal.
61 const portalConfig = yield* Stripe.BillingPortalConfiguration("Portal", {
62 name: "Pro customer portal",
63 features: {
64 invoiceHistory: { enabled: true },
65 paymentMethodUpdate: { enabled: true },
66 customerUpdate: { enabled: true, allowedUpdates: ["email"] },
67 subscriptionCancel: { enabled: true, mode: "at_period_end" },
68 },
69 });
70
71 // `price.id` / `portalConfig.id` are plan-time Outputs. Yielding them
72 // here returns accessors the request handlers can yield at runtime.
73 const priceId = yield* price.id;
74 const portalConfigId = yield* portalConfig.id;
75
76 // Runtime bindings — each one injects the account key onto the Worker
77 // and calls Stripe over HTTP from a request handler.
78 const createCustomer = yield* Stripe.CreateCustomer();
79 const createCheckout = yield* Stripe.CreateCheckoutSession();
80 const createPortal = yield* Stripe.CreateBillingPortalSession();
81
82 // Entitlement store.
83 const entitlements = yield* Cloudflare.KV.Namespace("Entitlements");
84 const kv = yield* Cloudflare.KV.ReadWriteNamespace(entitlements);
85
86 // Stripe → app. Every subscription state change lands here.
87 yield* Stripe.consumeEvents(
88 "Events",
89 {
90 events: [
91 Stripe.CheckoutSessionCompleted,
92 Stripe.CustomerSubscriptionCreated,
93 Stripe.CustomerSubscriptionUpdated,
94 Stripe.CustomerSubscriptionDeleted,
95 Stripe.InvoicePaymentFailed,
96 ],
97 },
98 Effect.fn(function* (event) {
99 const write = (entitlement: Entitlement) =>
100 kv
101 .put(entitlement.customerId, JSON.stringify(entitlement))
102 .pipe(Effect.orDie);
103
104 switch (event.type) {
105 case "checkout.session.completed": {
106 // The buyer paid. Grant access now; the subscription events
107 // that follow keep it accurate.
108 const session = event.object;
109 const customerId = idOf(session.customer);
110 if (customerId === null) return;
111 yield* write({
112 customerId,
113 status: "active",
114 priceId: yield* priceId,
115 subscriptionId: idOf(session.subscription),
116 updatedAt: Date.now(),
117 });
118 return;
119 }
120 case "customer.subscription.created":
121 case "customer.subscription.updated": {
122 const subscription = event.object;
123 yield* write({
124 customerId: idOf(subscription.customer)!,
125 status:
126 subscription.status === "active" ||
127 subscription.status === "trialing"
128 ? "active"
129 : subscription.status === "past_due"
130 ? "past_due"
131 : "canceled",
132 priceId: subscription.items.data[0]?.price.id ?? null,
133 subscriptionId: subscription.id,
134 updatedAt: Date.now(),
135 });
136 return;
137 }
138 case "customer.subscription.deleted": {
139 const subscription = event.object;
140 yield* write({
141 customerId: idOf(subscription.customer)!,
142 status: "canceled",
143 priceId: null,
144 subscriptionId: subscription.id,
145 updatedAt: Date.now(),
146 });
147 return;
148 }
149 case "invoice.payment_failed": {
150 // Card declined on renewal. Stripe will retry; mark the account
151 // so the UI can nudge the customer to the portal.
152 const invoice = event.object;
153 const customerId = idOf(invoice.customer);
154 if (customerId === null) return;
155 const current = yield* kv
156 .get<Entitlement>(customerId, "json")
157 .pipe(Effect.orDie);
158 yield* write({
159 customerId,
160 status: "past_due",
161 priceId: current?.priceId ?? null,
162 subscriptionId: current?.subscriptionId ?? null,
163 updatedAt: Date.now(),
164 });
165 return;
166 }
167 }
168 }),
169 );
170
171 return {
172 fetch: Effect.gen(function* () {
173 const request = yield* HttpServerRequest;
174 const url = new URL(request.originalUrl);
175
176 // Start a subscription. Creates the Customer up front so the
177 // webhook events carry a stable id you can key on.
178 if (request.method === "POST" && url.pathname === "/checkout") {
179 const body = (yield* request.json) as { email?: string };
180 if (!body.email) {
181 return yield* HttpServerResponse.json(
182 { error: "email is required" },
183 { status: 400 },
184 );
185 }
186
187 const customer = yield* createCustomer({ email: body.email }).pipe(
188 Effect.orDie,
189 );
190 const session = yield* createCheckout({
191 mode: "subscription",
192 customer: customer.id,
193 line_items: [{ price: yield* priceId, quantity: 1 }],
194 allow_promotion_codes: true,
195 success_url: `${url.origin}/welcome?session_id={CHECKOUT_SESSION_ID}`,
196 cancel_url: `${url.origin}/pricing`,
197 }).pipe(Effect.orDie);
198
199 return yield* HttpServerResponse.json(
200 { customerId: customer.id, checkoutUrl: session.url },
201 { status: 201 },
202 );
203 }
204
205 // Self-serve management: update card, switch plan, cancel.
206 if (request.method === "POST" && url.pathname === "/portal") {
207 const body = (yield* request.json) as { customerId?: string };
208 if (!body.customerId) {
209 return yield* HttpServerResponse.json(
210 { error: "customerId is required" },
211 { status: 400 },
212 );
213 }
214 const session = yield* createPortal({
215 customer: body.customerId,
216 configuration: yield* portalConfigId,
217 return_url: `${url.origin}/account`,
218 }).pipe(Effect.orDie);
219 return yield* HttpServerResponse.json({ portalUrl: session.url });
220 }
221
222 // What the rest of the app gates on.
223 if (
224 request.method === "GET" &&
225 url.pathname.startsWith("/subscription/")
226 ) {
227 const customerId = url.pathname.slice("/subscription/".length);
228 const entitlement = yield* kv
229 .get<Entitlement>(customerId, "json")
230 .pipe(Effect.orDie);
231 return yield* HttpServerResponse.json(
232 entitlement ?? { customerId, status: "none" },
233 );
234 }
235
236 if (url.pathname === "/welcome" || url.pathname === "/pricing") {
237 return HttpServerResponse.text(
238 url.pathname === "/welcome"
239 ? "Thanks — your subscription is active."
240 : "Pricing page.",
241 );
242 }
243
244 return yield* HttpServerResponse.json(
245 { error: "Not found" },
246 { status: 404 },
247 );
248 }),
249 };
250 }).pipe(
251 Effect.provide([
252 Cloudflare.KV.ReadWriteNamespaceBinding,
253 Stripe.CreateCustomerHttp,
254 Stripe.CreateCheckoutSessionHttp,
255 Stripe.CreateBillingPortalSessionHttp,
256 Stripe.ConsumeEventsLive,
257 ]),
258 ),
259) {}
260
261/**
262 * Stripe returns related objects either as an id string or, when expanded,
263 * as the full object. Normalise to the id.
264 */
265const idOf = (
266 ref: string | { id: string } | { id?: string } | null | undefined,
267): string | null =>
268 ref == null ? null : typeof ref === "string" ? ref : (ref.id ?? null);