1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetEntitlementsFeatures,
4 GetEntitlementsFeature,
5 CreateEntitlementsFeature,
6 UpdateEntitlementsFeature,
7 type EntitlementsFeature as StripeEntitlementsFeature,
8} from "@distilled.cloud/stripe/stripe";
9import * as Effect from "effect/Effect";
10import { Unowned } from "../AdoptPolicy.ts";
11import { isResolved } from "../Diff.ts";
12import { createPhysicalName } from "../PhysicalName.ts";
13import * as Provider from "../Provider.ts";
14import { Resource } from "../Resource.ts";
15import { tagRecord } from "../Tags.ts";
16import {
17 alchemyMetadataKeys,
18 createInternalMetadata,
19 diffMetadata,
20 hasAlchemyMetadata,
21 stripInternalMetadata,
22 toMetadata,
23} from "./Metadata.ts";
24import type { Providers } from "./Providers.ts";
25import { isMissingStripeResource } from "./missing.ts";
26
27const NAME_MAX_LENGTH = 80;
28const LOOKUP_KEY_MAX_LENGTH = 80;
29const LIST_PAGE_SIZE = 100;
30const LIST_MAX_PAGES = 100;
31
32export interface EntitlementsFeatureProps {
33 /**
34 * Unique system identifier for this feature (max 80 characters). If
35 * omitted, a unique key is generated from the stack, stage, and logical
36 * id. Create-only — changing it replaces the feature.
37 */
38 lookupKey?: string;
39 /**
40 * Feature name for your own purpose, not shown to customers (max 80
41 * characters). If omitted, a unique name is generated from the stack,
42 * stage, and logical id. Mutable.
43 */
44 name?: string;
45 /**
46 * Whether the feature can be attached to new products. Inactive
47 * (archived) features are omitted from the default list endpoint.
48 * @default true
49 */
50 active?: boolean;
51 /**
52 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
53 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
54 * not contain `:`. Mutable.
55 */
56 metadata?: Record<string, string>;
57}
58
59export type EntitlementsFeature = Resource<
60 "Stripe.EntitlementsFeature",
61 EntitlementsFeatureProps,
62 {
63 /** Stripe feature id (`feat_…`). */
64 id: string;
65 /** Unique system identifier for this feature. */
66 lookupKey: string;
67 /** Feature name for your own purpose, not shown to customers. */
68 name: string;
69 /** Whether the feature can be attached to new products. */
70 active: boolean;
71 /** User-defined metadata (Alchemy ownership keys stripped). */
72 metadata: Record<string, string>;
73 /** Whether the feature exists in live mode. */
74 livemode: boolean;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A Stripe Entitlements Feature — a monetizable ability you attach to
82 * Products. When a customer purchases a product with this feature, Stripe
83 * creates an entitlement for them. `lookup_key` is unique among active
84 * features and immutable (changing it replaces the feature). Name and
85 * metadata update in place.
86 *
87 * Stripe does not hard-delete features; destroying this resource
88 * deactivates it (`active: false`). Archived features cannot be edited
89 * or unarchived. An archived lookup key can be reused — a later create
90 * with the same key provisions a new feature.
91 *
92 * @see https://docs.stripe.com/api/entitlements/feature
93 *
94 * ### Creating a Feature
95 * **Example:** Generated lookup key
96 * ```typescript
97 * const seats = yield* Stripe.EntitlementsFeature("seats");
98 * ```
99 *
100 * **Example:** Named feature with lookup key
101 * ```typescript
102 * const seats = yield* Stripe.EntitlementsFeature("seats", {
103 * lookupKey: "seats",
104 * name: "Seat licenses",
105 * });
106 * ```
107 *
108 * ### Updating a Feature
109 * **Example:** Rename and retag
110 * ```typescript
111 * const seats = yield* Stripe.EntitlementsFeature("seats", {
112 * lookupKey: "seats",
113 * name: "Seat licenses (updated)",
114 * metadata: { plan: "pro" },
115 * });
116 * ```
117 *
118 * ### Deactivating a Feature
119 * **Example:** Destroy deactivates rather than deleting
120 * ```typescript
121 * // stack.destroy() / resource removal sets active: false
122 * const seats = yield* Stripe.EntitlementsFeature("seats", {
123 * lookupKey: "seats",
124 * name: "Seat licenses",
125 * });
126 * ```
127 *
128 * @resource
129 * @product Entitlements
130 */
131export const EntitlementsFeature = Resource<EntitlementsFeature>(
132 "Stripe.EntitlementsFeature",
133);
134
135type EntitlementsFeatureAttributes = EntitlementsFeature["Attributes"];
136
137const userMetadata = (
138 metadata: Record<string, string | undefined> | null | undefined,
139): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
140
141const toName = (id: string, name: string | undefined, existing?: string) =>
142 Effect.gen(function* () {
143 return (
144 name ??
145 existing ??
146 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
147 );
148 });
149
150const toLookupKey = (
151 id: string,
152 lookupKey: string | undefined,
153 existing?: string,
154) =>
155 Effect.gen(function* () {
156 return (
157 lookupKey ??
158 existing ??
159 (yield* createPhysicalName({
160 id,
161 maxLength: LOOKUP_KEY_MAX_LENGTH,
162 lowercase: true,
163 }))
164 );
165 });
166
167const toAttrs = (
168 feature: StripeEntitlementsFeature,
169): EntitlementsFeatureAttributes => ({
170 id: feature.id,
171 lookupKey: feature.lookup_key,
172 name: feature.name,
173 active: feature.active,
174 metadata: userMetadata(feature.metadata),
175 livemode: feature.livemode,
176});
177
178const isMissingFeature = isMissingStripeResource;
179
180const getById = (id: string) =>
181 GetEntitlementsFeature({ id }).pipe(
182 Effect.catchIf(isMissingFeature, () => Effect.succeed(undefined)),
183 );
184
185const listByArchived = Effect.fn(function* (archived: boolean) {
186 const features: StripeEntitlementsFeature[] = [];
187 let startingAfter: string | undefined;
188 for (let page = 0; page < LIST_MAX_PAGES; page++) {
189 const response = yield* GetEntitlementsFeatures({
190 archived,
191 limit: LIST_PAGE_SIZE,
192 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
193 });
194 features.push(...response.data);
195 if (!response.has_more || response.data.length === 0) {
196 break;
197 }
198 startingAfter = response.data[response.data.length - 1]?.id;
199 if (startingAfter === undefined) {
200 break;
201 }
202 }
203 return features;
204});
205
206const findByLookupKey = Effect.fn(function* (lookupKey: string) {
207 // Archived features cannot be updated or unarchived. Their lookup keys
208 // are reusable, so observation must ignore them and let ensure create.
209 const active = yield* GetEntitlementsFeatures({
210 lookup_key: lookupKey,
211 archived: false,
212 limit: LIST_PAGE_SIZE,
213 });
214 return active.data[0];
215});
216
217const findByAlchemyId = Effect.fn(function* (id: string) {
218 const features = yield* listByArchived(false);
219 const matches: StripeEntitlementsFeature[] = [];
220 for (const feature of features) {
221 if (yield* hasAlchemyMetadata(id, tagRecord(feature.metadata))) {
222 matches.push(feature);
223 }
224 }
225 matches.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
226 return matches[0];
227});
228
229const observe = Effect.fn(function* (input: {
230 id?: string;
231 logicalId: string;
232 lookupKey?: string;
233}) {
234 if (input.id !== undefined) {
235 const byId = yield* getById(input.id);
236 if (byId !== undefined) return byId;
237 }
238 if (input.lookupKey !== undefined) {
239 const byKey = yield* findByLookupKey(input.lookupKey);
240 if (
241 byKey !== undefined &&
242 (yield* hasAlchemyMetadata(input.logicalId, tagRecord(byKey.metadata)))
243 ) {
244 return byKey;
245 }
246 }
247 return yield* findByAlchemyId(input.logicalId);
248});
249
250const desiredMetadata = Effect.fn(function* (
251 id: string,
252 metadata: Record<string, string> | undefined,
253) {
254 return {
255 ...toMetadata(metadata),
256 ...(yield* createInternalMetadata(id)),
257 };
258});
259
260const shouldReplace = (
261 news: EntitlementsFeatureProps,
262 output: EntitlementsFeatureAttributes | undefined,
263): boolean => {
264 if (output === undefined) return false;
265 if (news.lookupKey !== undefined && news.lookupKey !== output.lookupKey) {
266 return true;
267 }
268 return false;
269};
270
271export const EntitlementsFeatureProvider = () =>
272 Provider.succeed(EntitlementsFeature, {
273 stables: ["id", "lookupKey", "livemode"],
274
275 diff: Effect.fn(function* ({ news, output }) {
276 if (!isResolved(news)) return undefined;
277 if (shouldReplace(news, output)) {
278 return { action: "replace" } as const;
279 }
280 return undefined;
281 }),
282
283 read: Effect.fn(function* ({ id, output }) {
284 const existing = yield* observe({
285 id: output?.id,
286 logicalId: id,
287 lookupKey: output?.lookupKey,
288 });
289 if (existing === undefined) return undefined;
290 const attrs = toAttrs(existing);
291 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
292 ? attrs
293 : Unowned(attrs);
294 }),
295
296 list: Effect.fn(function* () {
297 // Default list API is active features; archived (deactivated) rows
298 // stay in Stripe but must not re-enter nuke. Filter to alchemy_stack
299 // so account-wide teardown only touches our rows.
300 const features = yield* listByArchived(false);
301 return features
302 .filter((feature) => {
303 const metadata = tagRecord(feature.metadata);
304 return metadata[alchemyMetadataKeys.stack] !== undefined;
305 })
306 .map(toAttrs);
307 }),
308
309 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
310 const lookupKey = yield* toLookupKey(
311 id,
312 news.lookupKey,
313 output?.lookupKey,
314 );
315 const name = yield* toName(id, news.name, output?.name);
316 const metadata = yield* desiredMetadata(id, news.metadata);
317 const desiredActive = news.active ?? true;
318
319 let current: StripeEntitlementsFeature | undefined = yield* observe({
320 id: output?.id,
321 logicalId: id,
322 lookupKey,
323 });
324 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
325 current = undefined;
326 }
327 // Stripe rejects updates to archived features and cannot unarchive
328 // them. Treat inactive rows as missing so ensure creates a new one
329 // (lookup keys become reusable once archived).
330 if (current !== undefined && current.active === false) {
331 if (desiredActive === false) {
332 return toAttrs(current);
333 }
334 current = undefined;
335 }
336
337 if (current === undefined) {
338 current = yield* CreateEntitlementsFeature({
339 lookup_key: lookupKey,
340 name,
341 metadata,
342 }).pipe(
343 withRequestOptions({
344 idempotencyKey: `alchemy-entitlements-feature-${instanceId}`,
345 }),
346 );
347 }
348
349 const observedMetadata = tagRecord(current.metadata);
350 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
351 const metadataChanged = upsert.length > 0 || removed.length > 0;
352 const nameChanged = current.name !== name;
353 const activeChanged = current.active !== desiredActive;
354
355 if (!nameChanged && !activeChanged && !metadataChanged) {
356 return toAttrs(current);
357 }
358
359 const updated = yield* UpdateEntitlementsFeature({
360 id: current.id,
361 ...(nameChanged ? { name } : {}),
362 ...(activeChanged ? { active: desiredActive } : {}),
363 ...(metadataChanged
364 ? {
365 metadata: {
366 ...Object.fromEntries(
367 upsert.map((tag) => [tag.Key, tag.Value]),
368 ),
369 ...Object.fromEntries(removed.map((key) => [key, ""])),
370 },
371 }
372 : {}),
373 });
374 return toAttrs(updated);
375 }),
376
377 delete: Effect.fn(function* ({ output }) {
378 const existing = yield* getById(output.id);
379 if (existing === undefined || !existing.active) return;
380 yield* UpdateEntitlementsFeature({
381 id: existing.id,
382 active: false,
383 }).pipe(Effect.catchIf(isMissingFeature, () => Effect.void));
384 }),
385 });