1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Stripe from "alchemy/Stripe";
4import * as Test from "alchemy/Test/Bun";
5import {
6 DeleteAccount,
7 GetAccountByAccount,
8 GetWebhookEndpoints,
9 UpdateAccount,
10} from "@distilled.cloud/stripe/stripe";
11import { expect } from "bun:test";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Schedule from "effect/Schedule";
15import * as FetchHttpClient from "effect/http/FetchHttpClient";
16import * as HttpClient from "effect/http/HttpClient";
17import * as HttpClientRequest from "effect/http/HttpClientRequest";
18import Stack from "../alchemy.run.ts";
19
20const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
21 providers: Layer.mergeAll(Cloudflare.providers(), Stripe.providers()),
22 state: Alchemy.localState(),
23});
24
25const { getWhenReady } = Test;
26
27// Out-of-band verification resolves the same stored Stripe key the deploy
28// uses.
29const StripeHttp = Layer.mergeAll(
30 Stripe.StripeAuth,
31 Stripe.fromAuthProvider(),
32 FetchHttpClient.layer,
33);
34
35interface MerchantView {
36 id: string;
37 email: string;
38 onboarded: boolean;
39 chargesEnabled: boolean;
40 payoutsEnabled: boolean;
41 updatedAt: number;
42}
43
44const stack = beforeAll(deploy(Stack), { timeout: 180_000 });
45
46afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
47 timeout: 120_000,
48});
49
50const readMerchant = (base: string, id: string) =>
51 Effect.gen(function* () {
52 const res = yield* HttpClient.execute(
53 HttpClientRequest.get(`${base}/merchants/${id}`),
54 );
55 expect(res.status).toBe(200);
56 return (yield* res.json) as unknown as MerchantView;
57 });
58
59test(
60 "deploys a worker URL and provisions the account.updated webhook",
61 Effect.gen(function* () {
62 const { url } = yield* stack;
63 expect(url).toBeString();
64 const delivery = `${url.replace(/\/+$/, "")}/webhooks/stripe`;
65 const endpoints = yield* GetWebhookEndpoints({ limit: 100 }).pipe(
66 Effect.provide(StripeHttp),
67 );
68 const endpoint = endpoints.data.find(
69 (e) => e.url.replace(/\/+$/, "") === delivery,
70 );
71 expect(endpoint).toBeDefined();
72 expect(endpoint?.enabled_events).toContain("account.updated");
73 }),
74);
75
76// Connect must be enabled on the test account. Skipped otherwise.
77test.skipIf(process.env.STRIPE_TEST_CONNECT !== "1")(
78 "onboards a merchant, resumes onboarding, and records account.updated",
79 Effect.gen(function* () {
80 const { url } = yield* stack;
81 const base = url.replace(/\/+$/, "");
82 yield* getWhenReady(base);
83
84 // 1. Sign up.
85 const signup = yield* HttpClient.execute(
86 HttpClientRequest.post(`${base}/merchants`).pipe(
87 HttpClientRequest.bodyJsonUnsafe({ email: "merchant@example.com" }),
88 ),
89 );
90 expect(signup.status).toBe(201);
91 const { merchantId, onboardingUrl } = (yield* signup.json) as {
92 merchantId: string;
93 onboardingUrl: string;
94 };
95 expect(merchantId).toMatch(/^acct_/);
96 expect(onboardingUrl).toMatch(/^https:\/\/connect\.stripe\.com\//);
97
98 const account = yield* GetAccountByAccount({ account: merchantId }).pipe(
99 Effect.provide(StripeHttp),
100 );
101 expect(account.type).toEqual("express");
102 expect(account.details_submitted).toBe(false);
103
104 // The row exists and the merchant is not live yet.
105 const fresh = yield* readMerchant(base, merchantId);
106 expect(fresh.email).toEqual("merchant@example.com");
107 expect(fresh.onboarded).toBe(false);
108 expect(fresh.chargesEnabled).toBe(false);
109
110 // 2. Links are single-use; the platform can always mint another.
111 const resume = yield* HttpClient.execute(
112 HttpClientRequest.post(`${base}/merchants/${merchantId}/onboarding`),
113 );
114 expect(resume.status).toBe(200);
115 const resumed = (yield* resume.json) as { onboardingUrl: string };
116 expect(resumed.onboardingUrl).toMatch(/^https:\/\/connect\.stripe\.com\//);
117 expect(resumed.onboardingUrl).not.toEqual(onboardingUrl);
118
119 // Unknown merchants get a 404, not a Stripe error.
120 const unknown = yield* HttpClient.execute(
121 HttpClientRequest.post(`${base}/merchants/acct_doesnotexist/onboarding`),
122 );
123 expect(unknown.status).toBe(404);
124
125 // 3. Any change to the account fires account.updated; the handler
126 // copies the capability flags into D1 and bumps updated_at. Nudging
127 // metadata is enough to trigger it without completing the full
128 // onboarding form. Wait a beat so the bump is distinguishable from
129 // the row's creation second.
130 yield* Effect.sleep("2 seconds");
131 yield* UpdateAccount({
132 account: merchantId,
133 metadata: { touched: String(Date.now()) },
134 }).pipe(Effect.provide(StripeHttp));
135
136 const observed = yield* readMerchant(base, merchantId).pipe(
137 Effect.repeat({
138 schedule: Schedule.spaced("5 seconds"),
139 until: (m) => m.updatedAt > fresh.updatedAt,
140 times: 24,
141 }),
142 );
143 expect(observed.id).toEqual(merchantId);
144 expect(observed.onboarded).toBe(account.details_submitted ?? false);
145 expect(observed.chargesEnabled).toBe(account.charges_enabled ?? false);
146
147 yield* DeleteAccount({ account: merchantId }).pipe(
148 Effect.catch(() => Effect.void),
149 Effect.provide(StripeHttp),
150 );
151 }),
152 { timeout: 300_000 },
153);