1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteCustomerTaxIds,
4 GetCustomers,
5 GetCustomerTaxIds,
6 GetCustomerTaxIdsById,
7 GetTaxIdsById,
8 CreateCustomerTaxIds,
9 type Customer as StripeCustomer,
10 type TaxId as StripeTaxId,
11 type TaxIdCustomer,
12 type TaxIdType,
13 type TaxIdVerificationStatus,
14} from "@distilled.cloud/stripe/stripe";
15import * as Effect from "effect/Effect";
16import { isResolved } from "../Diff.ts";
17import * as Provider from "../Provider.ts";
18import { Resource } from "../Resource.ts";
19import { tagRecord } from "../Tags.ts";
20import { alchemyMetadataKeys } from "./Metadata.ts";
21import type { Providers } from "./Providers.ts";
22import { isMissingStripeResource } from "./missing.ts";
23
24const LIST_PAGE_SIZE = 100;
25const LIST_MAX_PAGES = 100;
26const LIST_CONCURRENCY = 10;
27
28/** Stripe tax ID type used when creating a customer tax ID (e.g. `eu_vat`). */
29export type CustomerTaxIdType = Exclude<TaxIdType, "unknown">;
30
31export interface CustomerTaxIdProps {
32 /**
33 * Id of the Stripe Customer to attach the tax ID to (`cus_…`).
34 * Changing it replaces the tax ID.
35 */
36 customer: string;
37 /**
38 * Type of the tax ID, such as `eu_vat`, `us_ein`, or `gb_vat`.
39 * Changing it replaces the tax ID.
40 */
41 type: CustomerTaxIdType;
42 /**
43 * Value of the tax ID (the VAT number, EIN, etc.). Changing it
44 * replaces the tax ID.
45 */
46 value: string;
47}
48
49export type CustomerTaxId = Resource<
50 "Stripe.CustomerTaxId",
51 CustomerTaxIdProps,
52 {
53 /** Stripe tax ID object id (`txi_…`). */
54 id: string;
55 /** Id of the customer this tax ID is attached to (`cus_…`). */
56 customer: string;
57 /** Type of the tax ID (e.g. `eu_vat`). */
58 type: TaxIdType;
59 /** Value of the tax ID. */
60 value: string;
61 /** Two-letter ISO country code inferred from the tax ID, if known. */
62 country: string | undefined;
63 /** Verification status, if Stripe has attempted verification. */
64 verificationStatus: TaxIdVerificationStatus | undefined;
65 /** Unix timestamp when the tax ID was created. */
66 created: number;
67 /** Whether the tax ID exists in live mode. */
68 livemode: boolean;
69 },
70 never,
71 Providers
72>;
73
74/**
75 * A Stripe Customer Tax ID — a tax identifier (VAT, EIN, GST, …) attached
76 * to a Customer and printed on invoices. Existence-only: type and value
77 * cannot be updated in place; changing `customer`, `type`, or `value`
78 * replaces the tax ID. Destroy deletes it.
79 *
80 * Customer tax IDs have no metadata of their own. Ownership for
81 * account-wide `list()` (nuke) is inferred from the parent Customer's
82 * Alchemy metadata.
83 *
84 * @see https://docs.stripe.com/api/customer_tax_ids
85 *
86 * ### Creating a Customer Tax ID
87 * **Example:** Attach an EU VAT number
88 * ```typescript
89 * const customer = yield* Stripe.Customer("alice", {
90 * email: "alice@example.com",
91 * });
92 * const vat = yield* Stripe.CustomerTaxId("alice-vat", {
93 * customer: customer.id,
94 * type: "eu_vat",
95 * value: "DE123456789",
96 * });
97 * ```
98 *
99 * ### Replacing a Tax ID
100 * **Example:** Point at a different VAT number
101 * ```typescript
102 * const vat = yield* Stripe.CustomerTaxId("alice-vat", {
103 * customer: customer.id,
104 * type: "eu_vat",
105 * value: "DE000000000",
106 * });
107 * ```
108 *
109 * @resource
110 * @product Customer
111 */
112export const CustomerTaxId = Resource<CustomerTaxId>("Stripe.CustomerTaxId");
113
114type CustomerTaxIdAttributes = CustomerTaxId["Attributes"];
115
116const customerIdOf = (customer: TaxIdCustomer | null): string | undefined => {
117 if (customer == null) return undefined;
118 if (typeof customer === "string") return customer;
119 return customer.id;
120};
121
122const toAttrs = (
123 customer: string,
124 taxId: StripeTaxId,
125): CustomerTaxIdAttributes => ({
126 id: taxId.id,
127 customer: customerIdOf(taxId.customer) ?? customer,
128 type: taxId.type,
129 value: taxId.value,
130 country: taxId.country ?? undefined,
131 verificationStatus: taxId.verification?.status,
132 created: taxId.created,
133 livemode: taxId.livemode,
134});
135
136const isMissing = isMissingStripeResource;
137
138const getById = (id: string, customer?: string) => {
139 if (customer !== undefined) {
140 return GetCustomerTaxIdsById({ customer, id }).pipe(
141 Effect.catchIf(isMissing, () => Effect.succeed(undefined)),
142 );
143 }
144 return GetTaxIdsById({ id }).pipe(
145 Effect.catchIf(isMissing, () => Effect.succeed(undefined)),
146 );
147};
148
149const listTaxIds = Effect.fn(function* (customer: string) {
150 const taxIds: StripeTaxId[] = [];
151 let startingAfter: string | undefined;
152 for (let page = 0; page < LIST_MAX_PAGES; page++) {
153 const response = yield* GetCustomerTaxIds({
154 customer,
155 limit: LIST_PAGE_SIZE,
156 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
157 }).pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
158 if (response === undefined) {
159 break;
160 }
161 taxIds.push(...response.data);
162 if (!response.has_more || response.data.length === 0) {
163 break;
164 }
165 startingAfter = response.data[response.data.length - 1]?.id;
166 if (startingAfter === undefined) {
167 break;
168 }
169 }
170 return taxIds;
171});
172
173const findByTypeValue = Effect.fn(function* (
174 customer: string,
175 type: string,
176 value: string,
177) {
178 const taxIds = yield* listTaxIds(customer);
179 const matches = taxIds.filter(
180 (taxId) => taxId.type === type && taxId.value === value,
181 );
182 matches.sort((a, b) => b.created - a.created);
183 return matches[0];
184});
185
186const listAllCustomers = Effect.fn(function* () {
187 const customers: StripeCustomer[] = [];
188 let startingAfter: string | undefined;
189 for (let page = 0; page < LIST_MAX_PAGES; page++) {
190 const response = yield* GetCustomers({
191 limit: LIST_PAGE_SIZE,
192 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
193 });
194 customers.push(...response.data);
195 if (!response.has_more || response.data.length === 0) {
196 break;
197 }
198 startingAfter = response.data[response.data.length - 1]?.id;
199 if (startingAfter === undefined) {
200 break;
201 }
202 }
203 return customers;
204});
205
206const listAlchemyCustomers = Effect.fn(function* () {
207 const customers = yield* listAllCustomers();
208 return customers.filter(
209 (customer) =>
210 tagRecord(customer.metadata)[alchemyMetadataKeys.stack] !== undefined,
211 );
212});
213
214const observe = Effect.fn(function* (input: {
215 id?: string;
216 customer?: string;
217 type?: string;
218 value?: string;
219}) {
220 if (input.id !== undefined) {
221 const byId = yield* getById(input.id, input.customer);
222 if (byId !== undefined) return byId;
223 }
224 if (
225 input.customer !== undefined &&
226 input.type !== undefined &&
227 input.value !== undefined
228 ) {
229 return yield* findByTypeValue(input.customer, input.type, input.value);
230 }
231 return undefined;
232});
233
234const shouldReplace = (
235 news: CustomerTaxIdProps,
236 output: CustomerTaxIdAttributes | undefined,
237): boolean => {
238 if (output === undefined) return false;
239 if (news.customer !== output.customer) return true;
240 if (news.type !== output.type) return true;
241 if (news.value !== output.value) return true;
242 return false;
243};
244
245export const CustomerTaxIdProvider = () =>
246 Provider.succeed(CustomerTaxId, {
247 stables: [
248 "id",
249 "customer",
250 "type",
251 "value",
252 "country",
253 "verificationStatus",
254 "created",
255 "livemode",
256 ],
257 nuke: { dependsOn: ["Stripe.Customer"] },
258
259 diff: Effect.fn(function* ({ news, output }) {
260 if (!isResolved(news)) return undefined;
261 if (shouldReplace(news, output)) {
262 return { action: "replace" } as const;
263 }
264 return undefined;
265 }),
266
267 read: Effect.fn(function* ({ output, olds }) {
268 const customer =
269 output?.customer ??
270 (typeof olds?.customer === "string" ? olds.customer : undefined);
271 const type =
272 output?.type ??
273 (typeof olds?.type === "string" ? olds.type : undefined);
274 const value =
275 output?.value ??
276 (typeof olds?.value === "string" ? olds.value : undefined);
277 const existing = yield* observe({
278 id: output?.id,
279 customer,
280 type,
281 value,
282 });
283 if (existing === undefined || customer === undefined) return undefined;
284 return toAttrs(customer, existing);
285 }),
286
287 list: Effect.fn(function* () {
288 // Customer tax IDs have no metadata. Fan out from alchemy-owned
289 // customers so nuke only tears down tax IDs we created.
290 const customers = yield* listAlchemyCustomers();
291 const rows = yield* Effect.forEach(
292 customers,
293 (customer) =>
294 listTaxIds(customer.id).pipe(
295 Effect.map((taxIds) =>
296 taxIds.map((taxId) => toAttrs(customer.id, taxId)),
297 ),
298 ),
299 { concurrency: LIST_CONCURRENCY },
300 );
301 return rows.flat();
302 }),
303
304 reconcile: Effect.fn(function* ({ news, output, instanceId }) {
305 let current = yield* observe({
306 id: output?.id,
307 customer: news.customer,
308 type: news.type,
309 value: news.value,
310 });
311 if (
312 current !== undefined &&
313 shouldReplace(news, toAttrs(news.customer, current))
314 ) {
315 current = undefined;
316 }
317
318 if (current === undefined) {
319 current = yield* CreateCustomerTaxIds({
320 customer: news.customer,
321 type: news.type,
322 value: news.value,
323 }).pipe(
324 withRequestOptions({
325 idempotencyKey: `alchemy-customer-tax-id-${instanceId}`,
326 }),
327 Effect.catchIf(
328 (e) => e._tag === "InvalidRequestError" || e._tag === "Conflict",
329 (e) =>
330 observe({
331 customer: news.customer,
332 type: news.type,
333 value: news.value,
334 }).pipe(
335 Effect.flatMap((found) =>
336 found !== undefined ? Effect.succeed(found) : Effect.fail(e),
337 ),
338 ),
339 ),
340 );
341 }
342
343 return toAttrs(news.customer, current);
344 }),
345
346 delete: Effect.fn(function* ({ output }) {
347 yield* DeleteCustomerTaxIds({
348 customer: output.customer,
349 id: output.id,
350 }).pipe(Effect.catchIf(isMissing, () => Effect.void));
351 }),
352 });