1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteRadarValueList,
4 GetRadarValueLists,
5 GetRadarValueList,
6 CreateRadarValueList,
7 UpdateRadarValueList,
8 type RadarValueList as StripeRadarValueList,
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 { createPhysicalName } from "../PhysicalName.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 NAME_MAX_LENGTH = 250;
30const ALIAS_MAX_LENGTH = 100;
31const LIST_PAGE_SIZE = 100;
32const LIST_MAX_PAGES = 100;
33
34/**
35 * Type of items stored in a Radar value list. Create-only — changing it
36 * replaces the list.
37 */
38export type RadarValueListItemType =
39 | "account"
40 | "card_bin"
41 | "card_fingerprint"
42 | "case_sensitive_string"
43 | "country"
44 | "crypto_fingerprint"
45 | "customer_id"
46 | "email"
47 | "ip_address"
48 | "sepa_debit_fingerprint"
49 | "string"
50 | "us_bank_account_fingerprint";
51
52export interface RadarValueListProps {
53 /**
54 * Alias used to reference this list in Radar rules. Unique per account;
55 * alphanumeric characters and underscores only, max 100 characters. If
56 * omitted, a unique alias is generated from the stack, stage, and
57 * logical id. Create-only — changing it replaces the list.
58 */
59 alias?: string;
60 /**
61 * Human-readable name shown in the Stripe Dashboard. If omitted, a unique
62 * name is generated from the stack, stage, and logical id. Mutable.
63 */
64 name?: string;
65 /**
66 * Type of items stored in this list. Immutable after create.
67 * @default "string"
68 */
69 itemType?: RadarValueListItemType;
70 /**
71 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
72 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
73 * not contain `:`. Mutable.
74 */
75 metadata?: Record<string, string>;
76}
77
78export type RadarValueList = Resource<
79 "Stripe.RadarValueList",
80 RadarValueListProps,
81 {
82 /** Stripe Radar value list id (`rsl_…`). */
83 id: string;
84 /** Alias used to reference this list in Radar rules. */
85 alias: string;
86 /** Human-readable name shown in the Stripe Dashboard. */
87 name: string;
88 /** Type of items stored in this list. */
89 itemType: RadarValueListItemType;
90 /** Name or email of the user who created this list. */
91 createdBy: string;
92 /** User-defined metadata (Alchemy ownership keys stripped). */
93 metadata: Record<string, string>;
94 /** Unix timestamp when the list was created. */
95 created: number;
96 /** Whether the list exists in live mode. */
97 livemode: boolean;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A Stripe Radar Value List — a named collection of values (emails, IPs,
105 * card fingerprints, …) referenced from Radar rules. `alias` and
106 * `itemType` are immutable and changing them replaces the list. Name and
107 * metadata update in place. Destroy deletes the list and its items; a
108 * list referenced by a Radar rule cannot be deleted.
109 *
110 * Radar custom lists may require Radar for Fraud Teams.
111 *
112 * @see https://docs.stripe.com/api/radar/value_lists
113 *
114 * ### Creating a Value List
115 * **Example:** Generated alias
116 * ```typescript
117 * const blocked = yield* Stripe.RadarValueList("blocked-emails", {
118 * itemType: "email",
119 * });
120 * ```
121 *
122 * **Example:** Named list with alias
123 * ```typescript
124 * const blocked = yield* Stripe.RadarValueList("blocked-emails", {
125 * alias: "custom_email_blocklist",
126 * name: "Blocked emails",
127 * itemType: "email",
128 * });
129 * ```
130 *
131 * ### Updating a Value List
132 * **Example:** Rename and retag
133 * ```typescript
134 * const blocked = yield* Stripe.RadarValueList("blocked-emails", {
135 * alias: "custom_email_blocklist",
136 * name: "Blocked emails (updated)",
137 * itemType: "email",
138 * metadata: { team: "fraud" },
139 * });
140 * ```
141 *
142 * ### Replacing a Value List
143 * **Example:** Changing item type replaces the list
144 * ```typescript
145 * const blocked = yield* Stripe.RadarValueList("blocked", {
146 * alias: "custom_blocklist",
147 * itemType: "ip_address",
148 * });
149 * ```
150 *
151 * @resource
152 * @product Radar
153 */
154export const RadarValueList = Resource<RadarValueList>("Stripe.RadarValueList");
155
156export class RadarValueListNotResolved extends Data.TaggedError(
157 "Stripe.RadarValueListNotResolved",
158)<{
159 alias: string;
160}> {}
161
162type RadarValueListAttributes = RadarValueList["Attributes"];
163
164const userMetadata = (
165 metadata: Record<string, string | undefined> | null | undefined,
166): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
167
168const toName = (id: string, name: string | undefined, existing?: string) =>
169 Effect.gen(function* () {
170 return (
171 name ??
172 existing ??
173 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
174 );
175 });
176
177const toAlias = (id: string, alias: string | undefined, existing?: string) =>
178 Effect.gen(function* () {
179 if (alias !== undefined) return alias;
180 if (existing !== undefined) return existing;
181 const generated = yield* createPhysicalName({
182 id,
183 maxLength: ALIAS_MAX_LENGTH,
184 lowercase: true,
185 delimiter: "_",
186 });
187 // Stripe aliases allow only [A-Za-z0-9_]; physical names may still
188 // contain hyphens from the stack/stage prefix.
189 return generated.replaceAll("-", "_");
190 });
191
192const toAttrs = (list: StripeRadarValueList): RadarValueListAttributes => ({
193 id: list.id,
194 alias: list.alias,
195 name: list.name,
196 itemType: list.item_type,
197 createdBy: list.created_by,
198 metadata: userMetadata(list.metadata),
199 created: list.created,
200 livemode: list.livemode,
201});
202
203const isMissingValueList = isMissingStripeResource;
204
205const getById = (valueList: string) =>
206 GetRadarValueList({ value_list: valueList }).pipe(
207 Effect.catchIf(isMissingValueList, () => Effect.succeed(undefined)),
208 );
209
210const findByAlias = (alias: string) =>
211 Effect.gen(function* () {
212 const response = yield* GetRadarValueLists({
213 alias,
214 limit: 1,
215 });
216 return response.data[0];
217 });
218
219const listAllValueLists = Effect.fn(function* () {
220 const lists: StripeRadarValueList[] = [];
221 let startingAfter: string | undefined;
222 for (let page = 0; page < LIST_MAX_PAGES; page++) {
223 const response = yield* GetRadarValueLists({
224 limit: LIST_PAGE_SIZE,
225 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
226 });
227 lists.push(...response.data);
228 if (!response.has_more || response.data.length === 0) {
229 break;
230 }
231 startingAfter = response.data[response.data.length - 1]?.id;
232 if (startingAfter === undefined) {
233 break;
234 }
235 }
236 return lists;
237});
238
239const findByAlchemyId = Effect.fn(function* (id: string) {
240 const lists = yield* listAllValueLists();
241 const matches: StripeRadarValueList[] = [];
242 for (const list of lists) {
243 if (yield* hasAlchemyMetadata(id, tagRecord(list.metadata))) {
244 matches.push(list);
245 }
246 }
247 matches.sort((a, b) => b.created - a.created);
248 return matches[0];
249});
250
251const observe = Effect.fn(function* (input: {
252 id?: string;
253 logicalId: string;
254 alias?: string;
255}) {
256 if (input.id !== undefined) {
257 const byId = yield* getById(input.id);
258 if (byId !== undefined) return byId;
259 }
260 if (input.alias !== undefined) {
261 const byAlias = yield* findByAlias(input.alias);
262 if (
263 byAlias !== undefined &&
264 (yield* hasAlchemyMetadata(input.logicalId, tagRecord(byAlias.metadata)))
265 ) {
266 return byAlias;
267 }
268 }
269 return yield* findByAlchemyId(input.logicalId);
270});
271
272const desiredMetadata = Effect.fn(function* (
273 id: string,
274 metadata: Record<string, string> | undefined,
275) {
276 return {
277 ...toMetadata(metadata),
278 ...(yield* createInternalMetadata(id)),
279 };
280});
281
282const shouldReplace = (
283 news: RadarValueListProps,
284 output: RadarValueListAttributes | undefined,
285): boolean => {
286 if (output === undefined) return false;
287 if (news.alias !== undefined && news.alias !== output.alias) {
288 return true;
289 }
290 if (news.itemType !== undefined && news.itemType !== output.itemType) {
291 return true;
292 }
293 return false;
294};
295
296export const RadarValueListProvider = () =>
297 Provider.succeed(RadarValueList, {
298 stables: ["id", "alias", "itemType", "created", "createdBy", "livemode"],
299
300 diff: Effect.fn(function* ({ olds, news, output }) {
301 if (!isResolved(news)) return undefined;
302 if (shouldReplace(news, output)) {
303 const previousAlias = output?.alias ?? olds?.alias;
304 return {
305 action: "replace" as const,
306 deleteFirst: news.alias !== undefined && news.alias === previousAlias,
307 };
308 }
309 return undefined;
310 }),
311
312 read: Effect.fn(function* ({ id, output }) {
313 const existing = yield* observe({
314 id: output?.id,
315 logicalId: id,
316 alias: output?.alias,
317 });
318 if (existing === undefined) return undefined;
319 const attrs = toAttrs(existing);
320 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
321 ? attrs
322 : Unowned(attrs);
323 }),
324
325 list: Effect.fn(function* () {
326 const lists = yield* listAllValueLists();
327 return lists
328 .filter((list) => {
329 const metadata = tagRecord(list.metadata);
330 return metadata[alchemyMetadataKeys.stack] !== undefined;
331 })
332 .map(toAttrs);
333 }),
334
335 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
336 const alias = yield* toAlias(id, news.alias, output?.alias);
337 const name = yield* toName(id, news.name, output?.name);
338 const metadata = yield* desiredMetadata(id, news.metadata);
339 const itemType = news.itemType ?? output?.itemType ?? "string";
340
341 let current: StripeRadarValueList | undefined = yield* observe({
342 id: output?.id,
343 logicalId: id,
344 alias,
345 });
346 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
347 current = undefined;
348 }
349
350 if (current === undefined) {
351 current = yield* CreateRadarValueList({
352 alias,
353 name,
354 item_type: itemType,
355 metadata,
356 }).pipe(
357 withRequestOptions({
358 idempotencyKey: `alchemy-radar-value-list-${instanceId}`,
359 }),
360 );
361 }
362
363 if (current === undefined) {
364 return yield* new RadarValueListNotResolved({ alias });
365 }
366
367 const observedMetadata = tagRecord(current.metadata);
368 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
369 const metadataChanged = upsert.length > 0 || removed.length > 0;
370 const nameChanged = current.name !== name;
371
372 if (!nameChanged && !metadataChanged) {
373 return toAttrs(current);
374 }
375
376 const updated = yield* UpdateRadarValueList({
377 value_list: current.id,
378 ...(nameChanged ? { name } : {}),
379 ...(metadataChanged
380 ? {
381 metadata: {
382 ...Object.fromEntries(
383 upsert.map((tag) => [tag.Key, tag.Value]),
384 ),
385 ...Object.fromEntries(removed.map((key) => [key, ""])),
386 },
387 }
388 : {}),
389 });
390 return toAttrs(updated);
391 }),
392
393 delete: Effect.fn(function* ({ output }) {
394 yield* DeleteRadarValueList({ value_list: output.id }).pipe(
395 Effect.catchIf(isMissingValueList, () => Effect.void),
396 );
397 }),
398 });