1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteRadarValueListItem,
4 GetRadarValueListItems,
5 GetRadarValueListItem,
6 GetRadarValueLists,
7 CreateRadarValueListItem,
8 type RadarValueList as StripeRadarValueList,
9 type RadarValueListItem as StripeRadarValueListItem,
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 RadarValueListItemProps {
25 /**
26 * Id of the parent Radar value list (`rsl_…`). Changing it replaces
27 * the item.
28 */
29 valueList: string;
30 /**
31 * Value stored on the list. Its type must match the parent list's
32 * `item_type` (e.g. an email, IP, country code, or fingerprint).
33 * Changing it replaces the item.
34 */
35 value: string;
36}
37
38export type RadarValueListItem = Resource<
39 "Stripe.RadarValueListItem",
40 RadarValueListItemProps,
41 {
42 /** Stripe value list item id (`rsli_…`). */
43 id: string;
44 /** Id of the parent value list (`rsl_…`). */
45 valueList: string;
46 /** Value stored on the list. */
47 value: string;
48 /** Unix timestamp when the item was created. */
49 created: number;
50 /** Name or email of the user who added the item. */
51 createdBy: string;
52 /** Whether the item exists in live mode. */
53 livemode: boolean;
54 },
55 never,
56 Providers
57>;
58
59/**
60 * A Stripe Radar Value List Item — one entry on a Radar value list used
61 * in Radar rules. Existence-only: there is nothing to update in place;
62 * changing `valueList` or `value` replaces the item. Destroy deletes it.
63 *
64 * Value list items have no metadata of their own. Ownership for
65 * account-wide `list()` (nuke) is inferred from the parent value list's
66 * Alchemy metadata.
67 *
68 * @see https://docs.stripe.com/api/radar/value_list_items
69 *
70 * ### Adding an Item
71 * **Example:** Add an email to a value list
72 * ```typescript
73 * const blocked = yield* Stripe.RadarValueList("blocked-emails", {
74 * alias: "blocked_emails",
75 * name: "Blocked emails",
76 * itemType: "email",
77 * });
78 * const item = yield* Stripe.RadarValueListItem("spammer", {
79 * valueList: blocked.id,
80 * value: "spammer@example.com",
81 * });
82 * ```
83 *
84 * ### Replacing an Item
85 * **Example:** Point the item at a different value
86 * ```typescript
87 * const item = yield* Stripe.RadarValueListItem("spammer", {
88 * valueList: blocked.id,
89 * value: "other@example.com",
90 * });
91 * ```
92 *
93 * @resource
94 * @product Radar
95 */
96export const RadarValueListItem = Resource<RadarValueListItem>(
97 "Stripe.RadarValueListItem",
98);
99
100type RadarValueListItemAttributes = RadarValueListItem["Attributes"];
101
102const toAttrs = (
103 item: StripeRadarValueListItem,
104): RadarValueListItemAttributes => ({
105 id: item.id,
106 valueList: item.value_list,
107 value: item.value,
108 created: item.created,
109 createdBy: item.created_by,
110 livemode: item.livemode,
111});
112
113const isMissing = isMissingStripeResource;
114
115const getById = (item: string) =>
116 GetRadarValueListItem({ item }).pipe(
117 Effect.catchIf(isMissing, () => Effect.succeed(undefined)),
118 );
119
120const listItems = Effect.fn(function* (valueList: string) {
121 const items: StripeRadarValueListItem[] = [];
122 let startingAfter: string | undefined;
123 for (let page = 0; page < LIST_MAX_PAGES; page++) {
124 const response = yield* GetRadarValueListItems({
125 value_list: valueList,
126 limit: LIST_PAGE_SIZE,
127 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
128 }).pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
129 if (response === undefined) {
130 break;
131 }
132 items.push(...response.data);
133 if (!response.has_more || response.data.length === 0) {
134 break;
135 }
136 startingAfter = response.data[response.data.length - 1]?.id;
137 if (startingAfter === undefined) {
138 break;
139 }
140 }
141 return items;
142});
143
144const findByValue = Effect.fn(function* (valueList: string, value: string) {
145 const matches: StripeRadarValueListItem[] = [];
146 let startingAfter: string | undefined;
147 for (let page = 0; page < LIST_MAX_PAGES; page++) {
148 const response = yield* GetRadarValueListItems({
149 value_list: valueList,
150 value,
151 limit: LIST_PAGE_SIZE,
152 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
153 }).pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
154 if (response === undefined) {
155 break;
156 }
157 for (const item of response.data) {
158 if (item.value === value) {
159 matches.push(item);
160 }
161 }
162 if (!response.has_more || response.data.length === 0) {
163 break;
164 }
165 startingAfter = response.data[response.data.length - 1]?.id;
166 if (startingAfter === undefined) {
167 break;
168 }
169 }
170 matches.sort((a, b) => b.created - a.created);
171 return matches[0];
172});
173
174const listValueLists = Effect.fn(function* () {
175 const lists: StripeRadarValueList[] = [];
176 let startingAfter: string | undefined;
177 for (let page = 0; page < LIST_MAX_PAGES; page++) {
178 const response = yield* GetRadarValueLists({
179 limit: LIST_PAGE_SIZE,
180 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
181 });
182 lists.push(...response.data);
183 if (!response.has_more || response.data.length === 0) {
184 break;
185 }
186 startingAfter = response.data[response.data.length - 1]?.id;
187 if (startingAfter === undefined) {
188 break;
189 }
190 }
191 return lists;
192});
193
194const listAlchemyValueLists = Effect.fn(function* () {
195 const lists = yield* listValueLists();
196 return lists.filter(
197 (list) => tagRecord(list.metadata)[alchemyMetadataKeys.stack] !== undefined,
198 );
199});
200
201const observe = Effect.fn(function* (input: {
202 id?: string;
203 valueList?: string;
204 value?: string;
205}) {
206 if (input.id !== undefined) {
207 const byId = yield* getById(input.id);
208 if (byId !== undefined) return byId;
209 }
210 if (input.valueList !== undefined && input.value !== undefined) {
211 return yield* findByValue(input.valueList, input.value);
212 }
213 return undefined;
214});
215
216const shouldReplace = (
217 news: RadarValueListItemProps,
218 output: RadarValueListItemAttributes | undefined,
219): boolean => {
220 if (output === undefined) return false;
221 if (news.valueList !== output.valueList) return true;
222 if (news.value !== output.value) return true;
223 return false;
224};
225
226export const RadarValueListItemProvider = () =>
227 Provider.succeed(RadarValueListItem, {
228 stables: ["id", "valueList", "value", "created", "createdBy", "livemode"],
229 nuke: { dependsOn: ["Stripe.RadarValueList"] },
230
231 diff: Effect.fn(function* ({ news, output }) {
232 if (!isResolved(news)) return undefined;
233 if (shouldReplace(news, output)) {
234 return { action: "replace" } as const;
235 }
236 return undefined;
237 }),
238
239 read: Effect.fn(function* ({ output, olds }) {
240 const valueList =
241 output?.valueList ??
242 (typeof olds?.valueList === "string" ? olds.valueList : undefined);
243 const value =
244 output?.value ??
245 (typeof olds?.value === "string" ? olds.value : undefined);
246 const existing = yield* observe({
247 id: output?.id,
248 valueList,
249 value,
250 });
251 if (existing === undefined) return undefined;
252 return toAttrs(existing);
253 }),
254
255 list: Effect.fn(function* () {
256 // Value list items have no metadata. Fan out from alchemy-owned
257 // value lists so nuke only tears down items on lists we created.
258 const lists = yield* listAlchemyValueLists();
259 const rows = yield* Effect.forEach(
260 lists,
261 (list) =>
262 listItems(list.id).pipe(Effect.map((items) => items.map(toAttrs))),
263 { concurrency: LIST_CONCURRENCY },
264 );
265 return rows.flat();
266 }),
267
268 reconcile: Effect.fn(function* ({ news, output, instanceId }) {
269 let current = yield* observe({
270 id: output?.id,
271 valueList: news.valueList,
272 value: news.value,
273 });
274 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
275 current = undefined;
276 }
277
278 if (current === undefined) {
279 current = yield* CreateRadarValueListItem({
280 value_list: news.valueList,
281 value: news.value,
282 }).pipe(
283 withRequestOptions({
284 idempotencyKey: `alchemy-radar-value-list-item-${instanceId}`,
285 }),
286 Effect.catchIf(
287 (e) => e._tag === "InvalidRequestError" || e._tag === "Conflict",
288 (e) =>
289 observe({
290 valueList: news.valueList,
291 value: news.value,
292 }).pipe(
293 Effect.flatMap((found) =>
294 found !== undefined ? Effect.succeed(found) : Effect.fail(e),
295 ),
296 ),
297 ),
298 );
299 }
300
301 return toAttrs(current);
302 }),
303
304 delete: Effect.fn(function* ({ output }) {
305 yield* DeleteRadarValueListItem({ item: output.id }).pipe(
306 Effect.catchIf(isMissing, () => Effect.void),
307 );
308 }),
309 });