1import { withRequestOptions } from "@distilled.cloud/stripe";
2import { isMissingStripeResource } from "./missing.ts";
3import {
4 GetPromotionCodes,
5 GetPromotionCode,
6 CreatePromotionCode,
7 UpdatePromotionCode,
8 type CreatePromotionCodeRequestRestrictions,
9 type CreatePromotionCodeRequestRestrictionsCurrencyOptionsMap,
10 type PromotionCode as StripePromotionCode,
11} from "@distilled.cloud/stripe/stripe";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import { Unowned } from "../AdoptPolicy.ts";
15import { deepEqual, isResolved } from "../Diff.ts";
16import { createPhysicalName } from "../PhysicalName.ts";
17import * as Provider from "../Provider.ts";
18import { Resource } from "../Resource.ts";
19import { tagRecord } from "../Tags.ts";
20import { recordsEqual } from "../Util/equal.ts";
21import {
22 alchemyMetadataKeys,
23 createInternalMetadata,
24 diffMetadata,
25 hasAlchemyMetadata,
26 stripInternalMetadata,
27 toMetadata,
28} from "./Metadata.ts";
29import type { Providers } from "./Providers.ts";
30
31export interface PromotionCodeCurrencyOption {
32 /**
33 * Minimum amount (in the currency's minor units) required to redeem this
34 * promotion code.
35 */
36 minimumAmount: number;
37}
38
39export interface PromotionCodeRestrictions {
40 /**
41 * When true, the code can only be redeemed by customers with no successful
42 * payments or invoices. Create-only — changing it replaces the code.
43 */
44 firstTimeTransaction?: boolean;
45 /**
46 * Minimum amount required to redeem this code. Create-only.
47 */
48 minimumAmount?: number;
49 /**
50 * Three-letter ISO currency code for `minimumAmount`. Create-only.
51 */
52 minimumAmountCurrency?: string;
53 /**
54 * Per-currency minimum amounts. Mutable on update.
55 */
56 currencyOptions?: Record<string, PromotionCodeCurrencyOption>;
57}
58
59export interface PromotionCodeProps {
60 /**
61 * Id of the coupon this promotion code redeems. Changing it replaces
62 * the promotion code.
63 */
64 coupon: string;
65 /**
66 * Customer-facing code. Unique among active promotion codes (case
67 * insensitive). Valid characters: `A-Z`, `a-z`, `0-9`, `-`. If omitted,
68 * a unique code is generated from the stack, stage, and logical id.
69 * Changing it replaces the promotion code.
70 */
71 code?: string;
72 /**
73 * Whether the promotion code can currently be redeemed.
74 * @default true
75 */
76 active?: boolean;
77 /**
78 * Restrict redemption to this customer id. Create-only.
79 */
80 customer?: string;
81 /**
82 * Restrict redemption to this customer account id. Create-only.
83 */
84 customerAccount?: string;
85 /**
86 * Unix timestamp after which the code can no longer be redeemed.
87 * Create-only.
88 */
89 expiresAt?: number;
90 /**
91 * Maximum number of times this code can be redeemed. Create-only.
92 */
93 maxRedemptions?: number;
94 /**
95 * User metadata. Alchemy ownership keys (`alchemy_stack`,
96 * `alchemy_stage`, `alchemy_id`) are merged in automatically. Keys
97 * cannot contain `:`.
98 */
99 metadata?: Record<string, string>;
100 /**
101 * Redemption restrictions.
102 */
103 restrictions?: PromotionCodeRestrictions;
104}
105
106export interface PromotionCode extends Resource<
107 "Stripe.PromotionCode",
108 PromotionCodeProps,
109 {
110 /** Stripe promotion code id (`promo_…`). */
111 id: string;
112 /** Customer-facing code. */
113 code: string;
114 /** Whether the code is currently redeemable. */
115 active: boolean;
116 /** Coupon id this code points at. */
117 couponId: string;
118 /** Restricted customer id, if any. */
119 customer: string | undefined;
120 /** Restricted customer account id, if any. */
121 customerAccount: string | undefined;
122 /** Unix expiry timestamp, if any. */
123 expiresAt: number | undefined;
124 /** Whether this object exists in live mode. */
125 livemode: boolean;
126 /** Maximum redemptions, if any. */
127 maxRedemptions: number | undefined;
128 /** Times this code has been redeemed. */
129 timesRedeemed: number;
130 /** Unix creation timestamp. */
131 created: number;
132 /** User metadata (Alchemy ownership keys stripped). */
133 metadata: Record<string, string>;
134 /** Current redemption restrictions. */
135 restrictions: PromotionCodeRestrictions;
136 },
137 never,
138 Providers
139> {}
140
141/**
142 * A Stripe Promotion Code — a customer-redeemable code for an underlying
143 * coupon. Stripe does not hard-delete promotion codes; destroying this
144 * resource deactivates it (`active: false`).
145 *
146 * `code`, `coupon`, `customer`, `expiresAt`, `maxRedemptions`, and most
147 * restrictions are immutable. Changing them replaces the promotion code
148 * (create a new one, then deactivate the old). `active`, `metadata`, and
149 * `restrictions.currencyOptions` update in place.
150 *
151 * @see https://docs.stripe.com/api/promotion_codes
152 *
153 * ### Creating a Promotion Code
154 * **Example:** Code for an existing coupon
155 * ```typescript
156 * const welcome = yield* Stripe.PromotionCode("welcome", {
157 * coupon: "25OFF",
158 * code: "WELCOME25",
159 * });
160 * ```
161 *
162 * **Example:** Generated code with a redemption cap
163 * ```typescript
164 * const launch = yield* Stripe.PromotionCode("launch", {
165 * coupon: "10OFF",
166 * maxRedemptions: 100,
167 * metadata: { campaign: "launch" },
168 * });
169 * ```
170 *
171 * ### Updating a Promotion Code
172 * **Example:** Pause redemptions and update metadata
173 * ```typescript
174 * const welcome = yield* Stripe.PromotionCode("welcome", {
175 * coupon: "25OFF",
176 * code: "WELCOME25",
177 * active: false,
178 * metadata: { campaign: "paused" },
179 * });
180 * ```
181 *
182 * ### Deactivating a Promotion Code
183 * **Example:** Destroy deactivates rather than deleting
184 * ```typescript
185 * // stack.destroy() / resource removal sets active: false
186 * const welcome = yield* Stripe.PromotionCode("welcome", {
187 * coupon: "25OFF",
188 * code: "WELCOME25",
189 * });
190 * ```
191 *
192 * @resource
193 * @product Product
194 */
195export const PromotionCode = Resource<PromotionCode>("Stripe.PromotionCode");
196
197export class PromotionCodeNotResolved extends Data.TaggedError(
198 "Stripe.PromotionCodeNotResolved",
199)<{
200 code: string;
201}> {}
202
203type PromotionCodeAttributes = PromotionCode["Attributes"];
204
205const CODE_MAX_LENGTH = 40;
206const LIST_PAGE_SIZE = 100;
207const LIST_MAX_PAGES = 100;
208
209const idOf = (value: unknown): string | undefined => {
210 if (typeof value === "string") return value;
211 if (value !== null && typeof value === "object" && "id" in value) {
212 const id = (value as { id?: unknown }).id;
213 if (typeof id === "string") return id;
214 }
215 return undefined;
216};
217
218const couponIdOf = (promo: StripePromotionCode): string =>
219 idOf(promo.promotion?.coupon) ?? "";
220
221const fromWireCurrencyOptions = (
222 options: StripePromotionCode["restrictions"]["currency_options"] | undefined,
223): Record<string, PromotionCodeCurrencyOption> | undefined => {
224 if (options === undefined) return undefined;
225 const out: Record<string, PromotionCodeCurrencyOption> = {};
226 for (const [currency, value] of Object.entries(options)) {
227 if (value === undefined) continue;
228 out[currency] = { minimumAmount: value.minimum_amount };
229 }
230 return Object.keys(out).length > 0 ? out : undefined;
231};
232
233const toWireCurrencyOptions = (
234 options: Record<string, PromotionCodeCurrencyOption> | undefined,
235): CreatePromotionCodeRequestRestrictionsCurrencyOptionsMap | undefined => {
236 if (options === undefined) return undefined;
237 return Object.fromEntries(
238 Object.entries(options).map(([currency, value]) => [
239 currency,
240 { minimum_amount: value.minimumAmount },
241 ]),
242 );
243};
244
245const fromObservedRestrictions = (
246 restrictions: StripePromotionCode["restrictions"] | undefined,
247): PromotionCodeRestrictions => {
248 if (restrictions === undefined) return {};
249 return {
250 firstTimeTransaction: restrictions.first_time_transaction,
251 ...(restrictions.minimum_amount != null
252 ? { minimumAmount: restrictions.minimum_amount }
253 : {}),
254 ...(restrictions.minimum_amount_currency != null
255 ? { minimumAmountCurrency: restrictions.minimum_amount_currency }
256 : {}),
257 ...(fromWireCurrencyOptions(restrictions.currency_options) !== undefined
258 ? {
259 currencyOptions: fromWireCurrencyOptions(
260 restrictions.currency_options,
261 ),
262 }
263 : {}),
264 };
265};
266
267const toCreateRestrictions = (
268 restrictions: PromotionCodeRestrictions | undefined,
269): CreatePromotionCodeRequestRestrictions | undefined => {
270 if (restrictions === undefined) return undefined;
271 const currency_options = toWireCurrencyOptions(restrictions.currencyOptions);
272 const body: CreatePromotionCodeRequestRestrictions = {
273 ...(restrictions.firstTimeTransaction !== undefined
274 ? { first_time_transaction: restrictions.firstTimeTransaction }
275 : {}),
276 ...(restrictions.minimumAmount !== undefined
277 ? { minimum_amount: restrictions.minimumAmount }
278 : {}),
279 ...(restrictions.minimumAmountCurrency !== undefined
280 ? { minimum_amount_currency: restrictions.minimumAmountCurrency }
281 : {}),
282 ...(currency_options !== undefined ? { currency_options } : {}),
283 };
284 return Object.keys(body).length > 0 ? body : undefined;
285};
286
287const toAttrs = (promo: StripePromotionCode): PromotionCodeAttributes => ({
288 id: promo.id,
289 code: promo.code,
290 active: promo.active,
291 couponId: couponIdOf(promo),
292 customer: idOf(promo.customer),
293 customerAccount: promo.customer_account ?? undefined,
294 expiresAt: promo.expires_at ?? undefined,
295 livemode: promo.livemode,
296 maxRedemptions: promo.max_redemptions ?? undefined,
297 timesRedeemed: promo.times_redeemed,
298 created: promo.created,
299 metadata: stripInternalMetadata(tagRecord(promo.metadata)),
300 restrictions: fromObservedRestrictions(promo.restrictions),
301});
302
303const toCode = (id: string, code: string | undefined, existing?: string) =>
304 Effect.gen(function* () {
305 return (
306 code ??
307 existing ??
308 (yield* createPhysicalName({ id, maxLength: CODE_MAX_LENGTH }))
309 );
310 });
311
312const isResourceMissing = isMissingStripeResource;
313
314const getById = (promotionCode: string) =>
315 GetPromotionCode({
316 promotion_code: promotionCode,
317 expand: ["promotion.coupon"],
318 }).pipe(Effect.catchIf(isResourceMissing, () => Effect.succeed(undefined)));
319
320const findByCode = (code: string) =>
321 Effect.gen(function* () {
322 const [active, inactive] = yield* Effect.all(
323 [
324 GetPromotionCodes({
325 code,
326 active: true,
327 limit: LIST_PAGE_SIZE,
328 expand: ["data.promotion.coupon"],
329 }),
330 GetPromotionCodes({
331 code,
332 active: false,
333 limit: LIST_PAGE_SIZE,
334 expand: ["data.promotion.coupon"],
335 }),
336 ],
337 { concurrency: 2 },
338 );
339 return (
340 active.data.find((item) => item.code === code) ??
341 inactive.data.find((item) => item.code === code)
342 );
343 });
344
345const listByActive = Effect.fn(function* (active: boolean) {
346 const items: StripePromotionCode[] = [];
347 let startingAfter: string | undefined;
348 for (let page = 0; page < LIST_MAX_PAGES; page++) {
349 const result = yield* GetPromotionCodes({
350 active,
351 limit: LIST_PAGE_SIZE,
352 expand: ["data.promotion.coupon"],
353 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
354 });
355 items.push(...result.data);
356 if (!result.has_more || result.data.length === 0) break;
357 startingAfter = result.data[result.data.length - 1]?.id;
358 if (startingAfter === undefined) break;
359 }
360 return items;
361});
362
363const listAllPromotionCodes = Effect.fn(function* () {
364 const [active, inactive] = yield* Effect.all(
365 [listByActive(true), listByActive(false)],
366 { concurrency: 2 },
367 );
368 const seen = new Set<string>();
369 const items: StripePromotionCode[] = [];
370 for (const item of [...active, ...inactive]) {
371 if (seen.has(item.id)) continue;
372 seen.add(item.id);
373 items.push(item);
374 }
375 return items;
376});
377
378const observe = Effect.fn(function* (input: { id?: string; code: string }) {
379 if (input.id !== undefined) {
380 const byId = yield* getById(input.id);
381 if (byId !== undefined) return byId;
382 }
383 return yield* findByCode(input.code);
384});
385
386const desiredMetadata = Effect.fn(function* (
387 id: string,
388 user: Record<string, string> | undefined,
389) {
390 return {
391 ...toMetadata(user),
392 ...(yield* createInternalMetadata(id)),
393 };
394});
395
396const replaceOnCreateOnlyChange = (
397 olds: PromotionCodeProps | undefined,
398 news: PromotionCodeProps,
399 output: PromotionCodeAttributes | undefined,
400): boolean => {
401 const previousCoupon = output?.couponId || olds?.coupon;
402 if (previousCoupon !== undefined && news.coupon !== previousCoupon) {
403 return true;
404 }
405 if (news.code !== undefined && news.code !== (output?.code ?? olds?.code)) {
406 return true;
407 }
408 if ((news.customer ?? undefined) !== (output?.customer ?? olds?.customer)) {
409 return true;
410 }
411 if (
412 (news.customerAccount ?? undefined) !==
413 (output?.customerAccount ?? olds?.customerAccount)
414 ) {
415 return true;
416 }
417 if (
418 (news.expiresAt ?? undefined) !== (output?.expiresAt ?? olds?.expiresAt)
419 ) {
420 return true;
421 }
422 if (
423 (news.maxRedemptions ?? undefined) !==
424 (output?.maxRedemptions ?? olds?.maxRedemptions)
425 ) {
426 return true;
427 }
428 const oldR = olds?.restrictions;
429 const newR = news.restrictions;
430 const outR = output?.restrictions;
431 const desiredFirstTime =
432 newR?.firstTimeTransaction ?? oldR?.firstTimeTransaction ?? false;
433 const observedFirstTime =
434 outR?.firstTimeTransaction ?? oldR?.firstTimeTransaction ?? false;
435 if (desiredFirstTime !== observedFirstTime) {
436 return true;
437 }
438 if (
439 (newR?.minimumAmount ?? oldR?.minimumAmount) !==
440 (outR?.minimumAmount ?? oldR?.minimumAmount)
441 ) {
442 return true;
443 }
444 if (
445 (newR?.minimumAmountCurrency ?? oldR?.minimumAmountCurrency) !==
446 (outR?.minimumAmountCurrency ?? oldR?.minimumAmountCurrency)
447 ) {
448 return true;
449 }
450 return false;
451};
452
453export const PromotionCodeProvider = () =>
454 Provider.succeed(PromotionCode, {
455 stables: ["id", "code", "couponId", "created", "livemode"],
456
457 list: Effect.fn(function* () {
458 const items = yield* listAllPromotionCodes();
459 return items
460 .filter(
461 (item) =>
462 tagRecord(item.metadata)[alchemyMetadataKeys.stack] !== undefined,
463 )
464 .map(toAttrs);
465 }),
466
467 diff: Effect.fn(function* ({ olds, news, output }) {
468 if (!isResolved(news)) return undefined;
469 if (replaceOnCreateOnlyChange(olds, news, output)) {
470 const previousCode = output?.code ?? olds?.code;
471 const nextCode = news.code ?? previousCode;
472 return {
473 action: "replace" as const,
474 deleteFirst: nextCode !== undefined && nextCode === previousCode,
475 };
476 }
477 if ((news.active ?? true) !== (output?.active ?? olds?.active ?? true)) {
478 return { action: "update" } as const;
479 }
480 if (!recordsEqual(news.metadata ?? {}, olds?.metadata ?? {})) {
481 return { action: "update" } as const;
482 }
483 if (
484 !deepEqual(
485 news.restrictions?.currencyOptions,
486 olds?.restrictions?.currencyOptions ??
487 output?.restrictions.currencyOptions,
488 { stripNullish: true },
489 )
490 ) {
491 return { action: "update" } as const;
492 }
493 return undefined;
494 }),
495
496 read: Effect.fn(function* ({ id, olds, output }) {
497 const code = yield* toCode(id, olds?.code, output?.code);
498 const existing = yield* observe({ id: output?.id, code });
499 if (existing === undefined) return undefined;
500 const attrs = toAttrs(existing);
501 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
502 ? attrs
503 : Unowned(attrs);
504 }),
505
506 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
507 const code = yield* toCode(id, news.code, output?.code);
508 const metadata = yield* desiredMetadata(id, news.metadata);
509 const active = news.active ?? true;
510 const createRestrictions = toCreateRestrictions(news.restrictions);
511
512 let current = yield* observe({ id: output?.id, code });
513 // A leftover inactive code with this customer-facing `code` may point
514 // at a deleted coupon. Stripe forbids reactivating it — treat it as
515 // missing and create a new promotion code instead.
516 if (current !== undefined) {
517 const observedCoupon = couponIdOf(current);
518 if (observedCoupon !== "" && observedCoupon !== news.coupon) {
519 current = undefined;
520 }
521 }
522
523 if (current === undefined) {
524 current = yield* CreatePromotionCode({
525 promotion: { type: "coupon", coupon: news.coupon },
526 expand: ["promotion.coupon"],
527 code,
528 active,
529 ...(news.customer !== undefined ? { customer: news.customer } : {}),
530 ...(news.customerAccount !== undefined
531 ? { customer_account: news.customerAccount }
532 : {}),
533 ...(news.expiresAt !== undefined
534 ? { expires_at: news.expiresAt }
535 : {}),
536 ...(news.maxRedemptions !== undefined
537 ? { max_redemptions: news.maxRedemptions }
538 : {}),
539 metadata,
540 ...(createRestrictions !== undefined
541 ? { restrictions: createRestrictions }
542 : {}),
543 }).pipe(
544 withRequestOptions({
545 idempotencyKey: `alchemy-promotion-code-${instanceId}`,
546 }),
547 Effect.catchTag("InvalidRequestError", (error) =>
548 observe({ id: output?.id, code }).pipe(
549 Effect.flatMap((found) =>
550 found !== undefined && couponIdOf(found) === news.coupon
551 ? Effect.succeed(found)
552 : Effect.fail(error),
553 ),
554 ),
555 ),
556 );
557 }
558
559 if (current === undefined) {
560 return yield* new PromotionCodeNotResolved({ code });
561 }
562
563 if (current.active !== active) {
564 current = yield* UpdatePromotionCode({
565 promotion_code: current.id,
566 active,
567 });
568 }
569
570 const observedMeta = tagRecord(current.metadata);
571 const { upsert, removed } = diffMetadata(observedMeta, metadata);
572 if (upsert.length > 0 || removed.length > 0) {
573 current = yield* UpdatePromotionCode({
574 promotion_code: current.id,
575 metadata: {
576 ...Object.fromEntries(upsert.map((tag) => [tag.Key, tag.Value])),
577 ...Object.fromEntries(removed.map((key) => [key, ""])),
578 },
579 });
580 }
581
582 const desiredCurrency = news.restrictions?.currencyOptions;
583 const observedCurrency = fromWireCurrencyOptions(
584 current.restrictions.currency_options,
585 );
586 if (
587 desiredCurrency !== undefined &&
588 !deepEqual(desiredCurrency, observedCurrency, { stripNullish: true })
589 ) {
590 current = yield* UpdatePromotionCode({
591 promotion_code: current.id,
592 restrictions: {
593 currency_options: toWireCurrencyOptions(desiredCurrency),
594 },
595 });
596 }
597
598 const attrs = toAttrs(current);
599 return {
600 ...attrs,
601 couponId: attrs.couponId || news.coupon,
602 };
603 }),
604
605 delete: Effect.fn(function* ({ output }) {
606 const existing = yield* observe({ id: output.id, code: output.code });
607 if (existing === undefined || !existing.active) return;
608 yield* UpdatePromotionCode({
609 promotion_code: existing.id,
610 active: false,
611 }).pipe(Effect.catchIf(isResourceMissing, () => Effect.void));
612 }),
613 });