1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetIssuingPersonalizationDesigns,
4 GetIssuingPersonalizationDesign,
5 CreateIssuingPersonalizationDesign,
6 UpdateIssuingPersonalizationDesign,
7 type IssuingPersonalizationDesign as StripeIssuingPersonalizationDesign,
8 type CreateIssuingPersonalizationDesignRequestCarrierText,
9} from "@distilled.cloud/stripe/stripe";
10import * as Effect from "effect/Effect";
11import { Unowned } from "../AdoptPolicy.ts";
12import { deepEqual, isResolved } from "../Diff.ts";
13import { createPhysicalName } from "../PhysicalName.ts";
14import * as Provider from "../Provider.ts";
15import { Resource } from "../Resource.ts";
16import { tagRecord } from "../Tags.ts";
17import {
18 alchemyMetadataKeys,
19 createInternalMetadata,
20 diffMetadata,
21 hasAlchemyMetadata,
22 stripInternalMetadata,
23 toMetadata,
24} from "./Metadata.ts";
25import type { Providers } from "./Providers.ts";
26import { isMissingStripeResource } from "./missing.ts";
27
28const NAME_MAX_LENGTH = 250;
29const LOOKUP_KEY_MAX_LENGTH = 200;
30const LIST_PAGE_SIZE = 100;
31const LIST_MAX_PAGES = 100;
32
33/** Whether this personalization design can be used to create cards. */
34export type IssuingPersonalizationDesignStatus =
35 | "active"
36 | "inactive"
37 | "rejected"
38 | "review";
39
40/** Reason a card logo was rejected. */
41export type IssuingPersonalizationDesignCardLogoRejection =
42 | "geographic_location"
43 | "inappropriate"
44 | "network_name"
45 | "non_binary_image"
46 | "non_fiat_currency"
47 | "other"
48 | "other_entity"
49 | "promotional_material";
50
51/** Reason carrier text was rejected. */
52export type IssuingPersonalizationDesignCarrierTextRejection =
53 | "geographic_location"
54 | "inappropriate"
55 | "network_name"
56 | "non_fiat_currency"
57 | "other"
58 | "other_entity"
59 | "promotional_material";
60
61/**
62 * Carrier-letter copy for physical bundles that support carrier text.
63 */
64export interface IssuingPersonalizationDesignCarrierText {
65 /**
66 * Footer body text of the carrier letter.
67 */
68 footerBody?: string;
69 /**
70 * Footer title text of the carrier letter.
71 */
72 footerTitle?: string;
73 /**
74 * Header body text of the carrier letter.
75 */
76 headerBody?: string;
77 /**
78 * Header title text of the carrier letter.
79 */
80 headerTitle?: string;
81}
82
83/**
84 * Whether this design is used when a card is created without a
85 * personalization design.
86 */
87export interface IssuingPersonalizationDesignPreferences {
88 /**
89 * When true, Stripe uses this design to create cards when one isn't
90 * specified. Connected accounts fall back to the platform default if
91 * none is set.
92 * @default false
93 */
94 isDefault?: boolean;
95}
96
97export interface IssuingPersonalizationDesignProps {
98 /**
99 * Id of the physical bundle (`ics_…`) this design ships with. Required.
100 * Mutable — changing it updates the design in place.
101 */
102 physicalBundle: string;
103 /**
104 * Friendly display name. If omitted, a unique name is generated from
105 * the stack, stage, and logical id. Mutable.
106 */
107 name?: string;
108 /**
109 * Lookup key used to retrieve this design from a static string. Unique
110 * per account, max 200 characters. If omitted, a unique key is
111 * generated. Mutable — a change transfers the key onto this design.
112 */
113 lookupKey?: string;
114 /**
115 * File id (`file_…`) of the card logo. Must have purpose
116 * `issuing_logo` (PNG, 1000×200, black on white). Mutable.
117 */
118 cardLogo?: string;
119 /**
120 * Carrier-letter copy. Omit or leave empty to clear. Mutable.
121 */
122 carrierText?: IssuingPersonalizationDesignCarrierText;
123 /**
124 * Default-design preference. Mutable.
125 */
126 preferences?: IssuingPersonalizationDesignPreferences;
127 /**
128 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
129 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
130 * not contain `:`. Mutable.
131 */
132 metadata?: Record<string, string>;
133}
134
135export type IssuingPersonalizationDesign = Resource<
136 "Stripe.IssuingPersonalizationDesign",
137 IssuingPersonalizationDesignProps,
138 {
139 /** Stripe personalization design id (`ipcd_…`). */
140 id: string;
141 /** Friendly display name. */
142 name: string;
143 /** Lookup key used to retrieve this design dynamically. */
144 lookupKey: string | undefined;
145 /** Physical bundle id (`ics_…`). */
146 physicalBundle: string;
147 /** Card logo file id, if set. */
148 cardLogo: string | undefined;
149 /** Carrier-letter copy, if set. */
150 carrierText: IssuingPersonalizationDesignCarrierText | undefined;
151 /** Whether this design is the account default. */
152 isDefault: boolean;
153 /**
154 * Whether this design is the Connect platform default. Null on
155 * non-connected accounts.
156 */
157 isPlatformDefault: boolean | undefined;
158 /** Whether this design can be used to create cards. */
159 status: IssuingPersonalizationDesignStatus;
160 /** Reasons the card logo was rejected, if any. */
161 cardLogoRejectionReasons:
162 | IssuingPersonalizationDesignCardLogoRejection[]
163 | undefined;
164 /** Reasons the carrier text was rejected, if any. */
165 carrierTextRejectionReasons:
166 | IssuingPersonalizationDesignCarrierTextRejection[]
167 | undefined;
168 /** User-defined metadata (Alchemy ownership keys stripped). */
169 metadata: Record<string, string>;
170 /** Unix timestamp when the design was created. */
171 created: number;
172 /** Whether the design exists in live mode. */
173 livemode: boolean;
174 },
175 never,
176 Providers
177>;
178
179/**
180 * A Stripe Issuing Personalization Design — a physical bundle, optional
181 * card logo, and optional carrier text that represent a card product
182 * line. Name, lookup key, logo, carrier text, physical bundle,
183 * preferences, and metadata update in place.
184 *
185 * Stripe has no delete, archive, or deactivate API for personalization
186 * designs. Destroy is a no-op; the design remains as residue on the
187 * account (still listed by `list()` via Alchemy metadata). Account-wide
188 * nuke skips this type.
189 *
190 * Creating designs requires the Stripe Issuing entitlement.
191 *
192 * @see https://docs.stripe.com/api/issuing/personalization-designs
193 *
194 * ### Creating a Personalization Design
195 * **Example:** Named design on a physical bundle
196 * ```typescript
197 * const design = yield* Stripe.IssuingPersonalizationDesign("metal-card", {
198 * physicalBundle: "ics_Fiiwz3T83opOUd",
199 * name: "Metal Card",
200 * });
201 * ```
202 *
203 * **Example:** Lookup key, carrier text, and metadata
204 * ```typescript
205 * const design = yield* Stripe.IssuingPersonalizationDesign("metal-card", {
206 * physicalBundle: "ics_Fiiwz3T83opOUd",
207 * name: "Metal Card",
208 * lookupKey: "metal_card_v1",
209 * carrierText: {
210 * headerTitle: "Welcome",
211 * headerBody: "Your card is on its way.",
212 * },
213 * metadata: { line: "metal" },
214 * });
215 * ```
216 *
217 * ### Updating a Personalization Design
218 * **Example:** Rename and retag
219 * ```typescript
220 * const design = yield* Stripe.IssuingPersonalizationDesign("metal-card", {
221 * physicalBundle: "ics_Fiiwz3T83opOUd",
222 * name: "Metal Card v2",
223 * lookupKey: "metal_card_v1",
224 * metadata: { line: "metal", version: "2" },
225 * });
226 * ```
227 *
228 * ### Retrieving a Personalization Design
229 * **Example:** Bind and retrieve
230 * ```typescript
231 * const retrieve = yield* Stripe.RetrieveIssuingPersonalizationDesign(design);
232 * const live = yield* retrieve();
233 * ```
234 *
235 * ### Destroying a Personalization Design
236 * **Example:** Destroy is a no-op
237 * ```typescript
238 * // stack.destroy() leaves the Stripe object in place — there is no
239 * // delete/archive API. Residue stays tagged with alchemy_stack.
240 * const design = yield* Stripe.IssuingPersonalizationDesign("metal-card", {
241 * physicalBundle: "ics_Fiiwz3T83opOUd",
242 * name: "Metal Card",
243 * });
244 * ```
245 *
246 * @resource
247 * @product Issuing
248 */
249export const IssuingPersonalizationDesign =
250 Resource<IssuingPersonalizationDesign>("Stripe.IssuingPersonalizationDesign");
251
252type IssuingPersonalizationDesignAttributes =
253 IssuingPersonalizationDesign["Attributes"];
254
255const userMetadata = (
256 metadata: Record<string, string | undefined> | null | undefined,
257): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
258
259const toName = (id: string, name: string | undefined, existing?: string) =>
260 Effect.gen(function* () {
261 return (
262 name ??
263 existing ??
264 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
265 );
266 });
267
268const toLookupKey = (
269 id: string,
270 lookupKey: string | undefined,
271 existing?: string,
272) =>
273 Effect.gen(function* () {
274 if (lookupKey !== undefined && lookupKey.length > 0) return lookupKey;
275 if (existing !== undefined && existing.length > 0) return existing;
276 return yield* createPhysicalName({
277 id,
278 maxLength: LOOKUP_KEY_MAX_LENGTH,
279 lowercase: true,
280 });
281 });
282
283const idOf = (
284 value: string | { readonly id: string } | null | undefined,
285): string | undefined => {
286 if (value == null) return undefined;
287 return typeof value === "string" ? value : value.id;
288};
289
290const compactCarrierText = (
291 input:
292 | {
293 footerBody?: string | null;
294 footerTitle?: string | null;
295 headerBody?: string | null;
296 headerTitle?: string | null;
297 }
298 | null
299 | undefined,
300): IssuingPersonalizationDesignCarrierText | undefined => {
301 if (input == null) return undefined;
302 const out: IssuingPersonalizationDesignCarrierText = {};
303 if (input.footerBody) out.footerBody = input.footerBody;
304 if (input.footerTitle) out.footerTitle = input.footerTitle;
305 if (input.headerBody) out.headerBody = input.headerBody;
306 if (input.headerTitle) out.headerTitle = input.headerTitle;
307 return Object.keys(out).length > 0 ? out : undefined;
308};
309
310const fromObservedCarrierText = (
311 value: StripeIssuingPersonalizationDesign["carrier_text"],
312): IssuingPersonalizationDesignCarrierText | undefined =>
313 compactCarrierText(
314 value == null
315 ? undefined
316 : {
317 footerBody: value.footer_body,
318 footerTitle: value.footer_title,
319 headerBody: value.header_body,
320 headerTitle: value.header_title,
321 },
322 );
323
324const toWireCarrierText = (
325 input: IssuingPersonalizationDesignCarrierText,
326): CreateIssuingPersonalizationDesignRequestCarrierText => ({
327 ...(input.footerBody !== undefined ? { footer_body: input.footerBody } : {}),
328 ...(input.footerTitle !== undefined
329 ? { footer_title: input.footerTitle }
330 : {}),
331 ...(input.headerBody !== undefined ? { header_body: input.headerBody } : {}),
332 ...(input.headerTitle !== undefined
333 ? { header_title: input.headerTitle }
334 : {}),
335});
336
337const toAttrs = (
338 design: StripeIssuingPersonalizationDesign,
339): IssuingPersonalizationDesignAttributes => ({
340 id: design.id,
341 name: design.name ?? "",
342 lookupKey: design.lookup_key ?? undefined,
343 physicalBundle: idOf(design.physical_bundle) ?? "",
344 cardLogo: idOf(design.card_logo),
345 carrierText: fromObservedCarrierText(design.carrier_text),
346 isDefault: design.preferences.is_default,
347 isPlatformDefault: design.preferences.is_platform_default ?? undefined,
348 status: design.status,
349 cardLogoRejectionReasons: design.rejection_reasons.card_logo ?? undefined,
350 carrierTextRejectionReasons:
351 design.rejection_reasons.carrier_text ?? undefined,
352 metadata: userMetadata(design.metadata),
353 created: design.created,
354 livemode: design.livemode,
355});
356
357const isMissingDesign = isMissingStripeResource;
358
359const getById = (personalizationDesign: string) =>
360 GetIssuingPersonalizationDesign({
361 personalization_design: personalizationDesign,
362 }).pipe(Effect.catchIf(isMissingDesign, () => Effect.succeed(undefined)));
363
364const findByLookupKey = Effect.fn(function* (lookupKey: string) {
365 const response = yield* GetIssuingPersonalizationDesigns({
366 lookup_keys: [lookupKey],
367 limit: 1,
368 });
369 return response.data[0];
370});
371
372const listAllDesigns = Effect.fn(function* () {
373 const designs: StripeIssuingPersonalizationDesign[] = [];
374 let startingAfter: string | undefined;
375 for (let page = 0; page < LIST_MAX_PAGES; page++) {
376 const response = yield* GetIssuingPersonalizationDesigns({
377 limit: LIST_PAGE_SIZE,
378 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
379 });
380 designs.push(...response.data);
381 if (!response.has_more || response.data.length === 0) {
382 break;
383 }
384 startingAfter = response.data[response.data.length - 1]?.id;
385 if (startingAfter === undefined) {
386 break;
387 }
388 }
389 return designs;
390});
391
392const findByAlchemyId = Effect.fn(function* (id: string) {
393 const designs = yield* listAllDesigns();
394 const matches: StripeIssuingPersonalizationDesign[] = [];
395 for (const design of designs) {
396 if (yield* hasAlchemyMetadata(id, tagRecord(design.metadata))) {
397 matches.push(design);
398 }
399 }
400 matches.sort((a, b) => b.created - a.created);
401 return matches[0];
402});
403
404const observe = Effect.fn(function* (input: {
405 id?: string;
406 logicalId: string;
407 lookupKey?: string;
408}) {
409 if (input.id !== undefined) {
410 const byId = yield* getById(input.id);
411 if (byId !== undefined) return byId;
412 }
413 if (input.lookupKey !== undefined) {
414 const byKey = yield* findByLookupKey(input.lookupKey);
415 if (
416 byKey !== undefined &&
417 (yield* hasAlchemyMetadata(input.logicalId, tagRecord(byKey.metadata)))
418 ) {
419 return byKey;
420 }
421 }
422 return yield* findByAlchemyId(input.logicalId);
423});
424
425const desiredMetadata = Effect.fn(function* (
426 id: string,
427 metadata: Record<string, string> | undefined,
428) {
429 return {
430 ...toMetadata(metadata),
431 ...(yield* createInternalMetadata(id)),
432 };
433});
434
435export const IssuingPersonalizationDesignProvider = () =>
436 Provider.succeed(IssuingPersonalizationDesign, {
437 stables: ["id", "created", "livemode"],
438 // Stripe has no delete/archive API; residue would reappear every nuke.
439 nuke: { skip: true },
440
441 diff: Effect.fn(function* ({ news }) {
442 if (!isResolved(news)) return undefined;
443 return undefined;
444 }),
445
446 read: Effect.fn(function* ({ id, output }) {
447 const existing = yield* observe({
448 id: output?.id,
449 logicalId: id,
450 lookupKey: output?.lookupKey,
451 });
452 if (existing === undefined) return undefined;
453 const attrs = toAttrs(existing);
454 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
455 ? attrs
456 : Unowned(attrs);
457 }),
458
459 list: Effect.fn(function* () {
460 const designs = yield* listAllDesigns();
461 return designs
462 .filter((design) => {
463 const metadata = tagRecord(design.metadata);
464 return metadata[alchemyMetadataKeys.stack] !== undefined;
465 })
466 .map(toAttrs);
467 }),
468
469 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
470 const name = yield* toName(id, news.name, output?.name);
471 const lookupKey = yield* toLookupKey(
472 id,
473 news.lookupKey,
474 output?.lookupKey,
475 );
476 const metadata = yield* desiredMetadata(id, news.metadata);
477 const desiredCardLogo = news.cardLogo;
478 const desiredCarrierText = compactCarrierText(news.carrierText);
479 const desiredIsDefault = news.preferences?.isDefault ?? false;
480 const desiredPhysicalBundle = news.physicalBundle;
481
482 let current = yield* observe({
483 id: output?.id,
484 logicalId: id,
485 lookupKey,
486 });
487
488 if (current === undefined) {
489 current = yield* CreateIssuingPersonalizationDesign({
490 physical_bundle: desiredPhysicalBundle,
491 name,
492 lookup_key: lookupKey,
493 transfer_lookup_key: true,
494 metadata,
495 ...(desiredCardLogo !== undefined
496 ? { card_logo: desiredCardLogo }
497 : {}),
498 ...(desiredCarrierText !== undefined
499 ? { carrier_text: toWireCarrierText(desiredCarrierText) }
500 : {}),
501 ...(desiredIsDefault ? { preferences: { is_default: true } } : {}),
502 }).pipe(
503 withRequestOptions({
504 idempotencyKey: `alchemy-issuing-personalization-design-${instanceId}`,
505 }),
506 );
507 }
508
509 const observedMetadata = tagRecord(current.metadata);
510 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
511 const metadataChanged = upsert.length > 0 || removed.length > 0;
512 const nameChanged = (current.name ?? "") !== name;
513 const lookupKeyChanged = (current.lookup_key ?? "") !== lookupKey;
514 const physicalBundleChanged =
515 (idOf(current.physical_bundle) ?? "") !== desiredPhysicalBundle;
516 const cardLogoChanged =
517 (idOf(current.card_logo) ?? "") !== (desiredCardLogo ?? "");
518 const carrierTextChanged = !deepEqual(
519 fromObservedCarrierText(current.carrier_text),
520 desiredCarrierText,
521 { stripNullish: true },
522 );
523 const preferencesChanged =
524 current.preferences.is_default !== desiredIsDefault;
525
526 if (
527 !nameChanged &&
528 !lookupKeyChanged &&
529 !physicalBundleChanged &&
530 !cardLogoChanged &&
531 !carrierTextChanged &&
532 !preferencesChanged &&
533 !metadataChanged
534 ) {
535 return toAttrs(current);
536 }
537
538 const updated = yield* UpdateIssuingPersonalizationDesign({
539 personalization_design: current.id,
540 ...(nameChanged ? { name } : {}),
541 ...(lookupKeyChanged
542 ? { lookup_key: lookupKey, transfer_lookup_key: true }
543 : {}),
544 ...(physicalBundleChanged
545 ? { physical_bundle: desiredPhysicalBundle }
546 : {}),
547 ...(cardLogoChanged
548 ? {
549 card_logo: desiredCardLogo !== undefined ? desiredCardLogo : "",
550 }
551 : {}),
552 ...(carrierTextChanged
553 ? {
554 carrier_text:
555 desiredCarrierText !== undefined
556 ? toWireCarrierText(desiredCarrierText)
557 : "",
558 }
559 : {}),
560 ...(preferencesChanged
561 ? { preferences: { is_default: desiredIsDefault } }
562 : {}),
563 ...(metadataChanged
564 ? {
565 metadata: {
566 ...Object.fromEntries(
567 upsert.map((tag) => [tag.Key, tag.Value]),
568 ),
569 ...Object.fromEntries(removed.map((key) => [key, ""])),
570 },
571 }
572 : {}),
573 });
574 return toAttrs(updated);
575 }),
576
577 delete: Effect.fn(function* (_input) {
578 // Stripe has no delete, archive, or deactivate API for Issuing
579 // Personalization Designs. Destroy is a no-op; the object remains
580 // as residue on the account.
581 yield* Effect.void;
582 }),
583 });