1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteProduct,
4 GetProducts,
5 GetProduct,
6 CreateProduct,
7 UpdateProduct,
8 type Product as StripeProduct,
9} from "@distilled.cloud/stripe/stripe";
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 { arrayEquals } from "../Util/equal.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 NAME_MAX_LENGTH = 250;
30const LIST_PAGE_SIZE = 100;
31const LIST_MAX_PAGES = 100;
32
33export interface ProductProps {
34 /**
35 * Display name of the product. If omitted, a unique name is generated
36 * from the stack, stage, and logical id.
37 */
38 name?: string;
39 /**
40 * Description meant to be displayable to the customer.
41 */
42 description?: string;
43 /**
44 * Whether the product is available for purchase.
45 * @default true
46 */
47 active?: boolean;
48 /**
49 * Up to 8 image URLs meant to be displayable to the customer.
50 */
51 images?: string[];
52 /**
53 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
54 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
55 * not contain `:`.
56 */
57 metadata?: Record<string, string>;
58}
59
60export type Product = Resource<
61 "Stripe.Product",
62 ProductProps,
63 {
64 /** Stripe product id (`prod_…`). */
65 id: string;
66 /** Display name of the product. */
67 name: string;
68 /** Description meant to be displayable to the customer. */
69 description: string | undefined;
70 /** Whether the product is available for purchase. */
71 active: boolean;
72 /** Image URLs meant to be displayable to the customer. */
73 images: string[];
74 /** User-defined metadata (Alchemy ownership keys stripped). */
75 metadata: Record<string, string>;
76 /** Unix timestamp when the product was created. */
77 created: number;
78 /** Whether the product exists in live mode. */
79 livemode: boolean;
80 },
81 never,
82 Providers
83>;
84
85/**
86 * A Stripe Product — the catalog item that Prices, invoices, and Checkout
87 * attach to. Name, description, active, images, and metadata are updated
88 * in place. Deleting a product is only possible when it has no Prices.
89 *
90 * @see https://docs.stripe.com/api/products
91 *
92 * ### Creating a Product
93 * **Example:** Generated name
94 * ```typescript
95 * const product = yield* Stripe.Product("pro-plan");
96 * ```
97 *
98 * **Example:** Named product with description
99 * ```typescript
100 * const product = yield* Stripe.Product("pro-plan", {
101 * name: "Pro Plan",
102 * description: "Billed monthly",
103 * });
104 * ```
105 *
106 * ### Catalog details
107 * **Example:** Images, metadata, and inactive product
108 * ```typescript
109 * const product = yield* Stripe.Product("pro-plan", {
110 * name: "Pro Plan",
111 * description: "Billed monthly",
112 * active: false,
113 * images: ["https://example.com/pro.png"],
114 * metadata: { tier: "pro" },
115 * });
116 * ```
117 *
118 * ### Destroying a Product
119 * **Example:** Delete when it has no prices
120 * ```typescript
121 * // alchemy destroy deletes the Product. If prices remain, Stripe
122 * // rejects the delete and Alchemy archives it (`active: false`).
123 * ```
124 *
125 * @resource
126 * @product Product
127 */
128export const Product = Resource<Product>("Stripe.Product");
129
130const userMetadata = (
131 metadata: Record<string, string | undefined> | null | undefined,
132): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
133
134const toName = (id: string, name: string | undefined, existing?: string) =>
135 Effect.gen(function* () {
136 return (
137 name ??
138 existing ??
139 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
140 );
141 });
142
143const toAttrs = (product: StripeProduct) => ({
144 id: product.id,
145 name: product.name,
146 description: product.description ?? undefined,
147 active: product.active,
148 images: product.images,
149 metadata: userMetadata(product.metadata),
150 created: product.created,
151 livemode: product.livemode,
152});
153
154const isMissingProduct = isMissingStripeResource;
155
156const getById = (id: string) =>
157 GetProduct({ id }).pipe(
158 Effect.catchIf(isMissingProduct, () => Effect.succeed(undefined)),
159 );
160
161const listByActive = Effect.fn(function* (active: boolean) {
162 const products: StripeProduct[] = [];
163 let startingAfter: string | undefined;
164 for (let page = 0; page < LIST_MAX_PAGES; page++) {
165 const response = yield* GetProducts({
166 active,
167 limit: LIST_PAGE_SIZE,
168 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
169 });
170 products.push(...response.data);
171 if (!response.has_more || response.data.length === 0) {
172 break;
173 }
174 startingAfter = response.data[response.data.length - 1]?.id;
175 if (startingAfter === undefined) {
176 break;
177 }
178 }
179 return products;
180});
181
182const listAllProducts = Effect.fn(function* () {
183 const [active, inactive] = yield* Effect.all(
184 [listByActive(true), listByActive(false)],
185 { concurrency: 2 },
186 );
187 const seen = new Set<string>();
188 const products: StripeProduct[] = [];
189 for (const product of [...active, ...inactive]) {
190 if (seen.has(product.id)) continue;
191 seen.add(product.id);
192 products.push(product);
193 }
194 return products;
195});
196
197const findByAlchemyId = Effect.fn(function* (id: string) {
198 const products = yield* listAllProducts();
199 const matches: StripeProduct[] = [];
200 for (const product of products) {
201 if (yield* hasAlchemyMetadata(id, tagRecord(product.metadata))) {
202 matches.push(product);
203 }
204 }
205 matches.sort((a, b) => b.created - a.created);
206 return matches[0];
207});
208
209const observe = Effect.fn(function* (input: {
210 id?: string;
211 logicalId: string;
212}) {
213 if (input.id !== undefined) {
214 const byId = yield* getById(input.id);
215 if (byId !== undefined) return byId;
216 }
217 return yield* findByAlchemyId(input.logicalId);
218});
219
220const desiredMetadata = Effect.fn(function* (
221 id: string,
222 metadata: Record<string, string> | undefined,
223) {
224 return {
225 ...toMetadata(metadata),
226 ...(yield* createInternalMetadata(id)),
227 };
228});
229
230export const ProductProvider = () =>
231 Provider.succeed(Product, {
232 stables: ["id", "created", "livemode"],
233
234 diff: Effect.fn(function* ({ news }) {
235 if (!isResolved(news)) return undefined;
236 return undefined;
237 }),
238
239 read: Effect.fn(function* ({ id, output }) {
240 const existing = yield* observe({
241 id: output?.id,
242 logicalId: id,
243 });
244 if (existing === undefined) return undefined;
245 const attrs = toAttrs(existing);
246 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
247 ? attrs
248 : Unowned(attrs);
249 }),
250
251 list: Effect.fn(function* () {
252 const products = yield* listAllProducts();
253 return products
254 .filter((product) => {
255 const metadata = tagRecord(product.metadata);
256 return metadata[alchemyMetadataKeys.stack] !== undefined;
257 })
258 .map(toAttrs);
259 }),
260
261 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
262 const name = yield* toName(id, news.name, output?.name);
263 const metadata = yield* desiredMetadata(id, news.metadata);
264 const desiredActive = news.active ?? true;
265 const desiredDescription = news.description ?? "";
266 const desiredImages = news.images ?? [];
267
268 let current = yield* observe({
269 id: output?.id,
270 logicalId: id,
271 });
272
273 if (current === undefined) {
274 current = yield* CreateProduct({
275 name,
276 active: desiredActive,
277 ...(desiredDescription.length > 0
278 ? { description: desiredDescription }
279 : {}),
280 ...(desiredImages.length > 0 ? { images: desiredImages } : {}),
281 metadata,
282 }).pipe(
283 withRequestOptions({
284 idempotencyKey: `alchemy-product-${instanceId}`,
285 }),
286 );
287 }
288
289 const observedMetadata = tagRecord(current.metadata);
290 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
291 const metadataChanged = upsert.length > 0 || removed.length > 0;
292 const nameChanged = current.name !== name;
293 const activeChanged = current.active !== desiredActive;
294 const descriptionChanged =
295 (current.description ?? "") !== desiredDescription;
296 const imagesChanged = !arrayEquals(current.images, desiredImages);
297
298 if (
299 !nameChanged &&
300 !activeChanged &&
301 !descriptionChanged &&
302 !imagesChanged &&
303 !metadataChanged
304 ) {
305 return toAttrs(current);
306 }
307
308 const updated = yield* UpdateProduct({
309 id: current.id,
310 ...(nameChanged ? { name } : {}),
311 ...(activeChanged ? { active: desiredActive } : {}),
312 ...(descriptionChanged ? { description: desiredDescription } : {}),
313 ...(imagesChanged
314 ? { images: desiredImages.length > 0 ? desiredImages : "" }
315 : {}),
316 ...(metadataChanged
317 ? {
318 metadata: {
319 ...Object.fromEntries(
320 upsert.map((tag) => [tag.Key, tag.Value]),
321 ),
322 ...Object.fromEntries(removed.map((key) => [key, ""])),
323 },
324 }
325 : {}),
326 });
327 return toAttrs(updated);
328 }),
329
330 delete: Effect.fn(function* ({ output }) {
331 yield* DeleteProduct({ id: output.id }).pipe(
332 Effect.catchTag("ProductHasPrices", () =>
333 UpdateProduct({ id: output.id, active: false }).pipe(
334 Effect.catchIf(isMissingProduct, () => Effect.void),
335 ),
336 ),
337 Effect.catchIf(isMissingProduct, () => Effect.void),
338 );
339 }),
340 });