| 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 | import { Database } from "./database.ts"; |
| 7 | |
| 8 | /** |
| 9 | * One merchant on the platform. `charges_enabled` is the flag that |
| 10 | * matters: until Stripe sets it, the merchant cannot take payments and |
| 11 | * the platform should keep sending them back to onboarding. |
| 12 | */ |
| 13 | interface Merchant { |
| 14 | id: string; |
| 15 | email: string; |
| 16 | details_submitted: number; |
| 17 | charges_enabled: number; |
| 18 | payouts_enabled: number; |
| 19 | created_at: number; |
| 20 | updated_at: number; |
| 21 | } |
| 22 | |
| 23 | /** |
| 24 | * A Stripe Connect platform Worker. |
| 25 | * |
| 26 | * A marketplace or SaaS-for-businesses onboards other companies as |
| 27 | * connected accounts, and those companies get paid through the platform. |
| 28 | * The lifecycle is: |
| 29 | * |
| 30 | * 1. `POST /merchants` — create an Express account for the merchant, |
| 31 | * store the id, and return a hosted onboarding URL. |
| 32 | * 2. The merchant fills in Stripe's onboarding form. Account Links expire |
| 33 | * and merchants abandon them, so `POST /merchants/:id/onboarding` |
| 34 | * mints a fresh link at any time. |
| 35 | * 3. Stripe sends `account.updated` when onboarding finishes. |
| 36 | * `consumeEvents` provisions the webhook endpoint; the handler records |
| 37 | * `charges_enabled` in D1. |
| 38 | * 4. `GET /merchants/:id` — the platform reads that row to decide whether |
| 39 | * the merchant is live. |
| 40 | */ |
| 41 | export default class Api extends Cloudflare.Worker<Api>()( |
| 42 | "Api", |
| 43 | { |
| 44 | main: import.meta.url, |
| 45 | }, |
| 46 | Effect.gen(function* () { |
| 47 | const db = yield* Cloudflare.D1.QueryDatabase(Database); |
| 48 | const createAccount = yield* Stripe.CreateAccount(); |
| 49 | const createAccountLink = yield* Stripe.CreateAccountLink(); |
| 50 | |
| 51 | // Stripe → platform. Fires on every change to any connected account. |
| 52 | yield* Stripe.consumeEvents( |
| 53 | "Events", |
| 54 | { events: [Stripe.AccountUpdated] }, |
| 55 | Effect.fn(function* (event) { |
| 56 | const account = event.object; |
| 57 | yield* db |
| 58 | .prepare( |
| 59 | `UPDATE merchants |
| 60 | SET details_submitted = ?, charges_enabled = ?, payouts_enabled = ?, updated_at = unixepoch() |
| 61 | WHERE id = ?`, |
| 62 | ) |
| 63 | .bind( |
| 64 | account.details_submitted ? 1 : 0, |
| 65 | account.charges_enabled ? 1 : 0, |
| 66 | account.payouts_enabled ? 1 : 0, |
| 67 | account.id, |
| 68 | ) |
| 69 | .run() |
| 70 | .pipe(Effect.orDie); |
| 71 | }), |
| 72 | ); |
| 73 | |
| 74 | // Stripe's hosted onboarding needs both URLs: `return_url` is where |
| 75 | // the merchant lands when done, `refresh_url` is where Stripe sends |
| 76 | // them if the link expired so the platform can mint a new one. |
| 77 | const onboardingLink = (merchantId: string, origin: string) => |
| 78 | createAccountLink({ |
| 79 | account: merchantId, |
| 80 | type: "account_onboarding", |
| 81 | return_url: `${origin}/merchants/${merchantId}/onboarded`, |
| 82 | refresh_url: `${origin}/merchants/${merchantId}/onboarding/refresh`, |
| 83 | }); |
| 84 | |
| 85 | return { |
| 86 | fetch: Effect.gen(function* () { |
| 87 | const request = yield* HttpServerRequest; |
| 88 | const url = new URL(request.originalUrl); |
| 89 | const segments = url.pathname.split("/").filter(Boolean); |
| 90 | |
| 91 | if (request.method === "GET" && url.pathname === "/") { |
| 92 | return HttpServerResponse.text("ok"); |
| 93 | } |
| 94 | |
| 95 | // 1. Sign a merchant up. |
| 96 | if (request.method === "POST" && url.pathname === "/merchants") { |
| 97 | const body = (yield* request.json) as { email?: string }; |
| 98 | if (!body.email) { |
| 99 | return yield* HttpServerResponse.json( |
| 100 | { error: "email is required" }, |
| 101 | { status: 400 }, |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | const account = yield* createAccount({ |
| 106 | type: "express", |
| 107 | country: "US", |
| 108 | email: body.email, |
| 109 | capabilities: { |
| 110 | card_payments: { requested: true }, |
| 111 | transfers: { requested: true }, |
| 112 | }, |
| 113 | }).pipe(Effect.orDie); |
| 114 | |
| 115 | yield* db |
| 116 | .prepare("INSERT INTO merchants (id, email) VALUES (?, ?)") |
| 117 | .bind(account.id, body.email) |
| 118 | .run() |
| 119 | .pipe(Effect.orDie); |
| 120 | |
| 121 | const link = yield* onboardingLink(account.id, url.origin).pipe( |
| 122 | Effect.orDie, |
| 123 | ); |
| 124 | |
| 125 | return yield* HttpServerResponse.json( |
| 126 | { merchantId: account.id, onboardingUrl: link.url }, |
| 127 | { status: 201 }, |
| 128 | ); |
| 129 | } |
| 130 | |
| 131 | // 2. Resume onboarding — links are single-use and expire. |
| 132 | if ( |
| 133 | request.method === "POST" && |
| 134 | segments.length === 3 && |
| 135 | segments[0] === "merchants" && |
| 136 | segments[2] === "onboarding" |
| 137 | ) { |
| 138 | const merchantId = segments[1]; |
| 139 | const row = yield* db |
| 140 | .prepare("SELECT id FROM merchants WHERE id = ?") |
| 141 | .bind(merchantId) |
| 142 | .first<{ id: string }>() |
| 143 | .pipe(Effect.orDie); |
| 144 | if (row === null) { |
| 145 | return yield* HttpServerResponse.json( |
| 146 | { error: "Unknown merchant" }, |
| 147 | { status: 404 }, |
| 148 | ); |
| 149 | } |
| 150 | const link = yield* onboardingLink(merchantId, url.origin).pipe( |
| 151 | Effect.orDie, |
| 152 | ); |
| 153 | return yield* HttpServerResponse.json({ onboardingUrl: link.url }); |
| 154 | } |
| 155 | |
| 156 | // Where Stripe sends the merchant back. |
| 157 | if ( |
| 158 | request.method === "GET" && |
| 159 | segments.length === 3 && |
| 160 | segments[0] === "merchants" && |
| 161 | (segments[2] === "onboarded" || segments[2] === "refresh") |
| 162 | ) { |
| 163 | return HttpServerResponse.text( |
| 164 | segments[2] === "onboarded" |
| 165 | ? "Onboarding complete. You can close this tab." |
| 166 | : "That onboarding link expired. Request a new one from the platform.", |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | // 4. Is this merchant live? |
| 171 | if ( |
| 172 | request.method === "GET" && |
| 173 | segments.length === 2 && |
| 174 | segments[0] === "merchants" |
| 175 | ) { |
| 176 | const merchant = yield* db |
| 177 | .prepare("SELECT * FROM merchants WHERE id = ?") |
| 178 | .bind(segments[1]) |
| 179 | .first<Merchant>() |
| 180 | .pipe(Effect.orDie); |
| 181 | if (merchant === null) { |
| 182 | return yield* HttpServerResponse.json( |
| 183 | { error: "Unknown merchant" }, |
| 184 | { status: 404 }, |
| 185 | ); |
| 186 | } |
| 187 | return yield* HttpServerResponse.json({ |
| 188 | id: merchant.id, |
| 189 | email: merchant.email, |
| 190 | onboarded: merchant.details_submitted === 1, |
| 191 | chargesEnabled: merchant.charges_enabled === 1, |
| 192 | payoutsEnabled: merchant.payouts_enabled === 1, |
| 193 | updatedAt: merchant.updated_at, |
| 194 | }); |
| 195 | } |
| 196 | |
| 197 | return yield* HttpServerResponse.json( |
| 198 | { error: "Not found" }, |
| 199 | { status: 404 }, |
| 200 | ); |
| 201 | }), |
| 202 | }; |
| 203 | }).pipe( |
| 204 | Effect.provide([ |
| 205 | Cloudflare.D1.QueryDatabaseBinding, |
| 206 | Stripe.CreateAccountHttp, |
| 207 | Stripe.CreateAccountLinkHttp, |
| 208 | Stripe.ConsumeEventsLive, |
| 209 | ]), |
| 210 | ), |
| 211 | ) {} |