1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetShippingRates,
4 GetShippingRate,
5 CreateShippingRate,
6 UpdateShippingRate,
7 type CreateShippingRateRequestFixedAmountCurrencyOptionsMap,
8 type ShippingRate as StripeShippingRate,
9 type ShippingRateFixedAmountCurrencyOptionsMap,
10 type ShippingRateTaxCode,
11} from "@distilled.cloud/stripe/stripe";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import { Unowned } from "../AdoptPolicy.ts";
15import { deepEqual, isResolved } from "../Diff.ts";
16import { createPhysicalName } from "../PhysicalName.ts";
17import * as Provider from "../Provider.ts";
18import { Resource } from "../Resource.ts";
19import { tagRecord } from "../Tags.ts";
20import {
21 alchemyMetadataKeys,
22 createInternalMetadata,
23 diffMetadata,
24 hasAlchemyMetadata,
25 stripInternalMetadata,
26 toMetadata,
27} from "./Metadata.ts";
28import type { Providers } from "./Providers.ts";
29import { isMissingStripeResource } from "./missing.ts";
30
31const NAME_MAX_LENGTH = 250;
32const LIST_PAGE_SIZE = 100;
33const LIST_MAX_PAGES = 100;
34
35/** The type of calculation used on the shipping rate. */
36export type ShippingRateType = "fixed_amount";
37
38/** Whether the rate is considered inclusive or exclusive of taxes. */
39export type ShippingRateTaxBehavior = "exclusive" | "inclusive" | "unspecified";
40
41/** Unit of time for a delivery-estimate bound. */
42export type ShippingRateDeliveryEstimateUnit =
43 | "business_day"
44 | "day"
45 | "hour"
46 | "month"
47 | "week";
48
49export interface ShippingRateDeliveryEstimateBound {
50 /**
51 * Unit of time for this bound.
52 */
53 unit: ShippingRateDeliveryEstimateUnit;
54 /**
55 * Must be greater than 0.
56 */
57 value: number;
58}
59
60export interface ShippingRateDeliveryEstimate {
61 /**
62 * Lower bound of the estimated range. Omit for no lower bound.
63 * Create-only — changing it replaces the shipping rate.
64 */
65 minimum?: ShippingRateDeliveryEstimateBound;
66 /**
67 * Upper bound of the estimated range. Omit for no upper bound.
68 * Create-only — changing it replaces the shipping rate.
69 */
70 maximum?: ShippingRateDeliveryEstimateBound;
71}
72
73export interface ShippingRateCurrencyOption {
74 /**
75 * Amount (in the currency's minor units) to charge for this currency.
76 */
77 amount: number;
78 /**
79 * Whether this currency option is inclusive of taxes.
80 */
81 taxBehavior?: ShippingRateTaxBehavior;
82}
83
84export interface ShippingRateProps {
85 /**
86 * Customer-facing name shown on Checkout sessions. If omitted, a unique
87 * name is generated from the stack, stage, and logical id. Create-only
88 * — changing it replaces the shipping rate.
89 */
90 displayName?: string;
91 /**
92 * Calculation type. Only `fixed_amount` is supported.
93 * @default "fixed_amount"
94 */
95 type?: ShippingRateType;
96 /**
97 * Amount in the currency's minor units (e.g. cents). Create-only —
98 * changing it replaces the shipping rate.
99 */
100 amount: number;
101 /**
102 * Three-letter ISO currency code, lowercase (e.g. `"usd"`). Create-only
103 * — changing it replaces the shipping rate.
104 */
105 currency: string;
106 /**
107 * Per-currency amounts. Mutable (amounts and tax behavior per currency
108 * can be updated in place).
109 */
110 currencyOptions?: Record<string, ShippingRateCurrencyOption>;
111 /**
112 * Estimated delivery range shown on Checkout sessions. Create-only —
113 * changing it replaces the shipping rate.
114 */
115 deliveryEstimate?: ShippingRateDeliveryEstimate;
116 /**
117 * Whether the rate is considered inclusive of taxes.
118 */
119 taxBehavior?: ShippingRateTaxBehavior;
120 /**
121 * Tax code id (the shipping tax code is `txcd_92010001`). Create-only —
122 * changing it replaces the shipping rate.
123 */
124 taxCode?: string;
125 /**
126 * Whether the shipping rate can be used for new purchases.
127 * @default true
128 */
129 active?: boolean;
130 /**
131 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
132 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
133 * not contain `:`.
134 */
135 metadata?: Record<string, string>;
136}
137
138export type ShippingRate = Resource<
139 "Stripe.ShippingRate",
140 ShippingRateProps,
141 {
142 /** Stripe shipping rate id (`shr_…`). */
143 id: string;
144 /** Customer-facing name shown on Checkout sessions. */
145 displayName: string;
146 /** Calculation type (`fixed_amount`). */
147 type: ShippingRateType;
148 /** Amount in the currency's minor units. */
149 amount: number;
150 /** Three-letter ISO currency code. */
151 currency: string;
152 /** Per-currency amounts, if set. */
153 currencyOptions: Record<string, ShippingRateCurrencyOption> | undefined;
154 /** Whether the shipping rate can be used for new purchases. */
155 active: boolean;
156 /** Estimated delivery range, if set. */
157 deliveryEstimate: ShippingRateDeliveryEstimate | undefined;
158 /** Whether the rate is considered inclusive of taxes. */
159 taxBehavior: ShippingRateTaxBehavior | undefined;
160 /** Tax code id, if set. */
161 taxCode: string | undefined;
162 /** User-defined metadata (Alchemy ownership keys stripped). */
163 metadata: Record<string, string>;
164 /** Unix timestamp when the shipping rate was created. */
165 created: number;
166 /** Whether the shipping rate exists in live mode. */
167 livemode: boolean;
168 },
169 never,
170 Providers
171>;
172
173/**
174 * A Stripe Shipping Rate — the price of shipping presented to customers
175 * and applied to a purchase. Amount, currency, display name, delivery
176 * estimate, and tax code are immutable (changing them replaces the
177 * rate). `active`, metadata, `taxBehavior`, and per-currency
178 * `currencyOptions` update in place. Shipping rates cannot be deleted;
179 * destroy deactivates them (`active=false`).
180 *
181 * @see https://docs.stripe.com/api/shipping_rates
182 *
183 * ### Creating a Shipping Rate
184 * **Example:** Fixed-amount USD rate
185 * ```typescript
186 * const ground = yield* Stripe.ShippingRate("ground", {
187 * displayName: "Ground",
188 * amount: 500,
189 * currency: "usd",
190 * });
191 * ```
192 *
193 * **Example:** Rate with a delivery estimate
194 * ```typescript
195 * const express = yield* Stripe.ShippingRate("express", {
196 * displayName: "Express",
197 * amount: 1500,
198 * currency: "usd",
199 * deliveryEstimate: {
200 * minimum: { unit: "business_day", value: 1 },
201 * maximum: { unit: "business_day", value: 3 },
202 * },
203 * });
204 * ```
205 *
206 * ### Updating a Shipping Rate
207 * **Example:** Pause the rate and retag
208 * ```typescript
209 * const ground = yield* Stripe.ShippingRate("ground", {
210 * displayName: "Ground",
211 * amount: 500,
212 * currency: "usd",
213 * active: false,
214 * metadata: { region: "us" },
215 * });
216 * ```
217 *
218 * ### Deactivating a Shipping Rate
219 * **Example:** Destroy deactivates rather than deleting
220 * ```typescript
221 * // stack.destroy() / resource removal sets active: false
222 * const ground = yield* Stripe.ShippingRate("ground", {
223 * displayName: "Ground",
224 * amount: 500,
225 * currency: "usd",
226 * });
227 * ```
228 *
229 * @resource
230 * @product Product
231 */
232export const ShippingRate = Resource<ShippingRate>("Stripe.ShippingRate");
233
234export class ShippingRateNotResolved extends Data.TaggedError(
235 "Stripe.ShippingRateNotResolved",
236)<{
237 displayName: string;
238 currency: string;
239}> {}
240
241type ShippingRateAttributes = ShippingRate["Attributes"];
242
243const userMetadata = (
244 metadata: Record<string, string | undefined> | null | undefined,
245): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
246
247const taxCodeOf = (
248 taxCode: ShippingRateTaxCode | null | undefined,
249): string | undefined => {
250 if (taxCode == null) return undefined;
251 if (typeof taxCode === "string") return taxCode;
252 return taxCode.id;
253};
254
255const fromWireCurrencyOptions = (
256 options: ShippingRateFixedAmountCurrencyOptionsMap | undefined,
257): Record<string, ShippingRateCurrencyOption> | undefined => {
258 if (options === undefined) return undefined;
259 const out: Record<string, ShippingRateCurrencyOption> = {};
260 for (const [currency, value] of Object.entries(options)) {
261 if (value === undefined) continue;
262 out[currency] = {
263 amount: value.amount,
264 taxBehavior: value.tax_behavior,
265 };
266 }
267 return Object.keys(out).length > 0 ? out : undefined;
268};
269
270const withoutBaseCurrency = <T>(
271 options: Record<string, T> | undefined,
272 currency: string,
273): Record<string, T> | undefined => {
274 if (options === undefined) return undefined;
275 const extra = Object.fromEntries(
276 Object.entries(options).filter(([key]) => key !== currency),
277 );
278 return Object.keys(extra).length > 0 ? extra : undefined;
279};
280
281const toWireCurrencyOptions = (
282 options: Record<string, ShippingRateCurrencyOption> | undefined,
283): CreateShippingRateRequestFixedAmountCurrencyOptionsMap | undefined => {
284 if (options === undefined) return undefined;
285 return Object.fromEntries(
286 Object.entries(options).map(([currency, value]) => [
287 currency,
288 {
289 amount: value.amount,
290 ...(value.taxBehavior !== undefined
291 ? { tax_behavior: value.taxBehavior }
292 : {}),
293 },
294 ]),
295 );
296};
297
298const fromObservedEstimate = (
299 estimate: StripeShippingRate["delivery_estimate"],
300): ShippingRateDeliveryEstimate | undefined => {
301 if (estimate == null) return undefined;
302 const out: ShippingRateDeliveryEstimate = {};
303 if (estimate.minimum != null) {
304 out.minimum = {
305 unit: estimate.minimum.unit,
306 value: estimate.minimum.value,
307 };
308 }
309 if (estimate.maximum != null) {
310 out.maximum = {
311 unit: estimate.maximum.unit,
312 value: estimate.maximum.value,
313 };
314 }
315 return out.minimum !== undefined || out.maximum !== undefined
316 ? out
317 : undefined;
318};
319
320const toAttrs = (rate: StripeShippingRate): ShippingRateAttributes => ({
321 id: rate.id,
322 displayName: rate.display_name ?? "",
323 type: rate.type,
324 amount: rate.fixed_amount?.amount ?? 0,
325 currency: rate.fixed_amount?.currency ?? "",
326 currencyOptions: fromWireCurrencyOptions(rate.fixed_amount?.currency_options),
327 active: rate.active,
328 deliveryEstimate: fromObservedEstimate(rate.delivery_estimate),
329 taxBehavior: rate.tax_behavior ?? undefined,
330 taxCode: taxCodeOf(rate.tax_code),
331 metadata: userMetadata(rate.metadata),
332 created: rate.created,
333 livemode: rate.livemode,
334});
335
336const toDisplayName = (
337 id: string,
338 displayName: string | undefined,
339 existing?: string,
340) =>
341 Effect.gen(function* () {
342 return (
343 displayName ??
344 existing ??
345 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
346 );
347 });
348
349const isMissingShippingRate = isMissingStripeResource;
350
351const getById = (shipping_rate_token: string) =>
352 GetShippingRate({ shipping_rate_token }).pipe(
353 Effect.catchIf(isMissingShippingRate, () => Effect.succeed(undefined)),
354 );
355
356const listByActive = Effect.fn(function* (active: boolean) {
357 const rates: StripeShippingRate[] = [];
358 let startingAfter: string | undefined;
359 for (let page = 0; page < LIST_MAX_PAGES; page++) {
360 const response = yield* GetShippingRates({
361 active,
362 limit: LIST_PAGE_SIZE,
363 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
364 });
365 rates.push(...response.data);
366 if (!response.has_more || response.data.length === 0) {
367 break;
368 }
369 startingAfter = response.data[response.data.length - 1]?.id;
370 if (startingAfter === undefined) {
371 break;
372 }
373 }
374 return rates;
375});
376
377const listAllShippingRates = Effect.fn(function* () {
378 const [active, inactive] = yield* Effect.all(
379 [listByActive(true), listByActive(false)],
380 { concurrency: 2 },
381 );
382 const seen = new Set<string>();
383 const rates: StripeShippingRate[] = [];
384 for (const rate of [...active, ...inactive]) {
385 if (seen.has(rate.id)) continue;
386 seen.add(rate.id);
387 rates.push(rate);
388 }
389 return rates;
390});
391
392const findByAlchemyId = Effect.fn(function* (id: string) {
393 const rates = yield* listAllShippingRates();
394 const matches: StripeShippingRate[] = [];
395 for (const rate of rates) {
396 if (yield* hasAlchemyMetadata(id, tagRecord(rate.metadata))) {
397 matches.push(rate);
398 }
399 }
400 matches.sort((a, b) => b.created - a.created);
401 return matches[0];
402});
403
404const observe = Effect.fn(function* (input: {
405 id?: string;
406 logicalId: string;
407}) {
408 if (input.id !== undefined) {
409 const byId = yield* getById(input.id);
410 if (byId !== undefined) return byId;
411 }
412 return yield* findByAlchemyId(input.logicalId);
413});
414
415const desiredMetadata = Effect.fn(function* (
416 id: string,
417 metadata: Record<string, string> | undefined,
418) {
419 return {
420 ...toMetadata(metadata),
421 ...(yield* createInternalMetadata(id)),
422 };
423});
424
425const shouldReplace = (
426 news: ShippingRateProps,
427 output: ShippingRateAttributes | undefined,
428): boolean => {
429 if (output === undefined) return false;
430 if (news.amount !== output.amount) return true;
431 if (news.currency !== output.currency) return true;
432 if ((news.type ?? "fixed_amount") !== output.type) return true;
433 if (
434 news.displayName !== undefined &&
435 news.displayName !== output.displayName
436 ) {
437 return true;
438 }
439 if (
440 news.taxCode !== undefined &&
441 news.taxCode !== (output.taxCode ?? undefined)
442 ) {
443 return true;
444 }
445 if (
446 news.deliveryEstimate !== undefined &&
447 !deepEqual(news.deliveryEstimate, output.deliveryEstimate, {
448 stripNullish: true,
449 })
450 ) {
451 return true;
452 }
453 return false;
454};
455
456export const ShippingRateProvider = () =>
457 Provider.succeed(ShippingRate, {
458 stables: ["id", "type", "amount", "currency", "created", "livemode"],
459
460 diff: Effect.fn(function* ({ news, output }) {
461 if (!isResolved(news)) return undefined;
462 if (shouldReplace(news, output)) {
463 return { action: "replace" } as const;
464 }
465 return undefined;
466 }),
467
468 read: Effect.fn(function* ({ id, output }) {
469 const existing = yield* observe({
470 id: output?.id,
471 logicalId: id,
472 });
473 if (existing === undefined) return undefined;
474 const attrs = toAttrs(existing);
475 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
476 ? attrs
477 : Unowned(attrs);
478 }),
479
480 list: Effect.fn(function* () {
481 // Default list API can return inactive rates; deactivated rows stay
482 // in Stripe but must not re-enter nuke.
483 const rates = yield* listByActive(true);
484 return rates
485 .filter((rate) => {
486 const metadata = tagRecord(rate.metadata);
487 return metadata[alchemyMetadataKeys.stack] !== undefined;
488 })
489 .map(toAttrs);
490 }),
491
492 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
493 const displayName = yield* toDisplayName(
494 id,
495 news.displayName,
496 output?.displayName,
497 );
498 const metadata = yield* desiredMetadata(id, news.metadata);
499 const desiredActive = news.active ?? true;
500 const desiredType = news.type ?? "fixed_amount";
501 const extraCurrencyOptions = withoutBaseCurrency(
502 news.currencyOptions,
503 news.currency,
504 );
505 const currencyOptions = toWireCurrencyOptions(extraCurrencyOptions);
506
507 let current: StripeShippingRate | undefined = yield* observe({
508 id: output?.id,
509 logicalId: id,
510 });
511 // A previous generation (same logical id, different immutable
512 // fields) must not be reused — Stripe shipping rates cannot change
513 // amount, currency, display name, delivery estimate, or tax code.
514 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
515 current = undefined;
516 }
517
518 if (current === undefined) {
519 current = yield* CreateShippingRate({
520 display_name: displayName,
521 type: desiredType,
522 fixed_amount: {
523 amount: news.amount,
524 currency: news.currency,
525 ...(currencyOptions !== undefined
526 ? { currency_options: currencyOptions }
527 : {}),
528 },
529 metadata,
530 ...(news.deliveryEstimate !== undefined
531 ? {
532 delivery_estimate: {
533 ...(news.deliveryEstimate.minimum !== undefined
534 ? { minimum: news.deliveryEstimate.minimum }
535 : {}),
536 ...(news.deliveryEstimate.maximum !== undefined
537 ? { maximum: news.deliveryEstimate.maximum }
538 : {}),
539 },
540 }
541 : {}),
542 ...(news.taxBehavior !== undefined
543 ? { tax_behavior: news.taxBehavior }
544 : {}),
545 ...(news.taxCode !== undefined ? { tax_code: news.taxCode } : {}),
546 }).pipe(
547 withRequestOptions({
548 idempotencyKey: `alchemy-shipping-rate-${instanceId}`,
549 }),
550 );
551 }
552
553 if (current === undefined) {
554 return yield* new ShippingRateNotResolved({
555 displayName,
556 currency: news.currency,
557 });
558 }
559
560 const observedMetadata = tagRecord(current.metadata);
561 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
562 const metadataChanged = upsert.length > 0 || removed.length > 0;
563 const activeChanged = current.active !== desiredActive;
564 const taxBehaviorChanged =
565 news.taxBehavior !== undefined &&
566 (current.tax_behavior ?? undefined) !== news.taxBehavior;
567 const observedCurrencyOptions = withoutBaseCurrency(
568 fromWireCurrencyOptions(current.fixed_amount?.currency_options),
569 news.currency,
570 );
571 const currencyOptionsChanged =
572 extraCurrencyOptions !== undefined &&
573 !deepEqual(extraCurrencyOptions, observedCurrencyOptions, {
574 stripNullish: true,
575 });
576
577 if (
578 !activeChanged &&
579 !taxBehaviorChanged &&
580 !currencyOptionsChanged &&
581 !metadataChanged
582 ) {
583 return toAttrs(current);
584 }
585
586 const updated = yield* UpdateShippingRate({
587 shipping_rate_token: current.id,
588 ...(activeChanged ? { active: desiredActive } : {}),
589 ...(taxBehaviorChanged ? { tax_behavior: news.taxBehavior } : {}),
590 ...(currencyOptionsChanged
591 ? { fixed_amount: { currency_options: currencyOptions } }
592 : {}),
593 ...(metadataChanged
594 ? {
595 metadata: {
596 ...Object.fromEntries(
597 upsert.map((tag) => [tag.Key, tag.Value]),
598 ),
599 ...Object.fromEntries(removed.map((key) => [key, ""])),
600 },
601 }
602 : {}),
603 });
604 return toAttrs(updated);
605 }),
606
607 delete: Effect.fn(function* ({ output }) {
608 const existing = yield* getById(output.id);
609 if (existing === undefined || !existing.active) return;
610 yield* UpdateShippingRate({
611 shipping_rate_token: existing.id,
612 active: false,
613 }).pipe(Effect.catchIf(isMissingShippingRate, () => Effect.void));
614 }),
615 });