| 1 | import { withRequestOptions } from "@distilled.cloud/stripe"; |
| 2 | import { |
| 3 | DeleteRadarValueList, |
| 4 | GetRadarValueLists, |
| 5 | GetRadarValueList, |
| 6 | CreateRadarValueList, |
| 7 | UpdateRadarValueList, |
| 8 | type RadarValueList as StripeRadarValueList, |
| 9 | } from "@distilled.cloud/stripe/stripe"; |
| 10 | import * as Data from "effect/Data"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import { Unowned } from "../AdoptPolicy.ts"; |
| 13 | import { isResolved } from "../Diff.ts"; |
| 14 | import { createPhysicalName } from "../PhysicalName.ts"; |
| 15 | import * as Provider from "../Provider.ts"; |
| 16 | import { Resource } from "../Resource.ts"; |
| 17 | import { tagRecord } from "../Tags.ts"; |
| 18 | import { |
| 19 | alchemyMetadataKeys, |
| 20 | createInternalMetadata, |
| 21 | diffMetadata, |
| 22 | hasAlchemyMetadata, |
| 23 | stripInternalMetadata, |
| 24 | toMetadata, |
| 25 | } from "./Metadata.ts"; |
| 26 | import type { Providers } from "./Providers.ts"; |
| 27 | import { isMissingStripeResource } from "./missing.ts"; |
| 28 | |
| 29 | const NAME_MAX_LENGTH = 250; |
| 30 | const ALIAS_MAX_LENGTH = 100; |
| 31 | const LIST_PAGE_SIZE = 100; |
| 32 | const 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 | */ |
| 38 | export 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 | |
| 52 | export 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 | |
| 78 | export 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 | */ |
| 154 | export const RadarValueList = Resource<RadarValueList>("Stripe.RadarValueList"); |
| 155 | |
| 156 | export class RadarValueListNotResolved extends Data.TaggedError( |
| 157 | "Stripe.RadarValueListNotResolved", |
| 158 | )<{ |
| 159 | alias: string; |
| 160 | }> {} |
| 161 | |
| 162 | type RadarValueListAttributes = RadarValueList["Attributes"]; |
| 163 | |
| 164 | const userMetadata = ( |
| 165 | metadata: Record<string, string | undefined> | null | undefined, |
| 166 | ): Record<string, string> => stripInternalMetadata(tagRecord(metadata)); |
| 167 | |
| 168 | const 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 | |
| 177 | const 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 | |
| 192 | const 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 | |
| 203 | const isMissingValueList = isMissingStripeResource; |
| 204 | |
| 205 | const getById = (valueList: string) => |
| 206 | GetRadarValueList({ value_list: valueList }).pipe( |
| 207 | Effect.catchIf(isMissingValueList, () => Effect.succeed(undefined)), |
| 208 | ); |
| 209 | |
| 210 | const 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 | |
| 219 | const 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 | |
| 239 | const 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 | |
| 251 | const 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 | |
| 272 | const 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 | |
| 282 | const 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 | |
| 296 | export 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 | }); |