1import {
2 GetTaxSettings,
3 CreateTaxSettings,
4 type CreateTaxSettingsRequest,
5 type CreateTaxSettingsRequestDefaults,
6 type CreateTaxSettingsRequestHeadOfficeAddress,
7 type TaxSettings as StripeTaxSettings,
8} from "@distilled.cloud/stripe/stripe";
9import * as Effect from "effect/Effect";
10import { deepEqual } from "../Diff.ts";
11import * as Provider from "../Provider.ts";
12import { Resource } from "../Resource.ts";
13import type { Providers } from "./Providers.ts";
14
15/** Default [tax behavior](https://docs.stripe.com/tax/products-prices-tax-categories-tax-behavior#tax-behavior). Once set, Stripe will not unset it. */
16export type TaxSettingsTaxBehavior =
17 | "exclusive"
18 | "inclusive"
19 | "inferred_by_currency";
20
21/** The tax calculation provider this account uses. */
22export type TaxSettingsProviderKind = "anrok" | "avalara" | "sphere" | "stripe";
23
24/** Status of the Tax Settings singleton. */
25export type TaxSettingsStatus = "active" | "pending";
26
27/** Head-office address fields Stripe Tax uses for calculations. */
28export interface TaxSettingsAddress {
29 /** City, district, suburb, town, or village. */
30 city?: string;
31 /** Two-letter ISO 3166-1 alpha-2 country code. */
32 country?: string;
33 /** Address line 1 (street, PO Box, or company name). */
34 line1?: string;
35 /** Address line 2 (apartment, suite, unit, or building). */
36 line2?: string;
37 /** ZIP or postal code. */
38 postalCode?: string;
39 /** ISO 3166-2 subdivision code without the country prefix (e.g. `"NY"`). */
40 state?: string;
41}
42
43/** Place where the business is located. */
44export interface TaxSettingsHeadOffice {
45 /**
46 * Location of the business for tax purposes. Stripe will not remove a
47 * head office once set; fields can only be replaced with other values.
48 */
49 address: TaxSettingsAddress;
50}
51
52/** Default configuration used on Stripe Tax calculations. */
53export interface TaxSettingsDefaults {
54 /**
55 * Default tax behavior when a price does not specify one. Once set,
56 * Stripe will not unset it.
57 */
58 taxBehavior?: TaxSettingsTaxBehavior;
59 /**
60 * Default [tax code](https://docs.stripe.com/tax/tax-categories) used
61 * to classify products and prices (e.g. `"txcd_99999999"`). Once set,
62 * Stripe will not unset it.
63 */
64 taxCode?: string;
65}
66
67/**
68 * Writable Tax Settings. Omitted fields keep the account's current value.
69 * Stripe will not clear a field once it has been set.
70 */
71export interface TaxSettingsProps {
72 /**
73 * Default tax behavior and tax code used in calculations. Mutable —
74 * posted in place. Fields Stripe has already set cannot be removed.
75 */
76 defaults?: TaxSettingsDefaults;
77 /**
78 * The place where the business is located. Mutable — posted in place.
79 * Once set, Stripe will not remove the head office.
80 */
81 headOffice?: TaxSettingsHeadOffice;
82}
83
84/** Snapshot of restorable Tax Settings fields. */
85export interface TaxSettingsSnapshot {
86 /**
87 * Default tax behavior, if Stripe reports one.
88 */
89 taxBehavior: TaxSettingsTaxBehavior | undefined;
90 /**
91 * Default tax code, if Stripe reports one.
92 */
93 taxCode: string | undefined;
94 /**
95 * Head-office address, if Stripe reports one.
96 */
97 headOffice: TaxSettingsAddress | undefined;
98}
99
100export type TaxSettings = Resource<
101 "Stripe.TaxSettings",
102 TaxSettingsProps,
103 {
104 /**
105 * Stripe object type. Always `"tax.settings"` — this resource is the
106 * account-level singleton.
107 */
108 object: "tax.settings";
109 /** Default tax calculation provider for the account. */
110 provider: TaxSettingsProviderKind;
111 /** Default tax behavior, if set. */
112 taxBehavior: TaxSettingsTaxBehavior | undefined;
113 /** Default tax code, if set. */
114 taxCode: string | undefined;
115 /** Head-office address, if set. */
116 headOffice: TaxSettingsAddress | undefined;
117 /** Whether Stripe Tax is `active` or still `pending` missing fields. */
118 status: TaxSettingsStatus;
119 /**
120 * Fields Stripe still requires before calculations can run. Present
121 * while `status` is `pending`.
122 */
123 missingFields: string[] | undefined;
124 /** Whether the settings exist in live mode. */
125 livemode: boolean;
126 /**
127 * Snapshot of restorable fields taken before Alchemy first wrote.
128 * `delete` posts these values back (Stripe will not unset a field that
129 * was originally null).
130 */
131 initialSettings: TaxSettingsSnapshot;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * Account-level Stripe Tax Settings — default tax behavior, default tax
139 * code, and head-office address used by Stripe Tax calculations.
140 *
141 * This is an account singleton: `GET /v1/tax/settings` always returns the
142 * merchant's settings, and `POST /v1/tax/settings` updates them in place.
143 * There is no create or hard-delete. The first reconcile captures the
144 * pre-management snapshot (`initialSettings`); destroy posts that snapshot
145 * back. Stripe will not unset a field once it has been set, so a null
146 * original is left as-is on restore.
147 *
148 * The Tax Settings object has no metadata. Alchemy does not stamp
149 * ownership keys.
150 *
151 * @see https://docs.stripe.com/api/tax/settings
152 *
153 * ### Configuring defaults
154 * **Example:** Exclusive default tax behavior
155 * ```typescript
156 * const settings = yield* Stripe.TaxSettings("tax", {
157 * defaults: { taxBehavior: "exclusive" },
158 * });
159 * ```
160 *
161 * **Example:** Default tax code
162 * ```typescript
163 * const settings = yield* Stripe.TaxSettings("tax", {
164 * defaults: {
165 * taxBehavior: "exclusive",
166 * taxCode: "txcd_99999999",
167 * },
168 * });
169 * ```
170 *
171 * ### Head office
172 * **Example:** Set the business location
173 * ```typescript
174 * const settings = yield* Stripe.TaxSettings("tax", {
175 * headOffice: {
176 * address: { country: "US", state: "CA" },
177 * },
178 * });
179 * ```
180 *
181 * ### Restoring on destroy
182 * **Example:** Destroy restores pre-management settings
183 * ```typescript
184 * // stack.destroy() posts `initialSettings` back (fields Stripe allows)
185 * const settings = yield* Stripe.TaxSettings("tax", {
186 * defaults: { taxBehavior: "inclusive" },
187 * });
188 * ```
189 *
190 * @resource
191 * @product Tax
192 */
193export const TaxSettings = Resource<TaxSettings>("Stripe.TaxSettings");
194
195type TaxSettingsAttributes = TaxSettings["Attributes"];
196
197const undef = <T>(value: T | null | undefined): T | undefined =>
198 value == null ? undefined : value;
199
200const toAddress = (
201 address:
202 | {
203 city?: string | null;
204 country?: string | null;
205 line1?: string | null;
206 line2?: string | null;
207 postal_code?: string | null;
208 state?: string | null;
209 }
210 | null
211 | undefined,
212): TaxSettingsAddress | undefined => {
213 if (address == null) return undefined;
214 const mapped: TaxSettingsAddress = {
215 ...(address.city != null ? { city: address.city } : {}),
216 ...(address.country != null ? { country: address.country } : {}),
217 ...(address.line1 != null ? { line1: address.line1 } : {}),
218 ...(address.line2 != null ? { line2: address.line2 } : {}),
219 ...(address.postal_code != null ? { postalCode: address.postal_code } : {}),
220 ...(address.state != null ? { state: address.state } : {}),
221 };
222 return Object.keys(mapped).length > 0 ? mapped : undefined;
223};
224
225const toWireAddress = (
226 address: TaxSettingsAddress,
227): CreateTaxSettingsRequestHeadOfficeAddress => ({
228 ...(address.city !== undefined ? { city: address.city } : {}),
229 ...(address.country !== undefined ? { country: address.country } : {}),
230 ...(address.line1 !== undefined ? { line1: address.line1 } : {}),
231 ...(address.line2 !== undefined ? { line2: address.line2 } : {}),
232 ...(address.postalCode !== undefined
233 ? { postal_code: address.postalCode }
234 : {}),
235 ...(address.state !== undefined ? { state: address.state } : {}),
236});
237
238const toSnapshot = (settings: StripeTaxSettings): TaxSettingsSnapshot => ({
239 taxBehavior: undef(settings.defaults.tax_behavior) as
240 | TaxSettingsTaxBehavior
241 | undefined,
242 taxCode: undef(settings.defaults.tax_code),
243 headOffice: toAddress(settings.head_office?.address),
244});
245
246const toAttrs = (
247 settings: StripeTaxSettings,
248 initialSettings: TaxSettingsSnapshot,
249): TaxSettingsAttributes => ({
250 object: "tax.settings",
251 provider: settings.defaults.provider as TaxSettingsProviderKind,
252 taxBehavior: undef(settings.defaults.tax_behavior) as
253 | TaxSettingsTaxBehavior
254 | undefined,
255 taxCode: undef(settings.defaults.tax_code),
256 headOffice: toAddress(settings.head_office?.address),
257 status: settings.status,
258 missingFields: undef(settings.status_details.pending?.missing_fields),
259 livemode: settings.livemode,
260 initialSettings,
261});
262
263const observe = GetTaxSettings({});
264
265const desiredDefaults = (
266 news: TaxSettingsProps,
267): CreateTaxSettingsRequestDefaults | undefined => {
268 if (news.defaults === undefined) return undefined;
269 const defaults: CreateTaxSettingsRequestDefaults = {
270 ...(news.defaults.taxBehavior !== undefined
271 ? { tax_behavior: news.defaults.taxBehavior }
272 : {}),
273 ...(news.defaults.taxCode !== undefined
274 ? { tax_code: news.defaults.taxCode }
275 : {}),
276 };
277 return Object.keys(defaults).length > 0 ? defaults : undefined;
278};
279
280const desiredHeadOffice = (news: TaxSettingsProps) =>
281 news.headOffice !== undefined
282 ? { address: toWireAddress(news.headOffice.address) }
283 : undefined;
284
285const syncBody = (
286 news: TaxSettingsProps,
287 observed: StripeTaxSettings,
288): CreateTaxSettingsRequest | undefined => {
289 const body: CreateTaxSettingsRequest = {};
290 const snapshot = toSnapshot(observed);
291 const defaults = desiredDefaults(news);
292 if (defaults !== undefined) {
293 const taxBehaviorChanged =
294 news.defaults?.taxBehavior !== undefined &&
295 snapshot.taxBehavior !== news.defaults.taxBehavior;
296 const taxCodeChanged =
297 news.defaults?.taxCode !== undefined &&
298 snapshot.taxCode !== news.defaults.taxCode;
299 if (taxBehaviorChanged || taxCodeChanged) {
300 body.defaults = defaults;
301 }
302 }
303 const headOffice = desiredHeadOffice(news);
304 if (
305 headOffice !== undefined &&
306 !deepEqual(snapshot.headOffice, news.headOffice?.address, {
307 stripNullish: true,
308 })
309 ) {
310 body.head_office = headOffice;
311 }
312 return Object.keys(body).length > 0 ? body : undefined;
313};
314
315const restoreBody = (
316 initial: TaxSettingsSnapshot,
317 observed: StripeTaxSettings,
318): CreateTaxSettingsRequest | undefined => {
319 const current = toSnapshot(observed);
320 const body: CreateTaxSettingsRequest = {};
321 const defaults: CreateTaxSettingsRequestDefaults = {};
322 // Stripe will not unset a field that was originally null — only restore
323 // values that were already set when Alchemy first captured the snapshot.
324 if (
325 initial.taxBehavior !== undefined &&
326 current.taxBehavior !== initial.taxBehavior
327 ) {
328 defaults.tax_behavior = initial.taxBehavior;
329 }
330 if (initial.taxCode !== undefined && current.taxCode !== initial.taxCode) {
331 defaults.tax_code = initial.taxCode;
332 }
333 if (Object.keys(defaults).length > 0) {
334 body.defaults = defaults;
335 }
336 if (
337 initial.headOffice !== undefined &&
338 !deepEqual(current.headOffice, initial.headOffice, { stripNullish: true })
339 ) {
340 body.head_office = { address: toWireAddress(initial.headOffice) };
341 }
342 return Object.keys(body).length > 0 ? body : undefined;
343};
344
345export const TaxSettingsProvider = () =>
346 Provider.succeed(TaxSettings, {
347 // Account-global singleton: nuke must not reset merchant tax config.
348 nuke: { singleton: true },
349 stables: ["object", "livemode", "initialSettings"],
350
351 diff: Effect.fn(function* () {
352 return undefined;
353 }),
354
355 read: Effect.fn(function* ({ output }) {
356 const observed = yield* observe;
357 // Always-present singleton — nothing to own, so a cold read adopts
358 // freely (never `Unowned`). The observed snapshot at adoption time
359 // becomes the baseline restored on destroy.
360 return toAttrs(observed, output?.initialSettings ?? toSnapshot(observed));
361 }),
362
363 list: Effect.fn(function* () {
364 // No collection API and no metadata. The singleton always exists;
365 // return it as a one-element array. Cold observation uses the live
366 // snapshot as `initialSettings`, so a nuke restore would be a no-op
367 // (and nuke skips this type via `nuke.singleton` anyway).
368 const observed = yield* observe;
369 const snapshot = toSnapshot(observed);
370 return [toAttrs(observed, snapshot)];
371 }),
372
373 reconcile: Effect.fn(function* ({ news, output }) {
374 // 1. Observe — the singleton always exists.
375 let observed = yield* observe;
376
377 // 2. Capture — the pre-management snapshot, restored on destroy.
378 const initialSettings = output?.initialSettings ?? toSnapshot(observed);
379
380 // 3. Sync — POST only the declared fields whose observed value
381 // differs. Skip the API on no delta.
382 const body = syncBody(news, observed);
383 if (body !== undefined) {
384 observed = yield* CreateTaxSettings(body);
385 }
386
387 return toAttrs(observed, initialSettings);
388 }),
389
390 delete: Effect.fn(function* ({ output }) {
391 const observed = yield* observe;
392 const body = restoreBody(output.initialSettings, observed);
393 if (body === undefined) return;
394 yield* CreateTaxSettings(body);
395 }),
396 });