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 CreatePaymentMethod,
7 CreatePaymentMethodAttach,
8 CreateSubscription,
9 DeleteCustomer,
10 DeleteSubscription,
11 GetCheckoutSession,
12 GetWebhookEndpoints,
13} from "@distilled.cloud/stripe/stripe";
14import { expect } from "bun:test";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Schedule from "effect/Schedule";
18import * as FetchHttpClient from "effect/http/FetchHttpClient";
19import * as HttpClient from "effect/http/HttpClient";
20import * as HttpClientRequest from "effect/http/HttpClientRequest";
21import Stack from "../alchemy.run.ts";
22
23const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
24 providers: Layer.mergeAll(Cloudflare.providers(), Stripe.providers()),
25 state: Alchemy.localState(),
26});
27
28const { getWhenReady } = Test;
29
30// Out-of-band distilled calls resolve the same stored Stripe key the deploy
31// uses, so the test runs against the configured profile without needing
32// STRIPE_API_KEY in the environment.
33const StripeHttp = Layer.mergeAll(
34 Stripe.StripeAuth,
35 Stripe.fromAuthProvider(),
36 FetchHttpClient.layer,
37);
38
39interface Entitlement {
40 customerId: string;
41 status: "active" | "past_due" | "canceled" | "none";
42 priceId: string | null;
43 subscriptionId: string | null;
44}
45
46const stack = beforeAll(deploy(Stack), { timeout: 180_000 });
47
48afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
49 timeout: 120_000,
50});
51
52const readEntitlement = (baseUrl: string, customerId: string) =>
53 Effect.gen(function* () {
54 const res = yield* HttpClient.execute(
55 HttpClientRequest.get(`${baseUrl}/subscription/${customerId}`),
56 );
57 return (yield* res.json) as unknown as Entitlement;
58 });
59
60// Webhook delivery is asynchronous; poll until the entitlement reaches the
61// expected status.
62const waitForStatus = (
63 baseUrl: string,
64 customerId: string,
65 status: Entitlement["status"],
66) =>
67 readEntitlement(baseUrl, customerId).pipe(
68 Effect.repeat({
69 schedule: Schedule.spaced("5 seconds"),
70 until: (e) => e.status === status,
71 times: 24,
72 }),
73 );
74
75test(
76 "deploys a worker URL and provisions the webhook endpoint",
77 Effect.gen(function* () {
78 const { url } = yield* stack;
79 expect(url).toBeString();
80 const delivery = `${url.replace(/\/+$/, "")}/webhooks/stripe`;
81 const endpoints = yield* GetWebhookEndpoints({ limit: 100 }).pipe(
82 Effect.provide(StripeHttp),
83 );
84 const endpoint = endpoints.data.find(
85 (e) => e.url.replace(/\/+$/, "") === delivery,
86 );
87 expect(endpoint).toBeDefined();
88 expect(endpoint?.enabled_events).toEqual(
89 expect.arrayContaining([
90 "checkout.session.completed",
91 "customer.subscription.created",
92 "customer.subscription.updated",
93 "customer.subscription.deleted",
94 "invoice.payment_failed",
95 ]),
96 );
97 }),
98);
99
100test(
101 "POST /checkout creates a customer and a hosted Checkout Session for the Pro price",
102 Effect.gen(function* () {
103 const { url } = yield* stack;
104 const baseUrl = url.replace(/\/+$/, "");
105 yield* getWhenReady(`${baseUrl}/pricing`);
106
107 const res = yield* HttpClient.execute(
108 HttpClientRequest.post(`${baseUrl}/checkout`).pipe(
109 HttpClientRequest.bodyJsonUnsafe({
110 email: "stripe-billing-checkout@example.com",
111 }),
112 ),
113 );
114 expect(res.status).toBe(201);
115 const body = (yield* res.json) as {
116 customerId: string;
117 checkoutUrl: string;
118 };
119 expect(body.customerId).toMatch(/^cus_/);
120 expect(body.checkoutUrl).toMatch(/^https:\/\/checkout\.stripe\.com\//);
121
122 // The session is a real subscription-mode Checkout for the deployed
123 // Price, bound to the customer the Worker just created.
124 const sessionId = new URL(body.checkoutUrl).pathname
125 .split("/")
126 .filter(Boolean)
127 .at(-1)!;
128 const session = yield* GetCheckoutSession({ session: sessionId }).pipe(
129 Effect.provide(StripeHttp),
130 );
131 expect(session.mode).toEqual("subscription");
132 expect(session.status).toEqual("open");
133 expect(session.customer).toEqual(body.customerId);
134
135 // Nothing paid yet → no entitlement.
136 const before = yield* readEntitlement(baseUrl, body.customerId);
137 expect(before.status).toEqual("none");
138
139 yield* DeleteCustomer({ customer: body.customerId }).pipe(
140 Effect.catch(() => Effect.void),
141 Effect.provide(StripeHttp),
142 );
143 }),
144 { timeout: 120_000 },
145);
146
147test(
148 "subscription lifecycle webhooks keep the entitlement current",
149 Effect.gen(function* () {
150 const { url } = yield* stack;
151 const baseUrl = url.replace(/\/+$/, "");
152 yield* getWhenReady(`${baseUrl}/pricing`);
153
154 // Start from the Worker so the customer exists in the app's flow.
155 const checkout = yield* HttpClient.execute(
156 HttpClientRequest.post(`${baseUrl}/checkout`).pipe(
157 HttpClientRequest.bodyJsonUnsafe({
158 email: "stripe-billing-lifecycle@example.com",
159 }),
160 ),
161 );
162 const { customerId, checkoutUrl } = (yield* checkout.json) as {
163 customerId: string;
164 checkoutUrl: string;
165 };
166
167 // Hosted Checkout needs a browser. Stand in for the buyer by attaching
168 // Stripe's `tok_visa` test card and creating the same subscription
169 // directly — this fires the real `customer.subscription.*` webhooks the
170 // Worker consumes.
171 const sessionId = new URL(checkoutUrl).pathname
172 .split("/")
173 .filter(Boolean)
174 .at(-1)!;
175 const session = yield* GetCheckoutSession({
176 session: sessionId,
177 expand: ["line_items"],
178 }).pipe(Effect.provide(StripeHttp));
179 const priceId = session.line_items?.data[0]?.price?.id;
180 expect(priceId).toMatch(/^price_/);
181
182 const paymentMethod = yield* CreatePaymentMethod({
183 type: "card",
184 card: { token: "tok_visa" },
185 }).pipe(Effect.provide(StripeHttp));
186 yield* CreatePaymentMethodAttach({
187 payment_method: paymentMethod.id,
188 customer: customerId,
189 }).pipe(Effect.provide(StripeHttp));
190 const subscription = yield* CreateSubscription({
191 customer: customerId,
192 items: [{ price: priceId! }],
193 default_payment_method: paymentMethod.id,
194 payment_behavior: "error_if_incomplete",
195 }).pipe(Effect.provide(StripeHttp));
196 expect(subscription.status).toEqual("active");
197
198 const active = yield* waitForStatus(baseUrl, customerId, "active");
199 expect(active.subscriptionId).toEqual(subscription.id);
200 expect(active.priceId).toEqual(priceId!);
201
202 yield* DeleteSubscription({
203 subscription_exposed_id: subscription.id,
204 }).pipe(Effect.provide(StripeHttp));
205
206 const canceled = yield* waitForStatus(baseUrl, customerId, "canceled");
207 expect(canceled.subscriptionId).toEqual(subscription.id);
208
209 yield* DeleteCustomer({ customer: customerId }).pipe(
210 Effect.catch(() => Effect.void),
211 Effect.provide(StripeHttp),
212 );
213 }),
214 { timeout: 300_000 },
215);
216
217test(
218 "POST /portal opens the Billing Portal for a customer",
219 Effect.gen(function* () {
220 const { url } = yield* stack;
221 const baseUrl = url.replace(/\/+$/, "");
222 yield* getWhenReady(`${baseUrl}/pricing`);
223
224 const checkout = yield* HttpClient.execute(
225 HttpClientRequest.post(`${baseUrl}/checkout`).pipe(
226 HttpClientRequest.bodyJsonUnsafe({
227 email: "stripe-billing-portal@example.com",
228 }),
229 ),
230 );
231 const { customerId } = (yield* checkout.json) as { customerId: string };
232
233 const res = yield* HttpClient.execute(
234 HttpClientRequest.post(`${baseUrl}/portal`).pipe(
235 HttpClientRequest.bodyJsonUnsafe({ customerId }),
236 ),
237 );
238 expect(res.status).toBe(200);
239 const body = (yield* res.json) as { portalUrl: string };
240 expect(body.portalUrl).toMatch(/^https:\/\/billing\.stripe\.com\//);
241
242 yield* DeleteCustomer({ customer: customerId }).pipe(
243 Effect.catch(() => Effect.void),
244 Effect.provide(StripeHttp),
245 );
246 }),
247 { timeout: 120_000 },
248);