1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetTaxRegistrations,
4 GetTaxRegistration,
5 CreateTaxRegistration,
6 UpdateTaxRegistration,
7 type CreateTaxRegistrationRequestCountryOptions,
8 type TaxProductRegistrationsResourceCountryOptions,
9 type TaxRegistration as StripeTaxRegistration,
10} from "@distilled.cloud/stripe/stripe";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import { deepEqual, isResolved } from "../Diff.ts";
14import * as Provider from "../Provider.ts";
15import { Resource } from "../Resource.ts";
16import type { Providers } from "./Providers.ts";
17import { isMissingStripeResource } from "./missing.ts";
18
19const LIST_PAGE_SIZE = 100;
20const LIST_MAX_PAGES = 100;
21
22/** The status of the registration, derived from `activeFrom` and `expiresAt`. */
23export type TaxRegistrationStatus = "active" | "expired" | "scheduled";
24
25/** When the registration becomes active. `"now"` means the current time. */
26export type TaxRegistrationActiveFrom = "now" | number;
27
28/**
29 * When the registration stops being active. `"now"` expires immediately.
30 * `null` clears a previously scheduled expiry.
31 */
32export type TaxRegistrationExpiresAt = "now" | number | null;
33
34/** Place of supply scheme used in a standard registration. */
35export type TaxRegistrationPlaceOfSupplyScheme = "inbound_goods" | "standard";
36
37/** Place of supply scheme used in an EU standard registration. */
38export type TaxRegistrationEuPlaceOfSupplyScheme =
39 | "inbound_goods"
40 | "small_seller"
41 | "standard";
42
43/** Type of registration in an EU country. */
44export type TaxRegistrationEuropeType =
45 | "ioss"
46 | "oss_non_union"
47 | "oss_union"
48 | "standard";
49
50/** Type of registration in Canada. */
51export type TaxRegistrationCanadaType =
52 | "province_standard"
53 | "simplified"
54 | "standard";
55
56/** Type of registration in the US. */
57export type TaxRegistrationUnitedStatesType =
58 | "local_amusement_tax"
59 | "local_lease_tax"
60 | "state_communications_tax"
61 | "state_retail_delivery_fee"
62 | "state_sales_tax";
63
64/** Election type for a US state sales tax registration. */
65export type TaxRegistrationUsStateSalesTaxElectionType =
66 | "local_use_tax"
67 | "simplified_sellers_use_tax"
68 | "single_local_use_tax";
69
70/** ISO country codes that use a standard (optionally inbound-goods) registration. */
71export type TaxRegistrationStandardCountryCode =
72 | "ae"
73 | "al"
74 | "ao"
75 | "au"
76 | "aw"
77 | "ba"
78 | "bb"
79 | "bd"
80 | "bf"
81 | "bh"
82 | "bs"
83 | "cd"
84 | "ch"
85 | "et"
86 | "gb"
87 | "gn"
88 | "is"
89 | "jp"
90 | "me"
91 | "mk"
92 | "mr"
93 | "no"
94 | "nz"
95 | "om"
96 | "rs"
97 | "sg"
98 | "sr"
99 | "uy"
100 | "za"
101 | "zw";
102
103/** ISO country codes that use a simplified registration. */
104export type TaxRegistrationSimplifiedCountryCode =
105 | "am"
106 | "az"
107 | "bj"
108 | "by"
109 | "cl"
110 | "cm"
111 | "co"
112 | "cr"
113 | "cv"
114 | "ec"
115 | "eg"
116 | "ge"
117 | "id"
118 | "in"
119 | "ke"
120 | "kg"
121 | "kh"
122 | "kr"
123 | "kz"
124 | "la"
125 | "lk"
126 | "ma"
127 | "md"
128 | "mx"
129 | "my"
130 | "ng"
131 | "np"
132 | "pe"
133 | "ph"
134 | "ru"
135 | "sa"
136 | "sn"
137 | "th"
138 | "tj"
139 | "tr"
140 | "tw"
141 | "tz"
142 | "ua"
143 | "ug"
144 | "uz"
145 | "vn"
146 | "zm";
147
148/** ISO country codes that use an EU registration (standard / OSS / IOSS). */
149export type TaxRegistrationEuropeCountryCode =
150 | "at"
151 | "be"
152 | "bg"
153 | "cy"
154 | "cz"
155 | "de"
156 | "dk"
157 | "ee"
158 | "es"
159 | "fi"
160 | "fr"
161 | "gr"
162 | "hr"
163 | "hu"
164 | "ie"
165 | "it"
166 | "lt"
167 | "lu"
168 | "lv"
169 | "mt"
170 | "nl"
171 | "pl"
172 | "pt"
173 | "ro"
174 | "se"
175 | "si"
176 | "sk";
177
178export interface TaxRegistrationStandardOption {
179 /**
180 * A standard Tax Registration in the specified country.
181 */
182 type: "standard";
183 /**
184 * Options for the standard registration.
185 */
186 standard?: {
187 /**
188 * Place of supply scheme. `inbound_goods` collects tax at destination
189 * for inbound physical goods; `standard` applies tax to sales in this
190 * country only.
191 */
192 placeOfSupplyScheme?: TaxRegistrationPlaceOfSupplyScheme;
193 };
194}
195
196export interface TaxRegistrationSimplifiedOption {
197 /**
198 * A simplified Tax Registration in the specified country.
199 */
200 type: "simplified";
201}
202
203export interface TaxRegistrationEuropeOption {
204 /**
205 * Type of registration in an EU country.
206 */
207 type: TaxRegistrationEuropeType;
208 /**
209 * Options for an EU standard registration. Required when `type` is
210 * `"standard"`.
211 */
212 standard?: {
213 /**
214 * Place of supply scheme used in an EU standard registration.
215 */
216 placeOfSupplyScheme?: TaxRegistrationEuPlaceOfSupplyScheme;
217 };
218}
219
220export interface TaxRegistrationCanadaOption {
221 /**
222 * Type of registration in Canada.
223 */
224 type: TaxRegistrationCanadaType;
225 /**
226 * Province-standard options. Required when `type` is
227 * `"province_standard"`.
228 */
229 provinceStandard?: {
230 /**
231 * Two-letter CA province code (ISO 3166-2, without country prefix).
232 */
233 province: string;
234 };
235}
236
237export interface TaxRegistrationUsStateSalesTaxElection {
238 /**
239 * The type of the election for the state sales tax registration.
240 */
241 type: TaxRegistrationUsStateSalesTaxElectionType;
242 /**
243 * A FIPS code representing the local jurisdiction, when required.
244 */
245 jurisdiction?: string;
246}
247
248export interface TaxRegistrationUnitedStatesOption {
249 /**
250 * Type of registration in the US.
251 */
252 type: TaxRegistrationUnitedStatesType;
253 /**
254 * Two-letter US state code (ISO 3166-2, without country prefix).
255 */
256 state: string;
257 /**
258 * Options for a local amusement tax registration.
259 */
260 localAmusementTax?: {
261 /**
262 * A FIPS code representing the local jurisdiction.
263 */
264 jurisdiction: string;
265 };
266 /**
267 * Options for a local lease tax registration.
268 */
269 localLeaseTax?: {
270 /**
271 * A FIPS code representing the local jurisdiction.
272 */
273 jurisdiction: string;
274 };
275 /**
276 * Options for a state sales tax registration.
277 */
278 stateSalesTax?: {
279 /**
280 * Elections for the state sales tax registration.
281 */
282 elections?: TaxRegistrationUsStateSalesTaxElection[];
283 };
284}
285
286/**
287 * Country-specific registration options. Set exactly the key matching
288 * `country` (lowercase ISO 3166-1 alpha-2).
289 */
290export type TaxRegistrationCountryOptions = {
291 [K in TaxRegistrationStandardCountryCode]?: TaxRegistrationStandardOption;
292} & {
293 [K in TaxRegistrationSimplifiedCountryCode]?: TaxRegistrationSimplifiedOption;
294} & {
295 [K in TaxRegistrationEuropeCountryCode]?: TaxRegistrationEuropeOption;
296} & {
297 ca?: TaxRegistrationCanadaOption;
298 us?: TaxRegistrationUnitedStatesOption;
299};
300
301export interface TaxRegistrationProps {
302 /**
303 * Two-letter country code (ISO 3166-1 alpha-2). Create-only — changing
304 * it replaces the registration.
305 */
306 country: string;
307 /**
308 * Specific options for a registration in `country`. Create-only —
309 * changing them replaces the registration.
310 */
311 countryOptions: TaxRegistrationCountryOptions;
312 /**
313 * Time at which the registration becomes active. `"now"` or a Unix
314 * timestamp in seconds. Mutable.
315 * @default "now"
316 */
317 activeFrom?: TaxRegistrationActiveFrom;
318 /**
319 * Time at which the registration stops being active. `"now"`, a Unix
320 * timestamp in seconds, or `null` to clear a scheduled expiry. Omit for
321 * no expiry. Destroying this resource sets `expiresAt` to `"now"`.
322 * Mutable.
323 */
324 expiresAt?: TaxRegistrationExpiresAt;
325}
326
327export type TaxRegistration = Resource<
328 "Stripe.TaxRegistration",
329 TaxRegistrationProps,
330 {
331 /** Stripe tax registration id (`taxreg_…`). */
332 id: string;
333 /** Two-letter country code (ISO 3166-1 alpha-2). */
334 country: string;
335 /** Country-specific registration options. */
336 countryOptions: TaxRegistrationCountryOptions;
337 /** Unix timestamp when the registration becomes active. */
338 activeFrom: number;
339 /** Unix timestamp when the registration stops being active, if set. */
340 expiresAt: number | undefined;
341 /** Status derived from `activeFrom` and `expiresAt`. */
342 status: TaxRegistrationStatus;
343 /** Unix timestamp when the registration was created. */
344 created: number;
345 /** Whether the registration exists in live mode. */
346 livemode: boolean;
347 },
348 never,
349 Providers
350>;
351
352/**
353 * A Stripe Tax Registration — records that your business is registered to
354 * collect tax in a region so Stripe Tax can calculate and collect it.
355 * Stripe does not register with tax authorities on your behalf.
356 *
357 * `country` and `countryOptions` are immutable and changing them replaces
358 * the registration. `activeFrom` and `expiresAt` update in place.
359 *
360 * A registration cannot be hard-deleted. Destroying this resource expires
361 * it (`expiresAt: "now"`). Expiration is permanent; to collect tax in
362 * that location again, create a new registration.
363 *
364 * Tax registrations have no metadata field. Identity is the Stripe id
365 * (and, when the id is missing, a matching non-expired country + options
366 * pair). `list()` returns every non-expired registration on the account.
367 *
368 * A head office address must be configured on Tax Settings before a
369 * registration can be created.
370 *
371 * @see https://docs.stripe.com/api/tax/registrations
372 *
373 * ### Creating a Tax Registration
374 * **Example:** US state sales tax
375 * ```typescript
376 * const california = yield* Stripe.TaxRegistration("ca-sales-tax", {
377 * country: "US",
378 * countryOptions: {
379 * us: { type: "state_sales_tax", state: "CA" },
380 * },
381 * });
382 * ```
383 *
384 * **Example:** Simplified registration
385 * ```typescript
386 * const tajikistan = yield* Stripe.TaxRegistration("tj", {
387 * country: "TJ",
388 * countryOptions: { tj: { type: "simplified" } },
389 * });
390 * ```
391 *
392 * **Example:** EU OSS Union
393 * ```typescript
394 * const irelandOss = yield* Stripe.TaxRegistration("ie-oss", {
395 * country: "IE",
396 * countryOptions: { ie: { type: "oss_union" } },
397 * activeFrom: "now",
398 * });
399 * ```
400 *
401 * ### Updating a Tax Registration
402 * **Example:** Schedule an expiry
403 * ```typescript
404 * const california = yield* Stripe.TaxRegistration("ca-sales-tax", {
405 * country: "US",
406 * countryOptions: {
407 * us: { type: "state_sales_tax", state: "CA" },
408 * },
409 * expiresAt: 1893456000,
410 * });
411 * ```
412 *
413 * ### Expiring a Tax Registration
414 * **Example:** Destroy expires rather than deleting
415 * ```typescript
416 * // stack.destroy() / resource removal sets expiresAt to "now"
417 * const california = yield* Stripe.TaxRegistration("ca-sales-tax", {
418 * country: "US",
419 * countryOptions: {
420 * us: { type: "state_sales_tax", state: "CA" },
421 * },
422 * });
423 * ```
424 *
425 * @resource
426 * @product Tax
427 */
428export const TaxRegistration = Resource<TaxRegistration>(
429 "Stripe.TaxRegistration",
430);
431
432export class TaxRegistrationNotResolved extends Data.TaggedError(
433 "Stripe.TaxRegistrationNotResolved",
434)<{
435 country: string;
436}> {}
437
438type TaxRegistrationAttributes = TaxRegistration["Attributes"];
439
440const CAMEL_TO_SNAKE: Record<string, string> = {
441 placeOfSupplyScheme: "place_of_supply_scheme",
442 provinceStandard: "province_standard",
443 localAmusementTax: "local_amusement_tax",
444 localLeaseTax: "local_lease_tax",
445 stateSalesTax: "state_sales_tax",
446};
447
448const SNAKE_TO_CAMEL: Record<string, string> = Object.fromEntries(
449 Object.entries(CAMEL_TO_SNAKE).map(([camel, snake]) => [snake, camel]),
450);
451
452const renameDeep = (value: unknown, map: Record<string, string>): unknown => {
453 if (Array.isArray(value)) {
454 return value.map((item) => renameDeep(item, map));
455 }
456 if (value !== null && typeof value === "object") {
457 return Object.fromEntries(
458 Object.entries(value as Record<string, unknown>).map(([key, nested]) => [
459 map[key] ?? key,
460 renameDeep(nested, map),
461 ]),
462 );
463 }
464 return value;
465};
466
467const toWireCountryOptions = (
468 options: TaxRegistrationCountryOptions,
469): CreateTaxRegistrationRequestCountryOptions =>
470 renameDeep(
471 options,
472 CAMEL_TO_SNAKE,
473 ) as CreateTaxRegistrationRequestCountryOptions;
474
475const fromWireCountryOptions = (
476 options: TaxProductRegistrationsResourceCountryOptions,
477): TaxRegistrationCountryOptions =>
478 renameDeep(options, SNAKE_TO_CAMEL) as TaxRegistrationCountryOptions;
479
480const identityKey = (
481 country: string,
482 options: TaxRegistrationCountryOptions,
483): string => {
484 const parts: string[] = [];
485 for (const [code, option] of Object.entries(options)) {
486 if (option == null || typeof option !== "object") continue;
487 const record = option as unknown as Record<string, unknown>;
488 const type = typeof record.type === "string" ? record.type : "";
489 const state = typeof record.state === "string" ? record.state : "";
490 const provinceStandard = record.provinceStandard as
491 | { province?: string }
492 | undefined;
493 const province = provinceStandard?.province ?? "";
494 parts.push(`${code}:${type}:${state}:${province}`);
495 }
496 parts.sort();
497 return `${country.toUpperCase()}|${parts.join(",")}`;
498};
499
500const toAttrs = (
501 registration: StripeTaxRegistration,
502): TaxRegistrationAttributes => ({
503 id: registration.id,
504 country: registration.country,
505 countryOptions: fromWireCountryOptions(registration.country_options),
506 activeFrom: registration.active_from,
507 expiresAt: registration.expires_at ?? undefined,
508 status: registration.status,
509 created: registration.created,
510 livemode: registration.livemode,
511});
512
513const isMissingRegistration = isMissingStripeResource;
514
515const getById = (id: string) =>
516 GetTaxRegistration({ id }).pipe(
517 Effect.catchIf(isMissingRegistration, () => Effect.succeed(undefined)),
518 );
519
520const listByStatus = Effect.fn(function* (
521 status: "active" | "all" | "expired" | "scheduled",
522) {
523 const registrations: StripeTaxRegistration[] = [];
524 let startingAfter: string | undefined;
525 for (let page = 0; page < LIST_MAX_PAGES; page++) {
526 const response = yield* GetTaxRegistrations({
527 status,
528 limit: LIST_PAGE_SIZE,
529 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
530 });
531 registrations.push(...response.data);
532 if (!response.has_more || response.data.length === 0) {
533 break;
534 }
535 startingAfter = response.data[response.data.length - 1]?.id;
536 if (startingAfter === undefined) {
537 break;
538 }
539 }
540 return registrations;
541});
542
543const findByIdentity = Effect.fn(function* (
544 country: string,
545 options: TaxRegistrationCountryOptions,
546) {
547 const desired = identityKey(country, options);
548 const registrations = yield* listByStatus("all");
549 const matches = registrations.filter(
550 (registration) =>
551 registration.status !== "expired" &&
552 identityKey(
553 registration.country,
554 fromWireCountryOptions(registration.country_options),
555 ) === desired,
556 );
557 matches.sort((a, b) => b.created - a.created);
558 return matches[0];
559});
560
561const observe = Effect.fn(function* (input: {
562 id?: string;
563 country?: string;
564 countryOptions?: TaxRegistrationCountryOptions;
565}) {
566 if (input.id !== undefined) {
567 const byId = yield* getById(input.id);
568 if (byId !== undefined) return byId;
569 }
570 if (input.country !== undefined && input.countryOptions !== undefined) {
571 return yield* findByIdentity(input.country, input.countryOptions);
572 }
573 return undefined;
574});
575
576const shouldReplace = (
577 news: TaxRegistrationProps,
578 output: TaxRegistrationAttributes | undefined,
579): boolean => {
580 if (output === undefined) return false;
581 if (news.country.toUpperCase() !== output.country.toUpperCase()) {
582 return true;
583 }
584 return (
585 identityKey(news.country, news.countryOptions) !==
586 identityKey(output.country, output.countryOptions)
587 );
588};
589
590const desiredExpiresAt = (
591 expiresAt: TaxRegistrationExpiresAt | undefined,
592): number | null | undefined => {
593 if (expiresAt === undefined) return null;
594 if (expiresAt === "now") return undefined;
595 return expiresAt;
596};
597
598export const TaxRegistrationProvider = () =>
599 Provider.succeed(TaxRegistration, {
600 stables: ["id", "country", "countryOptions", "created", "livemode"],
601
602 diff: Effect.fn(function* ({ news, output }) {
603 if (!isResolved(news)) return undefined;
604 if (shouldReplace(news, output)) {
605 return { action: "replace" } as const;
606 }
607 return undefined;
608 }),
609
610 read: Effect.fn(function* ({ output }) {
611 const existing = yield* observe({
612 id: output?.id,
613 country: output?.country,
614 countryOptions: output?.countryOptions,
615 });
616 if (existing === undefined) return undefined;
617 // Tax registrations have no metadata. Identity is the Stripe id and
618 // the non-expired country + options pair; a match is treated as owned.
619 return toAttrs(existing);
620 }),
621
622 list: Effect.fn(function* () {
623 // No metadata on this resource. Default list is non-expired
624 // registrations; expired rows stay in Stripe but must not re-enter
625 // nuke.
626 const [active, scheduled] = yield* Effect.all(
627 [listByStatus("active"), listByStatus("scheduled")],
628 { concurrency: 2 },
629 );
630 const seen = new Set<string>();
631 const registrations: StripeTaxRegistration[] = [];
632 for (const registration of [...active, ...scheduled]) {
633 if (seen.has(registration.id)) continue;
634 seen.add(registration.id);
635 registrations.push(registration);
636 }
637 return registrations.map(toAttrs);
638 }),
639
640 reconcile: Effect.fn(function* ({ news, output, instanceId }) {
641 const desiredActiveFrom = news.activeFrom ?? "now";
642 const countryOptions = toWireCountryOptions(news.countryOptions);
643
644 let current = yield* observe({
645 id: output?.id,
646 country: news.country,
647 countryOptions: news.countryOptions,
648 });
649 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
650 current = undefined;
651 }
652
653 if (current === undefined) {
654 current = yield* CreateTaxRegistration({
655 country: news.country,
656 country_options: countryOptions,
657 active_from: desiredActiveFrom,
658 ...(typeof news.expiresAt === "number"
659 ? { expires_at: news.expiresAt }
660 : {}),
661 }).pipe(
662 withRequestOptions({
663 idempotencyKey: `alchemy-tax-registration-${instanceId}`,
664 }),
665 );
666 }
667
668 if (current === undefined) {
669 return yield* new TaxRegistrationNotResolved({ country: news.country });
670 }
671
672 const activeFromChanged =
673 typeof desiredActiveFrom === "number" &&
674 current.active_from !== desiredActiveFrom;
675 const activateNow =
676 desiredActiveFrom === "now" && current.status === "scheduled";
677
678 const expiresDesired = desiredExpiresAt(news.expiresAt);
679 const expiresObserved = current.expires_at;
680 const expireNow =
681 news.expiresAt === "now" && current.status !== "expired";
682 const expiresChanged =
683 expiresDesired !== undefined &&
684 !deepEqual(expiresDesired, expiresObserved, { stripNullish: true });
685
686 if (!activeFromChanged && !activateNow && !expireNow && !expiresChanged) {
687 return toAttrs(current);
688 }
689
690 const updated = yield* UpdateTaxRegistration({
691 id: current.id,
692 ...(activeFromChanged || activateNow
693 ? { active_from: desiredActiveFrom }
694 : {}),
695 ...(expireNow
696 ? { expires_at: "now" }
697 : expiresChanged
698 ? {
699 expires_at:
700 expiresDesired === null
701 ? ("" as const)
702 : (expiresDesired as number),
703 }
704 : {}),
705 });
706 return toAttrs(updated);
707 }),
708
709 delete: Effect.fn(function* ({ output }) {
710 const existing = yield* getById(output.id);
711 if (existing === undefined || existing.status === "expired") return;
712 // Stripe requires expires_at > active_from and strictly in the
713 // future. `expires_at: "now"` fails when the registration was
714 // created in the same unix second; `expires_at: nowSec` fails
715 // when the clock ticks between compute and the request.
716 const nowSec = yield* Effect.sync(() => Math.floor(Date.now() / 1000));
717 const expiresAt = Math.max(nowSec + 1, existing.active_from + 1);
718 yield* UpdateTaxRegistration({
719 id: existing.id,
720 expires_at: expiresAt,
721 }).pipe(Effect.catchIf(isMissingRegistration, () => Effect.void));
722 }),
723 });