1import * as Provider from "@/Provider";
2import * as Stripe from "@/Stripe";
3import { isMissingStripeResource } from "@/Stripe/missing.ts";
4import * as Test from "@/Test/Alchemy";
5import {
6 DeleteAccount,
7 GetAccountExternalAccount,
8 CreateAccount,
9 CreateToken,
10} from "@distilled.cloud/stripe/stripe";
11import { expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import { MinimumLogLevel } from "effect/References";
14import * as Result from "effect/Result";
15import * as Schedule from "effect/Schedule";
16
17const { test } = Test.make({ providers: Stripe.providers() });
18
19const logLevel = Effect.provideService(
20 MinimumLogLevel,
21 process.env.DEBUG ? "Debug" : "Info",
22);
23
24/** Opt-in: the testing Stripe account must be a Connect platform. */
25const CONNECT_ENABLED = process.env.STRIPE_TEST_CONNECT === "1";
26
27const isMissing = isMissingStripeResource;
28
29const waitUntilGone = (account: string, id: string) =>
30 GetAccountExternalAccount({ account, id }).pipe(
31 Effect.as("found" as const),
32 Effect.catchIf(isMissing, () => Effect.succeed("gone" as const)),
33 Effect.repeat({
34 schedule: Schedule.spaced("1 second"),
35 until: (status) => status === "gone",
36 times: 10,
37 }),
38 );
39
40const deleteAccount = (account: string) =>
41 DeleteAccount({ account }).pipe(
42 Effect.catchIf(isMissing, () => Effect.void),
43 Effect.catchIf(
44 (e) => e._tag === "InvalidRequestError",
45 () => Effect.void,
46 ),
47 Effect.asVoid,
48 );
49
50const createBankToken = (holderName: string) =>
51 CreateToken({
52 bank_account: {
53 country: "US",
54 currency: "usd",
55 account_holder_name: holderName,
56 account_holder_type: "individual",
57 routing_number: "110000000",
58 account_number: "000123456789",
59 },
60 });
61
62const connectAccountProps = {
63 type: "custom" as const,
64 country: "US",
65 capabilities: {
66 transfers: { requested: true },
67 },
68 tosAcceptance: {
69 date: 1_609_459_200,
70 ip: "127.0.0.1",
71 },
72 businessType: "individual" as const,
73 individual: {
74 first_name: "Alchemy",
75 last_name: "Tester",
76 },
77};
78
79const probeConnectAccount = () =>
80 CreateAccount({
81 type: "custom",
82 country: "US",
83 email: "alchemy.account-external-account.probe@example.com",
84 capabilities: {
85 transfers: { requested: true },
86 },
87 tos_acceptance: {
88 date: 1_609_459_200,
89 ip: "127.0.0.1",
90 },
91 business_type: "individual",
92 individual: {
93 first_name: "Alchemy",
94 last_name: "Tester",
95 },
96 });
97
98test.provider(
99 "connect accounts entitlement probe",
100 (stack) =>
101 Effect.gen(function* () {
102 yield* stack.destroy();
103
104 const probe = yield* probeConnectAccount().pipe(Effect.result);
105 if (Result.isFailure(probe)) {
106 expect(probe.failure._tag).not.toEqual("UnknownStripeError");
107 expect(probe.failure._tag).toEqual("InvalidRequestError");
108 expect(probe.failure.message ?? "").toContain("signed up for Connect");
109 yield* stack.destroy();
110 return;
111 }
112
113 yield* deleteAccount(probe.success.id);
114 yield* stack.destroy();
115 }).pipe(logLevel),
116 {
117 tags: [
118 "provider:stripe",
119 "provider:stripe:account",
120 "provider:stripe:accountexternalaccount",
121 "live",
122 ],
123 timeout: 60_000,
124 },
125);
126
127test.provider.skipIf(!CONNECT_ENABLED)(
128 "create, update, and delete an account external account",
129 (stack) =>
130 Effect.gen(function* () {
131 yield* stack.destroy();
132
133 const token = yield* createBankToken("Jenny Rosen");
134
135 const created = yield* stack.deploy(
136 Effect.gen(function* () {
137 const account = yield* Stripe.Account("ConnectAccount", {
138 ...connectAccountProps,
139 email: "alchemy.aea.lifecycle@example.com",
140 });
141 const externalAccount = yield* Stripe.AccountExternalAccount(
142 "PayoutBank",
143 {
144 account: account.id,
145 externalAccount: token.id,
146 accountHolderName: "Jenny Rosen",
147 accountHolderType: "individual",
148 defaultForCurrency: true,
149 metadata: { purpose: "payouts" },
150 },
151 );
152 return { account, externalAccount };
153 }),
154 );
155
156 expect(created.externalAccount.id).toMatch(/^(ba_|card_)/);
157 expect(created.externalAccount.account).toEqual(created.account.id);
158 expect(created.externalAccount.object).toEqual("bank_account");
159 expect(created.externalAccount.last4).toEqual("6789");
160 expect(created.externalAccount.country).toEqual("US");
161 expect(created.externalAccount.currency).toEqual("usd");
162 expect(created.externalAccount.accountHolderName).toEqual("Jenny Rosen");
163 expect(created.externalAccount.accountHolderType).toEqual("individual");
164 expect(created.externalAccount.defaultForCurrency).toEqual(true);
165 expect(created.externalAccount.metadata).toMatchObject({
166 purpose: "payouts",
167 });
168 expect(created.externalAccount.routingNumber).toEqual("110000000");
169
170 const fetched = yield* GetAccountExternalAccount({
171 account: created.externalAccount.account,
172 id: created.externalAccount.id,
173 });
174 expect(fetched.id).toEqual(created.externalAccount.id);
175 expect(fetched.object).toEqual("bank_account");
176 if (fetched.object === "bank_account") {
177 expect(fetched.last4).toEqual("6789");
178 expect(fetched.account_holder_name).toEqual("Jenny Rosen");
179 expect(fetched.account_holder_type).toEqual("individual");
180 expect(fetched.metadata?.purpose).toEqual("payouts");
181 expect(
182 fetched.metadata?.[Stripe.alchemyMetadataKeys.stack],
183 ).toBeDefined();
184 expect(
185 fetched.metadata?.[Stripe.alchemyMetadataKeys.stage],
186 ).toBeDefined();
187 expect(fetched.metadata?.[Stripe.alchemyMetadataKeys.id]).toBeDefined();
188 }
189
190 const updated = yield* stack.deploy(
191 Effect.gen(function* () {
192 const account = yield* Stripe.Account("ConnectAccount", {
193 ...connectAccountProps,
194 email: "alchemy.aea.lifecycle@example.com",
195 });
196 const externalAccount = yield* Stripe.AccountExternalAccount(
197 "PayoutBank",
198 {
199 account: account.id,
200 externalAccount: token.id,
201 accountHolderName: "Alchemy Tester",
202 accountHolderType: "company",
203 defaultForCurrency: true,
204 metadata: { purpose: "payroll", region: "us" },
205 },
206 );
207 return { account, externalAccount };
208 }),
209 );
210
211 expect(updated.externalAccount.id).toEqual(created.externalAccount.id);
212 expect(updated.externalAccount.account).toEqual(created.account.id);
213 expect(updated.externalAccount.accountHolderName).toEqual(
214 "Alchemy Tester",
215 );
216 expect(updated.externalAccount.accountHolderType).toEqual("company");
217 expect(updated.externalAccount.metadata).toEqual({
218 purpose: "payroll",
219 region: "us",
220 });
221
222 const refetched = yield* GetAccountExternalAccount({
223 account: updated.externalAccount.account,
224 id: updated.externalAccount.id,
225 });
226 if (refetched.object === "bank_account") {
227 expect(refetched.account_holder_name).toEqual("Alchemy Tester");
228 expect(refetched.account_holder_type).toEqual("company");
229 expect(refetched.metadata?.purpose).toEqual("payroll");
230 expect(refetched.metadata?.region).toEqual("us");
231 expect(
232 refetched.metadata?.[Stripe.alchemyMetadataKeys.id],
233 ).toBeDefined();
234 }
235
236 yield* stack.destroy();
237
238 const gone = yield* waitUntilGone(
239 created.externalAccount.account,
240 created.externalAccount.id,
241 );
242 expect(gone).toEqual("gone");
243 }).pipe(logLevel),
244 {
245 tags: [
246 "provider:stripe",
247 "provider:stripe:account",
248 "provider:stripe:accountexternalaccount",
249 "live",
250 ],
251 timeout: 120_000,
252 },
253);
254
255test.provider.skipIf(!CONNECT_ENABLED)(
256 "list enumerates the deployed account external account",
257 (stack) =>
258 Effect.gen(function* () {
259 yield* stack.destroy();
260
261 const token = yield* createBankToken("List Holder");
262
263 const deployed = yield* stack.deploy(
264 Effect.gen(function* () {
265 const account = yield* Stripe.Account("ListConnectAccount", {
266 ...connectAccountProps,
267 email: "alchemy.aea.list@example.com",
268 });
269 const externalAccount = yield* Stripe.AccountExternalAccount(
270 "ListPayoutBank",
271 {
272 account: account.id,
273 externalAccount: token.id,
274 accountHolderName: "List Holder",
275 metadata: { kind: "list" },
276 },
277 );
278 return { account, externalAccount };
279 }),
280 );
281
282 const provider = yield* Provider.findProvider(
283 Stripe.AccountExternalAccount,
284 );
285 const all = yield* provider.list();
286 const found = all.find((ea) => ea.id === deployed.externalAccount.id);
287 expect(found).toBeDefined();
288 expect(found?.account).toEqual(deployed.account.id);
289 expect(found?.metadata).toMatchObject({ kind: "list" });
290 expect(found?.last4).toEqual("6789");
291
292 yield* stack.destroy();
293
294 const gone = yield* waitUntilGone(
295 deployed.externalAccount.account,
296 deployed.externalAccount.id,
297 );
298 expect(gone).toEqual("gone");
299
300 const after = yield* provider.list();
301 expect(
302 after.find((ea) => ea.id === deployed.externalAccount.id),
303 ).toBeUndefined();
304 }).pipe(logLevel),
305 {
306 tags: [
307 "provider:stripe",
308 "provider:stripe:account",
309 "provider:stripe:accountexternalaccount",
310 "live",
311 ],
312 timeout: 120_000,
313 },
314);