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";
6import { 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 */
13interface 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 */
41export 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) {}