1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetPrices,
4 GetPrice,
5 CreatePrice,
6 UpdatePrice,
7 type Price as StripePrice,
8 type PriceProduct,
9} from "@distilled.cloud/stripe/stripe";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import { Unowned } from "../AdoptPolicy.ts";
13import { isResolved } from "../Diff.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 { Product } from "./Product.ts";
26import type { Providers } from "./Providers.ts";
27import { isMissingStripeResource } from "./missing.ts";
28
29const LIST_PAGE_SIZE = 100;
30const LIST_MAX_PAGES = 100;
31
32export type PriceInterval = "day" | "month" | "week" | "year";
33export type PriceUsageType = "licensed" | "metered";
34export type PriceType = "one_time" | "recurring";
35
36export interface PriceRecurring {
37 /**
38 * Billing frequency. Create-only — changing it replaces the price.
39 */
40 interval: PriceInterval;
41 /**
42 * Number of intervals between billings. Maximum of three years
43 * (`3` years, `36` months, or `156` weeks).
44 * @default 1
45 */
46 intervalCount?: number;
47 /**
48 * How quantity per period is determined. `licensed` bills the
49 * subscription quantity; `metered` aggregates usage records.
50 * @default "licensed"
51 */
52 usageType?: PriceUsageType;
53 /**
54 * Meter that tracks usage for a metered price. Create-only.
55 */
56 meter?: string;
57 /**
58 * Default trial length (days) when subscribing with
59 * `trial_from_plan=true`. Create-only.
60 */
61 trialPeriodDays?: number;
62}
63
64export interface PriceProps {
65 /**
66 * Stripe Product this price belongs to — the resource, or a `prod_…`
67 * id. Create-only — changing it replaces the price.
68 */
69 product: string | Product;
70 /**
71 * Three-letter ISO currency code, lowercase (e.g. `"usd"`). Create-only
72 * — changing it replaces the price.
73 */
74 currency: string;
75 /**
76 * Amount in the currency's minor units (e.g. cents). Required unless
77 * `unitAmountDecimal` is set or the price is tiered. Create-only —
78 * changing it replaces the price.
79 */
80 unitAmount?: number;
81 /**
82 * Decimal amount in the currency's minor units, at most 12 decimal
83 * places. Mutually exclusive with `unitAmount`. Create-only.
84 */
85 unitAmountDecimal?: string;
86 /**
87 * Recurring billing configuration. Omit for a one-time price.
88 * Create-only — changing it replaces the price.
89 */
90 recurring?: PriceRecurring;
91 /**
92 * Brief description of the price, hidden from customers.
93 */
94 nickname?: string;
95 /**
96 * Whether the price can be used for new purchases.
97 * @default true
98 */
99 active?: boolean;
100 /**
101 * Lookup key used to retrieve this price from a static string (max 200
102 * characters).
103 */
104 lookupKey?: string;
105 /**
106 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
107 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
108 * not contain `:`.
109 */
110 metadata?: Record<string, string>;
111}
112
113export type Price = Resource<
114 "Stripe.Price",
115 PriceProps,
116 {
117 /** Stripe price id (`price_…`). */
118 id: string;
119 /** Id of the product this price belongs to. */
120 product: string;
121 /** Three-letter ISO currency code. */
122 currency: string;
123 /** Amount in the currency's minor units, or `undefined` when unset. */
124 unitAmount: number | undefined;
125 /** Decimal amount in the currency's minor units, or `undefined`. */
126 unitAmountDecimal: string | undefined;
127 /** Whether the price can be used for new purchases. */
128 active: boolean;
129 /** Brief description of the price, hidden from customers. */
130 nickname: string | undefined;
131 /** Lookup key, if set. */
132 lookupKey: string | undefined;
133 /** `one_time` or `recurring`. */
134 type: PriceType;
135 /** Recurring billing configuration, if this is a recurring price. */
136 recurring: PriceRecurring | undefined;
137 /** User-defined metadata (Alchemy ownership keys stripped). */
138 metadata: Record<string, string>;
139 /** Unix timestamp when the price was created. */
140 created: number;
141 /** Whether the price exists in live mode. */
142 livemode: boolean;
143 },
144 never,
145 Providers
146>;
147
148/**
149 * A Stripe Price — the unit cost attached to a Product. Currency, amount,
150 * product, and recurring interval are immutable (changing them replaces
151 * the price). Nickname, metadata, lookup key, and `active` update in
152 * place. Prices cannot be deleted; destroy deactivates them
153 * (`active=false`).
154 *
155 * @see https://docs.stripe.com/api/prices
156 *
157 * ### Creating a Price
158 * **Example:** One-time price
159 * ```typescript
160 * const product = yield* Stripe.Product("pro-plan", { name: "Pro Plan" });
161 * const price = yield* Stripe.Price("pro-once", {
162 * product,
163 * currency: "usd",
164 * unitAmount: 2000,
165 * });
166 * ```
167 *
168 * **Example:** Recurring monthly price
169 * ```typescript
170 * const price = yield* Stripe.Price("pro-monthly", {
171 * product,
172 * currency: "usd",
173 * unitAmount: 1500,
174 * recurring: { interval: "month" },
175 * nickname: "Pro monthly",
176 * });
177 * ```
178 *
179 * ### Updating a Price
180 * **Example:** Nickname, metadata, and deactivate
181 * ```typescript
182 * const price = yield* Stripe.Price("pro-monthly", {
183 * product,
184 * currency: "usd",
185 * unitAmount: 1500,
186 * recurring: { interval: "month" },
187 * nickname: "Pro monthly (paused)",
188 * active: false,
189 * metadata: { tier: "pro" },
190 * });
191 * ```
192 *
193 * ### Destroying a Price
194 * **Example:** Deactivate instead of delete
195 * ```typescript
196 * // Stripe cannot delete a used price. `alchemy destroy` sets
197 * // `active: false`.
198 * ```
199 *
200 * @resource
201 * @product Product
202 */
203export const Price = Resource<Price>("Stripe.Price");
204
205export class PriceNotResolved extends Data.TaggedError(
206 "Stripe.PriceNotResolved",
207)<{
208 product: string;
209 currency: string;
210}> {}
211
212const userMetadata = (
213 metadata: Record<string, string | undefined> | null | undefined,
214): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
215
216const productIdOf = (product: PriceProduct): string => {
217 if (typeof product === "string") return product;
218 return product.id;
219};
220
221const alchemyProductId = (product: string | Product): string => {
222 if (typeof product === "string") return product;
223 const id = product.id as unknown;
224 if (typeof id === "string") return id;
225 return String(id);
226};
227
228const toRecurring = (
229 recurring: StripePrice["recurring"],
230): PriceRecurring | undefined => {
231 if (recurring == null) return undefined;
232 return {
233 interval: recurring.interval as PriceInterval,
234 intervalCount: recurring.interval_count,
235 usageType: recurring.usage_type as PriceUsageType,
236 ...(recurring.meter != null ? { meter: recurring.meter } : {}),
237 ...(recurring.trial_period_days != null
238 ? { trialPeriodDays: recurring.trial_period_days }
239 : {}),
240 };
241};
242
243const toAttrs = (price: StripePrice) => ({
244 id: price.id,
245 product: productIdOf(price.product),
246 currency: price.currency,
247 unitAmount: price.unit_amount ?? undefined,
248 unitAmountDecimal: price.unit_amount_decimal ?? undefined,
249 active: price.active,
250 nickname: price.nickname ?? undefined,
251 lookupKey: price.lookup_key ?? undefined,
252 type: price.type as PriceType,
253 recurring: toRecurring(price.recurring),
254 metadata: userMetadata(price.metadata),
255 created: price.created,
256 livemode: price.livemode,
257});
258
259const isMissingPrice = isMissingStripeResource;
260
261const getById = (price: string) =>
262 GetPrice({ price }).pipe(
263 Effect.catchIf(isMissingPrice, () => Effect.succeed(undefined)),
264 );
265
266const listByActive = Effect.fn(function* (active: boolean) {
267 const prices: StripePrice[] = [];
268 let startingAfter: string | undefined;
269 for (let page = 0; page < LIST_MAX_PAGES; page++) {
270 const response = yield* GetPrices({
271 active,
272 limit: LIST_PAGE_SIZE,
273 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
274 });
275 prices.push(...response.data);
276 if (!response.has_more || response.data.length === 0) {
277 break;
278 }
279 startingAfter = response.data[response.data.length - 1]?.id;
280 if (startingAfter === undefined) {
281 break;
282 }
283 }
284 return prices;
285});
286
287const listAllPrices = Effect.fn(function* () {
288 const [active, inactive] = yield* Effect.all(
289 [listByActive(true), listByActive(false)],
290 { concurrency: 2 },
291 );
292 const seen = new Set<string>();
293 const prices: StripePrice[] = [];
294 for (const price of [...active, ...inactive]) {
295 if (seen.has(price.id)) continue;
296 seen.add(price.id);
297 prices.push(price);
298 }
299 return prices;
300});
301
302const findByAlchemyId = Effect.fn(function* (id: string) {
303 const prices = yield* listAllPrices();
304 const matches: StripePrice[] = [];
305 for (const price of prices) {
306 if (yield* hasAlchemyMetadata(id, tagRecord(price.metadata))) {
307 matches.push(price);
308 }
309 }
310 matches.sort((a, b) => b.created - a.created);
311 return matches[0];
312});
313
314const observe = Effect.fn(function* (input: {
315 id?: string;
316 logicalId: string;
317}) {
318 if (input.id !== undefined) {
319 const byId = yield* getById(input.id);
320 if (byId !== undefined) return byId;
321 }
322 return yield* findByAlchemyId(input.logicalId);
323});
324
325const desiredMetadata = Effect.fn(function* (
326 id: string,
327 metadata: Record<string, string> | undefined,
328) {
329 return {
330 ...toMetadata(metadata),
331 ...(yield* createInternalMetadata(id)),
332 };
333});
334
335const recurringEqual = (
336 news: PriceRecurring | undefined,
337 observed: PriceRecurring | undefined,
338): boolean => {
339 if (news === undefined) return true;
340 if (observed === undefined) return false;
341 if (news.interval !== observed.interval) return false;
342 if ((news.intervalCount ?? 1) !== (observed.intervalCount ?? 1)) return false;
343 if ((news.usageType ?? "licensed") !== (observed.usageType ?? "licensed")) {
344 return false;
345 }
346 if ((news.meter ?? undefined) !== (observed.meter ?? undefined)) return false;
347 if (
348 (news.trialPeriodDays ?? undefined) !==
349 (observed.trialPeriodDays ?? undefined)
350 ) {
351 return false;
352 }
353 return true;
354};
355
356const shouldReplace = (
357 news: PriceProps,
358 output: Price["Attributes"] | undefined,
359) => {
360 if (output === undefined) return false;
361 if (alchemyProductId(news.product) !== output.product) return true;
362 if (news.currency !== output.currency) return true;
363 if (news.unitAmount !== undefined && news.unitAmount !== output.unitAmount) {
364 return true;
365 }
366 if (
367 news.unitAmountDecimal !== undefined &&
368 news.unitAmountDecimal !== output.unitAmountDecimal
369 ) {
370 return true;
371 }
372 if (!recurringEqual(news.recurring, output.recurring)) return true;
373 return false;
374};
375
376export const PriceProvider = () =>
377 Provider.succeed(Price, {
378 stables: ["id", "product", "currency", "type", "created", "livemode"],
379
380 diff: Effect.fn(function* ({ news, output }) {
381 if (!isResolved(news)) return undefined;
382 if (shouldReplace(news, output)) {
383 return { action: "replace" } as const;
384 }
385 return undefined;
386 }),
387
388 read: Effect.fn(function* ({ id, output }) {
389 const existing = yield* observe({
390 id: output?.id,
391 logicalId: id,
392 });
393 if (existing === undefined) return undefined;
394 const attrs = toAttrs(existing);
395 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
396 ? attrs
397 : Unowned(attrs);
398 }),
399
400 list: Effect.fn(function* () {
401 // Default list API is active prices; inactive (deactivated) rows
402 // stay in Stripe but must not re-enter nuke. Filter to alchemy_stack
403 // so account-wide teardown only touches our rows.
404 const prices = yield* listByActive(true);
405 return prices
406 .filter((price) => {
407 const metadata = tagRecord(price.metadata);
408 return metadata[alchemyMetadataKeys.stack] !== undefined;
409 })
410 .map(toAttrs);
411 }),
412
413 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
414 const metadata = yield* desiredMetadata(id, news.metadata);
415 const desiredActive = news.active ?? true;
416 const desiredNickname = news.nickname ?? "";
417 const desiredLookupKey = news.lookupKey ?? "";
418
419 let current: StripePrice | undefined = yield* observe({
420 id: output?.id,
421 logicalId: id,
422 });
423 // A previous generation (same logical id, different immutable
424 // fields) must not be reused — Stripe prices cannot change amount,
425 // currency, product, or recurring interval.
426 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
427 current = undefined;
428 }
429
430 if (current === undefined) {
431 current = yield* CreatePrice({
432 product: alchemyProductId(news.product),
433 currency: news.currency,
434 active: desiredActive,
435 metadata,
436 ...(news.unitAmount !== undefined
437 ? { unit_amount: news.unitAmount }
438 : {}),
439 ...(news.unitAmountDecimal !== undefined
440 ? { unit_amount_decimal: news.unitAmountDecimal }
441 : {}),
442 ...(news.recurring !== undefined
443 ? {
444 recurring: {
445 interval: news.recurring.interval,
446 ...(news.recurring.intervalCount !== undefined
447 ? { interval_count: news.recurring.intervalCount }
448 : {}),
449 ...(news.recurring.usageType !== undefined
450 ? { usage_type: news.recurring.usageType }
451 : {}),
452 ...(news.recurring.meter !== undefined
453 ? { meter: news.recurring.meter }
454 : {}),
455 ...(news.recurring.trialPeriodDays !== undefined
456 ? { trial_period_days: news.recurring.trialPeriodDays }
457 : {}),
458 },
459 }
460 : {}),
461 ...(desiredNickname.length > 0 ? { nickname: desiredNickname } : {}),
462 ...(desiredLookupKey.length > 0
463 ? { lookup_key: desiredLookupKey, transfer_lookup_key: true }
464 : {}),
465 }).pipe(
466 withRequestOptions({
467 idempotencyKey: `alchemy-price-${instanceId}`,
468 }),
469 );
470 }
471
472 if (current === undefined) {
473 return yield* new PriceNotResolved({
474 product: alchemyProductId(news.product),
475 currency: news.currency,
476 });
477 }
478
479 const observedMetadata = tagRecord(current.metadata);
480 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
481 const metadataChanged = upsert.length > 0 || removed.length > 0;
482 const activeChanged = current.active !== desiredActive;
483 const nicknameChanged = (current.nickname ?? "") !== desiredNickname;
484 const lookupKeyChanged = (current.lookup_key ?? "") !== desiredLookupKey;
485
486 if (
487 !activeChanged &&
488 !nicknameChanged &&
489 !lookupKeyChanged &&
490 !metadataChanged
491 ) {
492 return toAttrs(current);
493 }
494
495 const updated = yield* UpdatePrice({
496 price: current.id,
497 ...(activeChanged ? { active: desiredActive } : {}),
498 ...(nicknameChanged ? { nickname: desiredNickname } : {}),
499 ...(lookupKeyChanged
500 ? { lookup_key: desiredLookupKey, transfer_lookup_key: true }
501 : {}),
502 ...(metadataChanged
503 ? {
504 metadata: {
505 ...Object.fromEntries(
506 upsert.map((tag) => [tag.Key, tag.Value]),
507 ),
508 ...Object.fromEntries(removed.map((key) => [key, ""])),
509 },
510 }
511 : {}),
512 });
513 return toAttrs(updated);
514 }),
515
516 delete: Effect.fn(function* ({ output }) {
517 const existing = yield* getById(output.id);
518 if (existing === undefined || !existing.active) return;
519 yield* UpdatePrice({
520 price: existing.id,
521 active: false,
522 }).pipe(Effect.catchIf(isMissingPrice, () => Effect.void));
523 }),
524 });