| 1 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 2 | import * as Stripe from "alchemy/Stripe"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 5 | import * 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 | */ |
| 13 | interface 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 | */ |
| 37 | export 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 | */ |
| 265 | const idOf = ( |
| 266 | ref: string | { id: string } | { id?: string } | null | undefined, |
| 267 | ): string | null => |
| 268 | ref == null ? null : typeof ref === "string" ? ref : (ref.id ?? null); |