1import {
2 withRequestOptions,
3 type StripeOpError,
4} from "@distilled.cloud/stripe";
5import {
6 DeleteAccountPerson,
7 GetAccounts,
8 GetAccountPersons,
9 GetAccountPerson,
10 CreateAccountPerson,
11 UpdateAccountPerson,
12 type Account as StripeAccount,
13 type Person as StripePerson,
14 type PersonRelationship,
15 type CreateAccountPersonRequestRelationship,
16} from "@distilled.cloud/stripe/stripe";
17import * as Effect from "effect/Effect";
18import { Unowned } from "../AdoptPolicy.ts";
19import { isResolved } from "../Diff.ts";
20import * as Provider from "../Provider.ts";
21import { Resource } from "../Resource.ts";
22import { tagRecord } from "../Tags.ts";
23import {
24 alchemyMetadataKeys,
25 createInternalMetadata,
26 diffMetadata,
27 hasAlchemyMetadata,
28 stripInternalMetadata,
29 toMetadata,
30} from "./Metadata.ts";
31import type { Providers } from "./Providers.ts";
32import { isMissingStripeResource } from "./missing.ts";
33
34const LIST_PAGE_SIZE = 100;
35const LIST_MAX_PAGES = 100;
36const LIST_CONCURRENCY = 10;
37
38export interface AccountPersonRelationship {
39 /**
40 * Whether the person is the authorizer of the account's representative.
41 */
42 authorizer?: boolean;
43 /**
44 * Whether the person is a director of the account's legal entity.
45 */
46 director?: boolean;
47 /**
48 * Whether the person has significant responsibility to control, manage,
49 * or direct the organization.
50 */
51 executive?: boolean;
52 /**
53 * Whether the person is the legal guardian of the account's
54 * representative.
55 */
56 legalGuardian?: boolean;
57 /**
58 * Whether the person is an owner of the account's legal entity.
59 */
60 owner?: boolean;
61 /**
62 * The percent owned by the person of the account's legal entity.
63 */
64 percentOwnership?: number;
65 /**
66 * Whether the person is authorized as the primary representative of the
67 * account. There can only be one representative at a time.
68 */
69 representative?: boolean;
70 /**
71 * The person's title (e.g. CEO, Support Engineer).
72 */
73 title?: string;
74}
75
76export interface AccountPersonProps {
77 /**
78 * Id of the Connect account this person belongs to (`acct_…`).
79 * Changing it replaces the person.
80 */
81 account: string;
82 /**
83 * The person's first name.
84 */
85 firstName?: string;
86 /**
87 * The person's last name.
88 */
89 lastName?: string;
90 /**
91 * The person's email address.
92 */
93 email?: string;
94 /**
95 * The person's phone number.
96 */
97 phone?: string;
98 /**
99 * The relationship this person has with the account's legal entity.
100 */
101 relationship?: AccountPersonRelationship;
102 /**
103 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
104 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
105 * not contain `:`.
106 */
107 metadata?: Record<string, string>;
108}
109
110export type AccountPerson = Resource<
111 "Stripe.AccountPerson",
112 AccountPersonProps,
113 {
114 /** Stripe person id (`person_…`). */
115 id: string;
116 /** Id of the Connect account this person belongs to (`acct_…`). */
117 account: string;
118 /** The person's first name, if set. */
119 firstName: string | undefined;
120 /** The person's last name, if set. */
121 lastName: string | undefined;
122 /** The person's email address, if set. */
123 email: string | undefined;
124 /** The person's phone number, if set. */
125 phone: string | undefined;
126 /** Relationship to the account's legal entity, if set. */
127 relationship: AccountPersonRelationship | undefined;
128 /** User-defined metadata (Alchemy ownership keys stripped). */
129 metadata: Record<string, string>;
130 /** Unix timestamp when the person was created. */
131 created: number;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A Stripe Person on a Connect account — a director, executive, owner, or
139 * representative of the account's legal entity. Name, email, phone,
140 * relationship, and metadata update in place. Changing `account` replaces
141 * the person. Destroy deletes the person (the account opener cannot be
142 * deleted).
143 *
144 * @see https://docs.stripe.com/api/persons
145 *
146 * ### Creating a Person
147 * **Example:** Director on a Connect account
148 * ```typescript
149 * const person = yield* Stripe.AccountPerson("cfo", {
150 * account: account.id,
151 * firstName: "Jane",
152 * lastName: "Diaz",
153 * email: "jane.diaz@example.com",
154 * relationship: { director: true, title: "CFO" },
155 * });
156 * ```
157 *
158 * **Example:** Owner with metadata
159 * ```typescript
160 * const person = yield* Stripe.AccountPerson("owner", {
161 * account: account.id,
162 * firstName: "Alex",
163 * lastName: "Kim",
164 * relationship: { owner: true, percentOwnership: 25 },
165 * metadata: { role: "beneficial-owner" },
166 * });
167 * ```
168 *
169 * ### Updating a Person
170 * **Example:** Change email and title
171 * ```typescript
172 * const person = yield* Stripe.AccountPerson("cfo", {
173 * account: account.id,
174 * firstName: "Jane",
175 * lastName: "Diaz",
176 * email: "jane.diaz+updated@example.com",
177 * relationship: { director: true, title: "COO" },
178 * });
179 * ```
180 *
181 * @resource
182 * @product Account
183 */
184export const AccountPerson = Resource<AccountPerson>("Stripe.AccountPerson");
185
186type AccountPersonAttributes = AccountPerson["Attributes"];
187
188const userMetadata = (
189 metadata: Record<string, string | undefined> | null | undefined,
190): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
191
192const fromRelationship = (
193 relationship: PersonRelationship | undefined,
194): AccountPersonRelationship | undefined => {
195 if (relationship === undefined) return undefined;
196 const value: AccountPersonRelationship = {
197 ...(relationship.authorizer != null
198 ? { authorizer: relationship.authorizer }
199 : {}),
200 ...(relationship.director != null
201 ? { director: relationship.director }
202 : {}),
203 ...(relationship.executive != null
204 ? { executive: relationship.executive }
205 : {}),
206 ...(relationship.legal_guardian != null
207 ? { legalGuardian: relationship.legal_guardian }
208 : {}),
209 ...(relationship.owner != null ? { owner: relationship.owner } : {}),
210 ...(relationship.percent_ownership != null
211 ? { percentOwnership: relationship.percent_ownership }
212 : {}),
213 ...(relationship.representative != null
214 ? { representative: relationship.representative }
215 : {}),
216 ...(relationship.title != null ? { title: relationship.title } : {}),
217 };
218 return Object.keys(value).length === 0 ? undefined : value;
219};
220
221const toRelationshipWire = (
222 relationship: AccountPersonRelationship,
223): CreateAccountPersonRequestRelationship => ({
224 ...(relationship.authorizer !== undefined
225 ? { authorizer: relationship.authorizer }
226 : {}),
227 ...(relationship.director !== undefined
228 ? { director: relationship.director }
229 : {}),
230 ...(relationship.executive !== undefined
231 ? { executive: relationship.executive }
232 : {}),
233 ...(relationship.legalGuardian !== undefined
234 ? { legal_guardian: relationship.legalGuardian }
235 : {}),
236 ...(relationship.owner !== undefined ? { owner: relationship.owner } : {}),
237 ...(relationship.percentOwnership !== undefined
238 ? { percent_ownership: relationship.percentOwnership }
239 : {}),
240 ...(relationship.representative !== undefined
241 ? { representative: relationship.representative }
242 : {}),
243 ...(relationship.title !== undefined ? { title: relationship.title } : {}),
244});
245
246const sameFlag = (
247 desired: boolean | undefined,
248 observed: boolean | null | undefined,
249): boolean => {
250 if (desired === undefined) return true;
251 return (observed ?? false) === desired;
252};
253
254const relationshipChanged = (
255 desired: AccountPersonRelationship | undefined,
256 observed: PersonRelationship | undefined,
257): boolean => {
258 if (desired === undefined) return false;
259 if (!sameFlag(desired.authorizer, observed?.authorizer)) return true;
260 if (!sameFlag(desired.director, observed?.director)) return true;
261 if (!sameFlag(desired.executive, observed?.executive)) return true;
262 if (!sameFlag(desired.legalGuardian, observed?.legal_guardian)) return true;
263 if (!sameFlag(desired.owner, observed?.owner)) return true;
264 if (
265 desired.percentOwnership !== undefined &&
266 observed?.percent_ownership !== desired.percentOwnership
267 ) {
268 return true;
269 }
270 if (!sameFlag(desired.representative, observed?.representative)) return true;
271 if (
272 desired.title !== undefined &&
273 (observed?.title ?? "") !== desired.title
274 ) {
275 return true;
276 }
277 return false;
278};
279
280const toAttrs = (
281 account: string,
282 person: StripePerson,
283): AccountPersonAttributes => ({
284 id: person.id,
285 account: person.account ?? account,
286 firstName: person.first_name ?? undefined,
287 lastName: person.last_name ?? undefined,
288 email: person.email ?? undefined,
289 phone: person.phone ?? undefined,
290 relationship: fromRelationship(person.relationship),
291 metadata: userMetadata(person.metadata),
292 created: person.created,
293});
294
295const isMissing = isMissingStripeResource;
296
297const isSkippedListError = (error: StripeOpError): boolean =>
298 isMissing(error) ||
299 error._tag === "InvalidRequestError" ||
300 error._tag === "Forbidden";
301
302const getById = (account: string, person: string) =>
303 GetAccountPerson({ account, person }).pipe(
304 Effect.catchIf(isMissing, () => Effect.succeed(undefined)),
305 );
306
307const listPersons = Effect.fn(function* (account: string) {
308 const people: StripePerson[] = [];
309 let startingAfter: string | undefined;
310 for (let page = 0; page < LIST_MAX_PAGES; page++) {
311 const response = yield* GetAccountPersons({
312 account,
313 limit: LIST_PAGE_SIZE,
314 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
315 }).pipe(
316 Effect.catchIf(isSkippedListError, () => Effect.succeed(undefined)),
317 );
318 if (response === undefined) {
319 break;
320 }
321 people.push(...response.data);
322 if (!response.has_more || response.data.length === 0) {
323 break;
324 }
325 startingAfter = response.data[response.data.length - 1]?.id;
326 if (startingAfter === undefined) {
327 break;
328 }
329 }
330 return people;
331});
332
333const listAllAccounts = Effect.fn(function* () {
334 const accounts: StripeAccount[] = [];
335 let startingAfter: string | undefined;
336 for (let page = 0; page < LIST_MAX_PAGES; page++) {
337 const response = yield* GetAccounts({
338 limit: LIST_PAGE_SIZE,
339 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
340 });
341 accounts.push(...response.data);
342 if (!response.has_more || response.data.length === 0) {
343 break;
344 }
345 startingAfter = response.data[response.data.length - 1]?.id;
346 if (startingAfter === undefined) {
347 break;
348 }
349 }
350 return accounts;
351});
352
353const findByAlchemyIdOnAccount = Effect.fn(function* (
354 account: string,
355 id: string,
356) {
357 const people = yield* listPersons(account);
358 const matches: StripePerson[] = [];
359 for (const person of people) {
360 if (yield* hasAlchemyMetadata(id, tagRecord(person.metadata))) {
361 matches.push(person);
362 }
363 }
364 matches.sort((a, b) => b.created - a.created);
365 return matches[0];
366});
367
368const findByAlchemyId = Effect.fn(function* (id: string) {
369 const accounts = yield* listAllAccounts();
370 const matches = yield* Effect.forEach(
371 accounts,
372 (account) => findByAlchemyIdOnAccount(account.id, id),
373 { concurrency: LIST_CONCURRENCY },
374 );
375 const found = matches.filter(
376 (person): person is StripePerson => person !== undefined,
377 );
378 found.sort((a, b) => b.created - a.created);
379 return found[0];
380});
381
382const observe = Effect.fn(function* (input: {
383 account?: string;
384 id?: string;
385 logicalId: string;
386}) {
387 if (input.account !== undefined && input.id !== undefined) {
388 const byId = yield* getById(input.account, input.id);
389 if (byId !== undefined) return byId;
390 }
391 if (input.account !== undefined) {
392 const onAccount = yield* findByAlchemyIdOnAccount(
393 input.account,
394 input.logicalId,
395 );
396 if (onAccount !== undefined) return onAccount;
397 }
398 return yield* findByAlchemyId(input.logicalId);
399});
400
401const desiredMetadata = Effect.fn(function* (
402 id: string,
403 metadata: Record<string, string> | undefined,
404) {
405 return {
406 ...toMetadata(metadata),
407 ...(yield* createInternalMetadata(id)),
408 };
409});
410
411const shouldReplace = (
412 news: AccountPersonProps,
413 output: AccountPersonAttributes | undefined,
414): boolean => {
415 if (output === undefined) return false;
416 return news.account !== output.account;
417};
418
419export const AccountPersonProvider = () =>
420 Provider.succeed(AccountPerson, {
421 stables: ["id", "account", "created"],
422 nuke: { dependsOn: ["Stripe.Account"] },
423
424 diff: Effect.fn(function* ({ news, output }) {
425 if (!isResolved(news)) return undefined;
426 if (shouldReplace(news, output)) {
427 return { action: "replace" } as const;
428 }
429 return undefined;
430 }),
431
432 read: Effect.fn(function* ({ id, output, olds }) {
433 const account =
434 output?.account ??
435 (typeof olds?.account === "string" ? olds.account : undefined);
436 const existing = yield* observe({
437 account,
438 id: output?.id,
439 logicalId: id,
440 });
441 if (existing === undefined) return undefined;
442 const resolvedAccount = existing.account ?? account;
443 if (resolvedAccount === undefined) return undefined;
444 const attrs = toAttrs(resolvedAccount, existing);
445 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
446 ? attrs
447 : Unowned(attrs);
448 }),
449
450 list: Effect.fn(function* () {
451 const accounts = yield* listAllAccounts();
452 const rows = yield* Effect.forEach(
453 accounts,
454 (account) =>
455 listPersons(account.id).pipe(
456 Effect.map((people) =>
457 people
458 .filter(
459 (person) =>
460 tagRecord(person.metadata)[alchemyMetadataKeys.stack] !==
461 undefined,
462 )
463 .map((person) => toAttrs(account.id, person)),
464 ),
465 ),
466 { concurrency: LIST_CONCURRENCY },
467 );
468 return rows.flat();
469 }),
470
471 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
472 const metadata = yield* desiredMetadata(id, news.metadata);
473 const desiredFirstName = news.firstName ?? "";
474 const desiredLastName = news.lastName ?? "";
475 const desiredEmail = news.email ?? "";
476 const desiredPhone = news.phone ?? "";
477
478 let current: StripePerson | undefined = yield* observe({
479 account: news.account,
480 id: output?.id,
481 logicalId: id,
482 });
483 if (
484 current !== undefined &&
485 shouldReplace(news, toAttrs(news.account, current))
486 ) {
487 current = undefined;
488 }
489
490 if (current === undefined) {
491 current = yield* CreateAccountPerson({
492 account: news.account,
493 ...(desiredFirstName.length > 0
494 ? { first_name: desiredFirstName }
495 : {}),
496 ...(desiredLastName.length > 0 ? { last_name: desiredLastName } : {}),
497 ...(desiredEmail.length > 0 ? { email: desiredEmail } : {}),
498 ...(desiredPhone.length > 0 ? { phone: desiredPhone } : {}),
499 ...(news.relationship !== undefined
500 ? { relationship: toRelationshipWire(news.relationship) }
501 : {}),
502 metadata,
503 }).pipe(
504 withRequestOptions({
505 idempotencyKey: `alchemy-account-person-${instanceId}`,
506 }),
507 );
508 }
509
510 const observedMetadata = tagRecord(current.metadata);
511 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
512 const metadataChanged = upsert.length > 0 || removed.length > 0;
513 const firstNameChanged = (current.first_name ?? "") !== desiredFirstName;
514 const lastNameChanged = (current.last_name ?? "") !== desiredLastName;
515 const emailChanged = (current.email ?? "") !== desiredEmail;
516 const phoneChanged = (current.phone ?? "") !== desiredPhone;
517 const relChanged = relationshipChanged(
518 news.relationship,
519 current.relationship,
520 );
521
522 if (
523 !firstNameChanged &&
524 !lastNameChanged &&
525 !emailChanged &&
526 !phoneChanged &&
527 !relChanged &&
528 !metadataChanged
529 ) {
530 return toAttrs(news.account, current);
531 }
532
533 const updated = yield* UpdateAccountPerson({
534 account: news.account,
535 person: current.id,
536 ...(firstNameChanged ? { first_name: desiredFirstName } : {}),
537 ...(lastNameChanged ? { last_name: desiredLastName } : {}),
538 ...(emailChanged ? { email: desiredEmail } : {}),
539 ...(phoneChanged ? { phone: desiredPhone } : {}),
540 ...(relChanged && news.relationship !== undefined
541 ? { relationship: toRelationshipWire(news.relationship) }
542 : {}),
543 ...(metadataChanged
544 ? {
545 metadata: {
546 ...Object.fromEntries(
547 upsert.map((tag) => [tag.Key, tag.Value]),
548 ),
549 ...Object.fromEntries(removed.map((key) => [key, ""])),
550 },
551 }
552 : {}),
553 });
554 return toAttrs(news.account, updated);
555 }),
556
557 delete: Effect.fn(function* ({ output }) {
558 yield* DeleteAccountPerson({
559 account: output.account,
560 person: output.id,
561 }).pipe(Effect.catchIf(isMissing, () => Effect.void));
562 }),
563 });