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