1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetTaxRates,
4 GetTaxRate,
5 CreateTaxRate,
6 UpdateTaxRate,
7 type TaxRate as StripeTaxRate,
8} from "@distilled.cloud/stripe/stripe";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import { Unowned } from "../AdoptPolicy.ts";
12import { isResolved } from "../Diff.ts";
13import { createPhysicalName } from "../PhysicalName.ts";
14import * as Provider from "../Provider.ts";
15import { Resource } from "../Resource.ts";
16import { tagRecord } from "../Tags.ts";
17import {
18 alchemyMetadataKeys,
19 createInternalMetadata,
20 diffMetadata,
21 hasAlchemyMetadata,
22 stripInternalMetadata,
23 toMetadata,
24} from "./Metadata.ts";
25import type { Providers } from "./Providers.ts";
26import { isMissingStripeResource } from "./missing.ts";
27
28const DISPLAY_NAME_MAX_LENGTH = 250;
29const LIST_PAGE_SIZE = 100;
30const LIST_MAX_PAGES = 100;
31
32/** High-level Stripe tax type, such as `vat` or `sales_tax`. */
33export type TaxRateTaxType =
34 | "amusement_tax"
35 | "communications_tax"
36 | "gst"
37 | "hst"
38 | "igst"
39 | "jct"
40 | "lease_tax"
41 | "pst"
42 | "qst"
43 | "retail_delivery_fee"
44 | "rst"
45 | "sales_tax"
46 | "service_tax"
47 | "vat";
48
49export interface TaxRateProps {
50 /**
51 * Display name shown to customers on receipts, invoice PDFs, and the
52 * hosted invoice page. If omitted, a unique name is generated from the
53 * stack, stage, and logical id. Mutable.
54 */
55 displayName?: string;
56 /**
57 * Tax rate percent out of 100. Create-only — changing it replaces the
58 * tax rate.
59 */
60 percentage: number;
61 /**
62 * Whether this tax is inclusive of the listed price. Create-only —
63 * changing it replaces the tax rate.
64 */
65 inclusive: boolean;
66 /**
67 * Whether the tax rate can be applied to new invoices, subscriptions,
68 * and Checkout Sessions. Inactive rates remain on objects that already
69 * use them.
70 * @default true
71 */
72 active?: boolean;
73 /**
74 * Internal description. Not shown to customers.
75 */
76 description?: string;
77 /**
78 * Two-letter ISO 3166-1 alpha-2 country code.
79 */
80 country?: string;
81 /**
82 * Jurisdiction label for tax reporting; also appears on customer
83 * invoices.
84 */
85 jurisdiction?: string;
86 /**
87 * ISO 3166-2 subdivision code without the country prefix (e.g. `"NY"`).
88 */
89 state?: string;
90 /**
91 * High-level tax type, such as `vat` or `sales_tax`.
92 */
93 taxType?: TaxRateTaxType;
94 /**
95 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
96 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
97 * not contain `:`. Mutable.
98 */
99 metadata?: Record<string, string>;
100}
101
102export type TaxRate = Resource<
103 "Stripe.TaxRate",
104 TaxRateProps,
105 {
106 /** Stripe tax rate id (`txr_…`). */
107 id: string;
108 /** Display name shown to customers. */
109 displayName: string;
110 /** Tax rate percent out of 100. */
111 percentage: number;
112 /** Whether this tax is inclusive of the listed price. */
113 inclusive: boolean;
114 /** Whether the tax rate can be applied to new objects. */
115 active: boolean;
116 /** Internal description, if set. */
117 description: string | undefined;
118 /** Two-letter ISO country code, if set. */
119 country: string | undefined;
120 /** Jurisdiction label, if set. */
121 jurisdiction: string | undefined;
122 /** ISO 3166-2 subdivision code, if set. */
123 state: string | undefined;
124 /** High-level tax type, if set. */
125 taxType: TaxRateTaxType | undefined;
126 /** User-defined metadata (Alchemy ownership keys stripped). */
127 metadata: Record<string, string>;
128 /** Unix timestamp when the tax rate was created. */
129 created: number;
130 /** Whether the tax rate exists in live mode. */
131 livemode: boolean;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A Stripe Tax Rate — a percent tax applied to invoices, subscriptions,
139 * and Checkout Sessions. `percentage` and `inclusive` are immutable and
140 * changing them replaces the tax rate. Display name, active, description,
141 * jurisdiction fields, and metadata update in place.
142 *
143 * Stripe does not hard-delete tax rates; destroying this resource
144 * deactivates it (`active: false`).
145 *
146 * @see https://docs.stripe.com/api/tax_rates
147 *
148 * ### Creating a Tax Rate
149 * **Example:** Exclusive sales tax
150 * ```typescript
151 * const vat = yield* Stripe.TaxRate("vat", {
152 * displayName: "VAT",
153 * percentage: 20,
154 * inclusive: false,
155 * });
156 * ```
157 *
158 * **Example:** Inclusive tax with metadata
159 * ```typescript
160 * const gst = yield* Stripe.TaxRate("gst", {
161 * displayName: "GST",
162 * percentage: 10,
163 * inclusive: true,
164 * taxType: "gst",
165 * metadata: { region: "au" },
166 * });
167 * ```
168 *
169 * ### Updating a Tax Rate
170 * **Example:** Rename and pause
171 * ```typescript
172 * const vat = yield* Stripe.TaxRate("vat", {
173 * displayName: "VAT (paused)",
174 * percentage: 20,
175 * inclusive: false,
176 * active: false,
177 * metadata: { region: "eu" },
178 * });
179 * ```
180 *
181 * ### Deactivating a Tax Rate
182 * **Example:** Destroy deactivates rather than deleting
183 * ```typescript
184 * // stack.destroy() / resource removal sets active: false
185 * const vat = yield* Stripe.TaxRate("vat", {
186 * displayName: "VAT",
187 * percentage: 20,
188 * inclusive: false,
189 * });
190 * ```
191 *
192 * @resource
193 * @product Tax
194 */
195export const TaxRate = Resource<TaxRate>("Stripe.TaxRate");
196
197export class TaxRateNotResolved extends Data.TaggedError(
198 "Stripe.TaxRateNotResolved",
199)<{
200 displayName: string;
201}> {}
202
203type TaxRateAttributes = TaxRate["Attributes"];
204
205const userMetadata = (
206 metadata: Record<string, string | undefined> | null | undefined,
207): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
208
209const toDisplayName = (
210 id: string,
211 displayName: string | undefined,
212 existing?: string,
213) =>
214 Effect.gen(function* () {
215 return (
216 displayName ??
217 existing ??
218 (yield* createPhysicalName({ id, maxLength: DISPLAY_NAME_MAX_LENGTH }))
219 );
220 });
221
222const toAttrs = (rate: StripeTaxRate): TaxRateAttributes => ({
223 id: rate.id,
224 displayName: rate.display_name,
225 percentage: rate.percentage,
226 inclusive: rate.inclusive,
227 active: rate.active,
228 description: rate.description ?? undefined,
229 country: rate.country ?? undefined,
230 jurisdiction: rate.jurisdiction ?? undefined,
231 state: rate.state ?? undefined,
232 taxType: (rate.tax_type ?? undefined) as TaxRateTaxType | undefined,
233 metadata: userMetadata(rate.metadata),
234 created: rate.created,
235 livemode: rate.livemode,
236});
237
238const isMissingTaxRate = isMissingStripeResource;
239
240const getById = (taxRate: string) =>
241 GetTaxRate({ tax_rate: taxRate }).pipe(
242 Effect.catchIf(isMissingTaxRate, () => Effect.succeed(undefined)),
243 );
244
245const listByActive = Effect.fn(function* (active: boolean) {
246 const rates: StripeTaxRate[] = [];
247 let startingAfter: string | undefined;
248 for (let page = 0; page < LIST_MAX_PAGES; page++) {
249 const response = yield* GetTaxRates({
250 active,
251 limit: LIST_PAGE_SIZE,
252 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
253 });
254 rates.push(...response.data);
255 if (!response.has_more || response.data.length === 0) {
256 break;
257 }
258 startingAfter = response.data[response.data.length - 1]?.id;
259 if (startingAfter === undefined) {
260 break;
261 }
262 }
263 return rates;
264});
265
266const listAllTaxRates = Effect.fn(function* () {
267 const [active, inactive] = yield* Effect.all(
268 [listByActive(true), listByActive(false)],
269 { concurrency: 2 },
270 );
271 const seen = new Set<string>();
272 const rates: StripeTaxRate[] = [];
273 for (const rate of [...active, ...inactive]) {
274 if (seen.has(rate.id)) continue;
275 seen.add(rate.id);
276 rates.push(rate);
277 }
278 return rates;
279});
280
281const findByAlchemyId = Effect.fn(function* (id: string) {
282 const rates = yield* listAllTaxRates();
283 const matches: StripeTaxRate[] = [];
284 for (const rate of rates) {
285 if (yield* hasAlchemyMetadata(id, tagRecord(rate.metadata))) {
286 matches.push(rate);
287 }
288 }
289 matches.sort((a, b) => b.created - a.created);
290 return matches[0];
291});
292
293const observe = Effect.fn(function* (input: {
294 id?: string;
295 logicalId: string;
296}) {
297 if (input.id !== undefined) {
298 const byId = yield* getById(input.id);
299 if (byId !== undefined) return byId;
300 }
301 return yield* findByAlchemyId(input.logicalId);
302});
303
304const desiredMetadata = Effect.fn(function* (
305 id: string,
306 metadata: Record<string, string> | undefined,
307) {
308 return {
309 ...toMetadata(metadata),
310 ...(yield* createInternalMetadata(id)),
311 };
312});
313
314const shouldReplace = (
315 news: TaxRateProps,
316 output: TaxRateAttributes | undefined,
317): boolean => {
318 if (output === undefined) return false;
319 if (news.percentage !== output.percentage) return true;
320 if (news.inclusive !== output.inclusive) return true;
321 return false;
322};
323
324export const TaxRateProvider = () =>
325 Provider.succeed(TaxRate, {
326 stables: ["id", "percentage", "inclusive", "created", "livemode"],
327
328 diff: Effect.fn(function* ({ news, output }) {
329 if (!isResolved(news)) return undefined;
330 if (shouldReplace(news, output)) {
331 return { action: "replace" } as const;
332 }
333 return undefined;
334 }),
335
336 read: Effect.fn(function* ({ id, output }) {
337 const existing = yield* observe({
338 id: output?.id,
339 logicalId: id,
340 });
341 if (existing === undefined) return undefined;
342 const attrs = toAttrs(existing);
343 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
344 ? attrs
345 : Unowned(attrs);
346 }),
347
348 list: Effect.fn(function* () {
349 // Default list API is active tax rates; inactive (deactivated) rows
350 // stay in Stripe but must not re-enter nuke. Filter to alchemy_stack
351 // so account-wide teardown only touches our rows.
352 const rates = yield* listByActive(true);
353 return rates
354 .filter((rate) => {
355 const metadata = tagRecord(rate.metadata);
356 return metadata[alchemyMetadataKeys.stack] !== undefined;
357 })
358 .map(toAttrs);
359 }),
360
361 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
362 const displayName = yield* toDisplayName(
363 id,
364 news.displayName,
365 output?.displayName,
366 );
367 const metadata = yield* desiredMetadata(id, news.metadata);
368 const desiredActive = news.active ?? true;
369
370 let current: StripeTaxRate | undefined = yield* observe({
371 id: output?.id,
372 logicalId: id,
373 });
374 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
375 current = undefined;
376 }
377
378 if (current === undefined) {
379 current = yield* CreateTaxRate({
380 display_name: displayName,
381 percentage: news.percentage,
382 inclusive: news.inclusive,
383 active: desiredActive,
384 metadata,
385 ...(news.description !== undefined
386 ? { description: news.description }
387 : {}),
388 ...(news.country !== undefined ? { country: news.country } : {}),
389 ...(news.jurisdiction !== undefined
390 ? { jurisdiction: news.jurisdiction }
391 : {}),
392 ...(news.state !== undefined ? { state: news.state } : {}),
393 ...(news.taxType !== undefined ? { tax_type: news.taxType } : {}),
394 }).pipe(
395 withRequestOptions({
396 idempotencyKey: `alchemy-tax-rate-${instanceId}`,
397 }),
398 );
399 }
400
401 if (current === undefined) {
402 return yield* new TaxRateNotResolved({ displayName });
403 }
404
405 const observedMetadata = tagRecord(current.metadata);
406 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
407 const metadataChanged = upsert.length > 0 || removed.length > 0;
408 const displayNameChanged = current.display_name !== displayName;
409 const activeChanged = current.active !== desiredActive;
410 const descriptionChanged =
411 news.description !== undefined &&
412 (current.description ?? undefined) !== news.description;
413 const countryChanged =
414 news.country !== undefined &&
415 (current.country ?? undefined) !== news.country;
416 const jurisdictionChanged =
417 news.jurisdiction !== undefined &&
418 (current.jurisdiction ?? undefined) !== news.jurisdiction;
419 const stateChanged =
420 news.state !== undefined && (current.state ?? undefined) !== news.state;
421 const taxTypeChanged =
422 news.taxType !== undefined &&
423 (current.tax_type ?? undefined) !== news.taxType;
424
425 if (
426 !displayNameChanged &&
427 !activeChanged &&
428 !descriptionChanged &&
429 !countryChanged &&
430 !jurisdictionChanged &&
431 !stateChanged &&
432 !taxTypeChanged &&
433 !metadataChanged
434 ) {
435 return toAttrs(current);
436 }
437
438 const updated = yield* UpdateTaxRate({
439 tax_rate: current.id,
440 ...(displayNameChanged ? { display_name: displayName } : {}),
441 ...(activeChanged ? { active: desiredActive } : {}),
442 ...(descriptionChanged ? { description: news.description } : {}),
443 ...(countryChanged ? { country: news.country } : {}),
444 ...(jurisdictionChanged ? { jurisdiction: news.jurisdiction } : {}),
445 ...(stateChanged ? { state: news.state } : {}),
446 ...(taxTypeChanged ? { tax_type: news.taxType } : {}),
447 ...(metadataChanged
448 ? {
449 metadata: {
450 ...Object.fromEntries(
451 upsert.map((tag) => [tag.Key, tag.Value]),
452 ),
453 ...Object.fromEntries(removed.map((key) => [key, ""])),
454 },
455 }
456 : {}),
457 });
458 return toAttrs(updated);
459 }),
460
461 delete: Effect.fn(function* ({ output }) {
462 const existing = yield* getById(output.id);
463 if (existing === undefined || !existing.active) return;
464 yield* UpdateTaxRate({
465 tax_rate: existing.id,
466 active: false,
467 }).pipe(Effect.catchIf(isMissingTaxRate, () => Effect.void));
468 }),
469 });