| 1 | import { |
| 2 | GetTaxSettings, |
| 3 | CreateTaxSettings, |
| 4 | type CreateTaxSettingsRequest, |
| 5 | type CreateTaxSettingsRequestDefaults, |
| 6 | type CreateTaxSettingsRequestHeadOfficeAddress, |
| 7 | type TaxSettings as StripeTaxSettings, |
| 8 | } from "@distilled.cloud/stripe/stripe"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import { deepEqual } from "../Diff.ts"; |
| 11 | import * as Provider from "../Provider.ts"; |
| 12 | import { Resource } from "../Resource.ts"; |
| 13 | import 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. */ |
| 16 | export type TaxSettingsTaxBehavior = |
| 17 | | "exclusive" |
| 18 | | "inclusive" |
| 19 | | "inferred_by_currency"; |
| 20 | |
| 21 | /** The tax calculation provider this account uses. */ |
| 22 | export type TaxSettingsProviderKind = "anrok" | "avalara" | "sphere" | "stripe"; |
| 23 | |
| 24 | /** Status of the Tax Settings singleton. */ |
| 25 | export type TaxSettingsStatus = "active" | "pending"; |
| 26 | |
| 27 | /** Head-office address fields Stripe Tax uses for calculations. */ |
| 28 | export 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. */ |
| 44 | export 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. */ |
| 53 | export 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 | */ |
| 71 | export 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. */ |
| 85 | export 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 | |
| 100 | export 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 | */ |
| 193 | export const TaxSettings = Resource<TaxSettings>("Stripe.TaxSettings"); |
| 194 | |
| 195 | type TaxSettingsAttributes = TaxSettings["Attributes"]; |
| 196 | |
| 197 | const undef = <T>(value: T | null | undefined): T | undefined => |
| 198 | value == null ? undefined : value; |
| 199 | |
| 200 | const 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 | |
| 225 | const 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 | |
| 238 | const 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 | |
| 246 | const 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 | |
| 263 | const observe = GetTaxSettings({}); |
| 264 | |
| 265 | const 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 | |
| 280 | const desiredHeadOffice = (news: TaxSettingsProps) => |
| 281 | news.headOffice !== undefined |
| 282 | ? { address: toWireAddress(news.headOffice.address) } |
| 283 | : undefined; |
| 284 | |
| 285 | const 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 | |
| 315 | const 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 | |
| 345 | export 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 | }); |