1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteAccountExternalAccount,
4 GetAccounts,
5 GetAccountExternalAccounts,
6 GetAccountExternalAccount,
7 CreateAccountExternalAccount,
8 UpdateAccountExternalAccount,
9 type Account as StripeAccount,
10 type BankAccount as StripeBankAccount,
11 type Card as StripeCard,
12 type ExternalAccount as StripeExternalAccount,
13} from "@distilled.cloud/stripe/stripe";
14import * as Effect from "effect/Effect";
15import { Unowned } from "../AdoptPolicy.ts";
16import { isResolved } from "../Diff.ts";
17import * as Provider from "../Provider.ts";
18import { Resource } from "../Resource.ts";
19import { tagRecord } from "../Tags.ts";
20import {
21 alchemyMetadataKeys,
22 createInternalMetadata,
23 diffMetadata,
24 hasAlchemyMetadata,
25 stripInternalMetadata,
26 toMetadata,
27} from "./Metadata.ts";
28import type { Providers } from "./Providers.ts";
29import { isMissingStripeResource } from "./missing.ts";
30
31const LIST_PAGE_SIZE = 100;
32const LIST_MAX_PAGES = 100;
33const LIST_CONCURRENCY = 10;
34
35/** Entity that holds a bank external account. */
36export type AccountExternalAccountHolderType = "individual" | "company";
37
38/** Bank account type. `futsu` / `toza` are Japan-only. */
39export type AccountExternalAccountBankType =
40 | "checking"
41 | "savings"
42 | "futsu"
43 | "toza";
44
45/** Discriminator for a bank vs card external account. */
46export type AccountExternalAccountObject = "bank_account" | "card";
47
48export interface AccountExternalAccountProps {
49 /**
50 * Id of the Connect account this external account is attached to
51 * (`acct_…`). Changing it replaces the external account.
52 */
53 account: string;
54 /**
55 * Single-use Stripe token (`btok_…` or `tok_…`) representing a bank
56 * account or debit card. Used on create; changing it replaces the
57 * external account. Tokens cannot be reused.
58 */
59 externalAccount: string;
60 /**
61 * When true, this becomes the default payout destination for its
62 * currency. The first external account in a currency is default
63 * automatically.
64 */
65 defaultForCurrency?: boolean;
66 /**
67 * Name of the person or business that owns the bank account (bank
68 * accounts only).
69 */
70 accountHolderName?: string;
71 /**
72 * Type of entity that holds the bank account (`individual` or
73 * `company`). Bank accounts only.
74 */
75 accountHolderType?: AccountExternalAccountHolderType;
76 /**
77 * Bank account type (`checking`, `savings`, `futsu`, or `toza`). Bank
78 * accounts only.
79 */
80 accountType?: AccountExternalAccountBankType;
81 /**
82 * Cardholder name (cards only).
83 */
84 name?: string;
85 /**
86 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
87 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
88 * not contain `:`.
89 */
90 metadata?: Record<string, string>;
91}
92
93export type AccountExternalAccount = Resource<
94 "Stripe.AccountExternalAccount",
95 AccountExternalAccountProps,
96 {
97 /** Stripe external account id (`ba_…` or `card_…`). */
98 id: string;
99 /** Id of the Connect account (`acct_…`). */
100 account: string;
101 /** Whether this is a bank account or a card. */
102 object: AccountExternalAccountObject;
103 /** Last four digits of the account or card number. */
104 last4: string;
105 /** Two-letter ISO country code of the bank or card. */
106 country: string;
107 /** Three-letter ISO currency code, if set. */
108 currency: string | undefined;
109 /** Bank name associated with the routing number (bank accounts only). */
110 bankName: string | undefined;
111 /** Routing transit number (bank accounts only). */
112 routingNumber: string | undefined;
113 /** Name of the person or business that owns the bank account. */
114 accountHolderName: string | undefined;
115 /** Type of entity that holds the bank account. */
116 accountHolderType: string | undefined;
117 /** Bank account type (`checking`, `savings`, …). */
118 accountType: string | undefined;
119 /** Whether this is the default external account for its currency. */
120 defaultForCurrency: boolean;
121 /** Status (`new`, `validated`, `errored`, …). */
122 status: string | undefined;
123 /** Card brand (cards only). */
124 brand: string | undefined;
125 /** Card funding type (`debit`, `credit`, …) (cards only). */
126 funding: string | undefined;
127 /** Cardholder name (cards only). */
128 name: string | undefined;
129 /** Fingerprint uniquely identifying the underlying number. */
130 fingerprint: string | undefined;
131 /** User-defined metadata (Alchemy ownership keys stripped). */
132 metadata: Record<string, string>;
133 },
134 never,
135 Providers
136>;
137
138/**
139 * A Stripe Account External Account — a bank account or debit card
140 * attached to a Connect account as a payout destination. Metadata,
141 * account-holder details, and `defaultForCurrency` update in place.
142 * Changing `account` or the `externalAccount` token replaces it.
143 * Destroy deletes it.
144 *
145 * @see https://docs.stripe.com/api/external_accounts
146 *
147 * ### Creating an External Account
148 * **Example:** Bank account token on a Connect account
149 * ```typescript
150 * const payout = yield* Stripe.AccountExternalAccount("payout-bank", {
151 * account: connected.id,
152 * externalAccount: bankToken.id,
153 * accountHolderName: "Jenny Rosen",
154 * accountHolderType: "individual",
155 * metadata: { purpose: "payouts" },
156 * });
157 * ```
158 *
159 * **Example:** Debit card token
160 * ```typescript
161 * const payout = yield* Stripe.AccountExternalAccount("payout-card", {
162 * account: connected.id,
163 * externalAccount: debitToken.id,
164 * defaultForCurrency: true,
165 * });
166 * ```
167 *
168 * ### Updating an External Account
169 * **Example:** Holder name, default, and metadata
170 * ```typescript
171 * const payout = yield* Stripe.AccountExternalAccount("payout-bank", {
172 * account: connected.id,
173 * externalAccount: bankToken.id,
174 * accountHolderName: "Alchemy Tester",
175 * accountHolderType: "company",
176 * defaultForCurrency: true,
177 * metadata: { purpose: "payroll" },
178 * });
179 * ```
180 *
181 * @resource
182 * @product Account
183 */
184export const AccountExternalAccount = Resource<AccountExternalAccount>(
185 "Stripe.AccountExternalAccount",
186);
187
188type AccountExternalAccountAttributes = AccountExternalAccount["Attributes"];
189
190const userMetadata = (
191 metadata: Record<string, string | undefined> | null | undefined,
192): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
193
194const isBankAccount = (
195 value: StripeExternalAccount,
196): value is StripeBankAccount => value.object === "bank_account";
197
198const parentAccountId = (
199 ea: StripeExternalAccount,
200 fallback: string,
201): string => {
202 const raw = ea.account;
203 if (typeof raw === "string" && raw.length > 0) return raw;
204 if (raw !== null && raw !== undefined && typeof raw === "object") {
205 const id = (raw as { id?: unknown }).id;
206 if (typeof id === "string" && id.length > 0) return id;
207 }
208 return fallback;
209};
210
211const toAttrs = (
212 fallbackAccount: string,
213 ea: StripeExternalAccount,
214): AccountExternalAccountAttributes => {
215 const account = parentAccountId(ea, fallbackAccount);
216 const metadata = userMetadata(ea.metadata);
217 if (!isBankAccount(ea)) {
218 const card: StripeCard = ea;
219 return {
220 id: card.id,
221 account,
222 object: "card",
223 last4: card.last4,
224 country: card.country ?? "",
225 currency: card.currency ?? undefined,
226 bankName: undefined,
227 routingNumber: undefined,
228 accountHolderName: undefined,
229 accountHolderType: undefined,
230 accountType: undefined,
231 defaultForCurrency: card.default_for_currency ?? false,
232 status: card.status ?? undefined,
233 brand: card.brand,
234 funding: card.funding,
235 name: card.name ?? undefined,
236 fingerprint: card.fingerprint ?? undefined,
237 metadata,
238 };
239 }
240 return {
241 id: ea.id,
242 account,
243 object: "bank_account",
244 last4: ea.last4,
245 country: ea.country,
246 currency: ea.currency,
247 bankName: ea.bank_name ?? undefined,
248 routingNumber: ea.routing_number ?? undefined,
249 accountHolderName: ea.account_holder_name ?? undefined,
250 accountHolderType: ea.account_holder_type ?? undefined,
251 accountType: ea.account_type ?? undefined,
252 defaultForCurrency: ea.default_for_currency ?? false,
253 status: ea.status,
254 brand: undefined,
255 funding: undefined,
256 name: undefined,
257 fingerprint: ea.fingerprint ?? undefined,
258 metadata,
259 };
260};
261
262const isMissing = isMissingStripeResource;
263
264const getById = (account: string, id: string) =>
265 GetAccountExternalAccount({ account, id }).pipe(
266 Effect.catchIf(isMissing, () => Effect.succeed(undefined)),
267 );
268
269const listExternalAccounts = Effect.fn(function* (account: string) {
270 const accounts: StripeExternalAccount[] = [];
271 let startingAfter: string | undefined;
272 for (let page = 0; page < LIST_MAX_PAGES; page++) {
273 const response = yield* GetAccountExternalAccounts({
274 account,
275 limit: LIST_PAGE_SIZE,
276 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
277 }).pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
278 if (response === undefined) {
279 break;
280 }
281 accounts.push(...response.data);
282 if (!response.has_more || response.data.length === 0) {
283 break;
284 }
285 startingAfter = response.data[response.data.length - 1]?.id;
286 if (startingAfter === undefined) {
287 break;
288 }
289 }
290 return accounts;
291});
292
293const listAllConnectAccounts = Effect.fn(function* () {
294 const accounts: StripeAccount[] = [];
295 let startingAfter: string | undefined;
296 for (let page = 0; page < LIST_MAX_PAGES; page++) {
297 const response = yield* GetAccounts({
298 limit: LIST_PAGE_SIZE,
299 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
300 }).pipe(
301 Effect.catchIf(
302 (e) =>
303 e._tag === "InvalidRequestError" ||
304 e._tag === "Forbidden" ||
305 e._tag === "Unauthorized",
306 () => Effect.succeed(undefined),
307 ),
308 );
309 if (response === undefined) {
310 break;
311 }
312 accounts.push(...response.data);
313 if (!response.has_more || response.data.length === 0) {
314 break;
315 }
316 startingAfter = response.data[response.data.length - 1]?.id;
317 if (startingAfter === undefined) {
318 break;
319 }
320 }
321 return accounts;
322});
323
324const findByAlchemyId = Effect.fn(function* (
325 logicalId: string,
326 account?: string,
327) {
328 const search = Effect.fn(function* (accountId: string) {
329 const eas = yield* listExternalAccounts(accountId);
330 const matches: StripeExternalAccount[] = [];
331 for (const ea of eas) {
332 if (yield* hasAlchemyMetadata(logicalId, tagRecord(ea.metadata))) {
333 matches.push(ea);
334 }
335 }
336 return matches[0];
337 });
338 if (account !== undefined) {
339 return yield* search(account);
340 }
341 const accounts = yield* listAllConnectAccounts();
342 for (const acct of accounts) {
343 const found = yield* search(acct.id);
344 if (found !== undefined) return found;
345 }
346 return undefined;
347});
348
349const observe = Effect.fn(function* (input: {
350 account?: string;
351 id?: string;
352 logicalId: string;
353}) {
354 if (input.account !== undefined && input.id !== undefined) {
355 const byId = yield* getById(input.account, input.id);
356 if (byId !== undefined) return byId;
357 }
358 return yield* findByAlchemyId(input.logicalId, input.account);
359});
360
361const desiredMetadata = Effect.fn(function* (
362 id: string,
363 metadata: Record<string, string> | undefined,
364) {
365 return {
366 ...toMetadata(metadata),
367 ...(yield* createInternalMetadata(id)),
368 };
369});
370
371const shouldReplace = (
372 news: AccountExternalAccountProps,
373 olds: AccountExternalAccountProps | undefined,
374 output: AccountExternalAccountAttributes | undefined,
375): boolean => {
376 if (output === undefined) return false;
377 if (news.account !== output.account) return true;
378 if (
379 olds !== undefined &&
380 olds.externalAccount !== undefined &&
381 news.externalAccount !== olds.externalAccount
382 ) {
383 return true;
384 }
385 return false;
386};
387
388export const AccountExternalAccountProvider = () =>
389 Provider.succeed(AccountExternalAccount, {
390 stables: [
391 "id",
392 "account",
393 "object",
394 "last4",
395 "country",
396 "currency",
397 "bankName",
398 "routingNumber",
399 "fingerprint",
400 "brand",
401 "funding",
402 ],
403 nuke: { dependsOn: ["Stripe.Account"] },
404
405 diff: Effect.fn(function* ({ news, olds, output }) {
406 if (!isResolved(news)) return undefined;
407 if (shouldReplace(news, olds, output)) {
408 return { action: "replace" } as const;
409 }
410 return undefined;
411 }),
412
413 read: Effect.fn(function* ({ id, output, olds }) {
414 const account =
415 output?.account ??
416 (typeof olds?.account === "string" ? olds.account : undefined);
417 const existing = yield* observe({
418 account,
419 id: output?.id,
420 logicalId: id,
421 });
422 if (existing === undefined || account === undefined) return undefined;
423 const attrs = toAttrs(account, existing);
424 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
425 ? attrs
426 : Unowned(attrs);
427 }),
428
429 list: Effect.fn(function* () {
430 const accounts = yield* listAllConnectAccounts();
431 const rows = yield* Effect.forEach(
432 accounts,
433 (account) =>
434 listExternalAccounts(account.id).pipe(
435 Effect.map((eas) =>
436 eas
437 .filter(
438 (ea) =>
439 tagRecord(ea.metadata)[alchemyMetadataKeys.stack] !==
440 undefined,
441 )
442 .map((ea) => toAttrs(account.id, ea)),
443 ),
444 ),
445 { concurrency: LIST_CONCURRENCY },
446 );
447 return rows.flat();
448 }),
449
450 reconcile: Effect.fn(function* ({ id, news, olds, output, instanceId }) {
451 const metadata = yield* desiredMetadata(id, news.metadata);
452 const desiredHolderName = news.accountHolderName ?? "";
453 const desiredHolderType = news.accountHolderType ?? "";
454 const desiredAccountType = news.accountType;
455 const desiredName = news.name ?? "";
456 const desiredDefault = news.defaultForCurrency;
457
458 let current = yield* observe({
459 account: news.account,
460 id: output?.id,
461 logicalId: id,
462 });
463 if (
464 current !== undefined &&
465 shouldReplace(news, olds, toAttrs(news.account, current))
466 ) {
467 current = undefined;
468 }
469
470 if (current === undefined) {
471 current = yield* CreateAccountExternalAccount({
472 account: news.account,
473 external_account: news.externalAccount,
474 metadata,
475 ...(desiredDefault !== undefined
476 ? { default_for_currency: desiredDefault }
477 : {}),
478 }).pipe(
479 withRequestOptions({
480 idempotencyKey: `alchemy-account-external-account-${instanceId}`,
481 }),
482 Effect.catchIf(
483 (e) => e._tag === "InvalidRequestError" || e._tag === "Conflict",
484 (e) =>
485 observe({
486 account: news.account,
487 logicalId: id,
488 }).pipe(
489 Effect.flatMap((found) =>
490 found !== undefined ? Effect.succeed(found) : Effect.fail(e),
491 ),
492 ),
493 ),
494 );
495 }
496
497 const attrs = toAttrs(news.account, current);
498 const observedMetadata = tagRecord(current.metadata);
499 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
500 const metadataChanged = upsert.length > 0 || removed.length > 0;
501 const defaultChanged =
502 desiredDefault !== undefined &&
503 attrs.defaultForCurrency !== desiredDefault;
504 const holderNameChanged =
505 attrs.object === "bank_account" &&
506 (attrs.accountHolderName ?? "") !== desiredHolderName;
507 const holderTypeChanged =
508 attrs.object === "bank_account" &&
509 (attrs.accountHolderType ?? "") !== desiredHolderType;
510 const accountTypeChanged =
511 attrs.object === "bank_account" &&
512 desiredAccountType !== undefined &&
513 (attrs.accountType ?? "") !== desiredAccountType;
514 const nameChanged =
515 attrs.object === "card" && (attrs.name ?? "") !== desiredName;
516
517 if (
518 !metadataChanged &&
519 !defaultChanged &&
520 !holderNameChanged &&
521 !holderTypeChanged &&
522 !accountTypeChanged &&
523 !nameChanged
524 ) {
525 return attrs;
526 }
527
528 const updated = yield* UpdateAccountExternalAccount({
529 account: news.account,
530 id: current.id,
531 ...(holderNameChanged
532 ? { account_holder_name: desiredHolderName }
533 : {}),
534 ...(holderTypeChanged
535 ? {
536 account_holder_type:
537 desiredHolderType === ""
538 ? ""
539 : (desiredHolderType as AccountExternalAccountHolderType),
540 }
541 : {}),
542 ...(accountTypeChanged && desiredAccountType !== undefined
543 ? { account_type: desiredAccountType }
544 : {}),
545 ...(nameChanged ? { name: desiredName } : {}),
546 ...(defaultChanged && desiredDefault !== undefined
547 ? { default_for_currency: desiredDefault }
548 : {}),
549 ...(metadataChanged
550 ? {
551 metadata: {
552 ...Object.fromEntries(
553 upsert.map((tag) => [tag.Key, tag.Value]),
554 ),
555 ...Object.fromEntries(removed.map((key) => [key, ""])),
556 },
557 }
558 : {}),
559 });
560 return toAttrs(news.account, updated);
561 }),
562
563 delete: Effect.fn(function* ({ output }) {
564 yield* DeleteAccountExternalAccount({
565 account: output.account,
566 id: output.id,
567 }).pipe(Effect.catchIf(isMissing, () => Effect.void));
568 }),
569 });