1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteProductFeature,
4 GetProducts,
5 GetProductFeatures,
6 GetProductFeature,
7 CreateProductFeature,
8 type Product as StripeProduct,
9 type ProductFeature as StripeProductFeature,
10} from "@distilled.cloud/stripe/stripe";
11import * as Effect from "effect/Effect";
12import { isResolved } from "../Diff.ts";
13import * as Provider from "../Provider.ts";
14import { Resource } from "../Resource.ts";
15import { tagRecord } from "../Tags.ts";
16import { alchemyMetadataKeys } from "./Metadata.ts";
17import type { Providers } from "./Providers.ts";
18import { isMissingStripeResource } from "./missing.ts";
19
20const LIST_PAGE_SIZE = 100;
21const LIST_MAX_PAGES = 100;
22const LIST_CONCURRENCY = 10;
23
24export interface ProductFeatureProps {
25 /**
26 * Id of the Stripe Product to attach the feature to (`prod_…`).
27 * Changing it replaces the attachment.
28 */
29 product: string;
30 /**
31 * Id of the Entitlements Feature to attach (`feat_…`). Changing it
32 * replaces the attachment.
33 */
34 entitlementFeature: string;
35}
36
37export type ProductFeature = Resource<
38 "Stripe.ProductFeature",
39 ProductFeatureProps,
40 {
41 /** Stripe product-feature id (`prodft_…`). */
42 id: string;
43 /** Id of the product this feature is attached to (`prod_…`). */
44 product: string;
45 /** Id of the attached entitlements feature (`feat_…`). */
46 entitlementFeature: string;
47 /** Whether the attachment exists in live mode. */
48 livemode: boolean;
49 },
50 never,
51 Providers
52>;
53
54/**
55 * A Stripe Product Feature — the attachment of an Entitlements Feature
56 * to a Product. When a customer purchases a product that has a feature
57 * attached, Stripe creates an entitlement to that feature. Existence-only:
58 * there is nothing to update in place; changing `product` or
59 * `entitlementFeature` replaces the attachment. Destroy deletes it.
60 *
61 * Product features have no metadata of their own. Ownership for
62 * account-wide `list()` (nuke) is inferred from the parent Product's
63 * Alchemy metadata.
64 *
65 * @see https://docs.stripe.com/api/product-feature
66 *
67 * ### Attaching a Feature
68 * **Example:** Attach a feature to a product
69 * ```typescript
70 * const product = yield* Stripe.Product("pro-plan", { name: "Pro Plan" });
71 * const analytics = yield* Stripe.EntitlementsFeature("analytics", {
72 * lookupKey: "analytics",
73 * name: "Analytics",
74 * });
75 * const attachment = yield* Stripe.ProductFeature("pro-analytics", {
76 * product: product.id,
77 * entitlementFeature: analytics.id,
78 * });
79 * ```
80 *
81 * ### Replacing an Attachment
82 * **Example:** Point the product at a different feature
83 * ```typescript
84 * const reporting = yield* Stripe.EntitlementsFeature("reporting", {
85 * lookupKey: "reporting",
86 * name: "Reporting",
87 * });
88 * const attachment = yield* Stripe.ProductFeature("pro-analytics", {
89 * product: product.id,
90 * entitlementFeature: reporting.id,
91 * });
92 * ```
93 *
94 * @resource
95 * @product Product
96 */
97export const ProductFeature = Resource<ProductFeature>("Stripe.ProductFeature");
98
99type ProductFeatureAttributes = ProductFeature["Attributes"];
100
101const toAttrs = (
102 product: string,
103 feature: StripeProductFeature,
104): ProductFeatureAttributes => ({
105 id: feature.id,
106 product,
107 entitlementFeature: feature.entitlement_feature.id,
108 livemode: feature.livemode,
109});
110
111const isMissing = isMissingStripeResource;
112
113const getById = (product: string, id: string) =>
114 GetProductFeature({ product, id }).pipe(
115 Effect.catchIf(isMissing, () => Effect.succeed(undefined)),
116 );
117
118const listFeatures = Effect.fn(function* (product: string) {
119 const features: StripeProductFeature[] = [];
120 let startingAfter: string | undefined;
121 for (let page = 0; page < LIST_MAX_PAGES; page++) {
122 const response = yield* GetProductFeatures({
123 product,
124 limit: LIST_PAGE_SIZE,
125 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
126 }).pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
127 if (response === undefined) {
128 break;
129 }
130 features.push(...response.data);
131 if (!response.has_more || response.data.length === 0) {
132 break;
133 }
134 startingAfter = response.data[response.data.length - 1]?.id;
135 if (startingAfter === undefined) {
136 break;
137 }
138 }
139 return features;
140});
141
142const listProductsByActive = Effect.fn(function* (active: boolean) {
143 const products: StripeProduct[] = [];
144 let startingAfter: string | undefined;
145 for (let page = 0; page < LIST_MAX_PAGES; page++) {
146 const response = yield* GetProducts({
147 active,
148 limit: LIST_PAGE_SIZE,
149 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
150 });
151 products.push(...response.data);
152 if (!response.has_more || response.data.length === 0) {
153 break;
154 }
155 startingAfter = response.data[response.data.length - 1]?.id;
156 if (startingAfter === undefined) {
157 break;
158 }
159 }
160 return products;
161});
162
163const listAlchemyProducts = Effect.fn(function* () {
164 const [active, inactive] = yield* Effect.all(
165 [listProductsByActive(true), listProductsByActive(false)],
166 { concurrency: 2 },
167 );
168 const seen = new Set<string>();
169 const products: StripeProduct[] = [];
170 for (const product of [...active, ...inactive]) {
171 if (seen.has(product.id)) continue;
172 seen.add(product.id);
173 if (tagRecord(product.metadata)[alchemyMetadataKeys.stack] !== undefined) {
174 products.push(product);
175 }
176 }
177 return products;
178});
179
180const observe = Effect.fn(function* (input: {
181 product?: string;
182 id?: string;
183 entitlementFeature?: string;
184}) {
185 if (input.product !== undefined && input.id !== undefined) {
186 const byId = yield* getById(input.product, input.id);
187 if (byId !== undefined) return byId;
188 }
189 if (input.product !== undefined && input.entitlementFeature !== undefined) {
190 const features = yield* listFeatures(input.product);
191 return features.find(
192 (feature) => feature.entitlement_feature.id === input.entitlementFeature,
193 );
194 }
195 return undefined;
196});
197
198const shouldReplace = (
199 news: ProductFeatureProps,
200 output: ProductFeatureAttributes | undefined,
201): boolean => {
202 if (output === undefined) return false;
203 if (news.product !== output.product) return true;
204 if (news.entitlementFeature !== output.entitlementFeature) return true;
205 return false;
206};
207
208export const ProductFeatureProvider = () =>
209 Provider.succeed(ProductFeature, {
210 stables: ["id", "product", "entitlementFeature", "livemode"],
211 nuke: { dependsOn: ["Stripe.Product"] },
212
213 diff: Effect.fn(function* ({ news, output }) {
214 if (!isResolved(news)) return undefined;
215 if (shouldReplace(news, output)) {
216 return { action: "replace" } as const;
217 }
218 return undefined;
219 }),
220
221 read: Effect.fn(function* ({ output, olds }) {
222 const product =
223 output?.product ??
224 (typeof olds?.product === "string" ? olds.product : undefined);
225 const entitlementFeature =
226 output?.entitlementFeature ??
227 (typeof olds?.entitlementFeature === "string"
228 ? olds.entitlementFeature
229 : undefined);
230 const existing = yield* observe({
231 product,
232 id: output?.id,
233 entitlementFeature,
234 });
235 if (existing === undefined || product === undefined) return undefined;
236 return toAttrs(product, existing);
237 }),
238
239 list: Effect.fn(function* () {
240 // Product features have no metadata. Fan out from alchemy-owned
241 // products so nuke only tears down attachments we created.
242 const products = yield* listAlchemyProducts();
243 const rows = yield* Effect.forEach(
244 products,
245 (product) =>
246 listFeatures(product.id).pipe(
247 Effect.map((features) =>
248 features.map((feature) => toAttrs(product.id, feature)),
249 ),
250 ),
251 { concurrency: LIST_CONCURRENCY },
252 );
253 return rows.flat();
254 }),
255
256 reconcile: Effect.fn(function* ({ news, output, instanceId }) {
257 let current = yield* observe({
258 product: news.product,
259 id: output?.id,
260 entitlementFeature: news.entitlementFeature,
261 });
262 if (
263 current !== undefined &&
264 shouldReplace(news, toAttrs(news.product, current))
265 ) {
266 current = undefined;
267 }
268
269 if (current === undefined) {
270 current = yield* CreateProductFeature({
271 product: news.product,
272 entitlement_feature: news.entitlementFeature,
273 }).pipe(
274 withRequestOptions({
275 idempotencyKey: `alchemy-product-feature-${instanceId}`,
276 }),
277 Effect.catchIf(
278 (e) => e._tag === "InvalidRequestError" || e._tag === "Conflict",
279 (e) =>
280 observe({
281 product: news.product,
282 entitlementFeature: news.entitlementFeature,
283 }).pipe(
284 Effect.flatMap((found) =>
285 found !== undefined ? Effect.succeed(found) : Effect.fail(e),
286 ),
287 ),
288 ),
289 );
290 }
291
292 return toAttrs(news.product, current);
293 }),
294
295 delete: Effect.fn(function* ({ output }) {
296 yield* DeleteProductFeature({
297 product: output.product,
298 id: output.id,
299 }).pipe(Effect.catchIf(isMissing, () => Effect.void));
300 }),
301 });