1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteCoupon,
4 GetCoupons,
5 GetCoupon,
6 CreateCoupon,
7 UpdateCoupon,
8 type Coupon as StripeCoupon,
9 type CreateCouponRequestCurrencyOptionsMap,
10} from "@distilled.cloud/stripe/stripe";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import { Unowned } from "../AdoptPolicy.ts";
14import { deepEqual, isResolved } from "../Diff.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 LIST_PAGE_SIZE = 100;
30const LIST_MAX_PAGES = 100;
31
32/** How long a customer who applies this coupon keeps the discount. */
33export type CouponDuration = "forever" | "once" | "repeating";
34
35export interface CouponAppliesTo {
36 /**
37 * Product ids this coupon applies to. Create-only — changing it
38 * replaces the coupon.
39 */
40 products?: string[];
41}
42
43export interface CouponCurrencyOption {
44 /**
45 * Amount (in the currency's minor units) taken off the invoice subtotal.
46 */
47 amountOff: number;
48}
49
50export interface CouponProps {
51 /**
52 * Unique coupon id (the code used when applying the coupon). If omitted,
53 * Stripe generates one. Changing it replaces the coupon.
54 */
55 couponId?: string;
56 /**
57 * Name displayed to customers on invoices and receipts. Defaults to the
58 * coupon id if omitted. Mutable.
59 */
60 name?: string;
61 /**
62 * Percent taken off the invoice subtotal (0–100). Required unless
63 * `amountOff` is set. Create-only — changing it replaces the coupon.
64 */
65 percentOff?: number;
66 /**
67 * Amount (in `currency` minor units) taken off the invoice subtotal.
68 * Required unless `percentOff` is set. Create-only — changing it
69 * replaces the coupon.
70 */
71 amountOff?: number;
72 /**
73 * Three-letter ISO currency code for `amountOff`. Required when
74 * `amountOff` is set. Create-only — changing it replaces the coupon.
75 */
76 currency?: string;
77 /**
78 * Per-currency `amountOff` values (amount-based coupons only). Mutable.
79 */
80 currencyOptions?: Record<string, CouponCurrencyOption>;
81 /**
82 * How long the discount lasts when applied to a subscription.
83 * @default "once"
84 */
85 duration?: CouponDuration;
86 /**
87 * Months the discount lasts when `duration` is `repeating`. Create-only.
88 */
89 durationInMonths?: number;
90 /**
91 * Maximum redemptions across all customers. Create-only.
92 */
93 maxRedemptions?: number;
94 /**
95 * Unix timestamp after which the coupon can no longer be redeemed.
96 * Create-only.
97 */
98 redeemBy?: number;
99 /**
100 * Restrict the coupon to specific products. Create-only.
101 */
102 appliesTo?: CouponAppliesTo;
103 /**
104 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
105 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
106 * not contain `:`. Mutable.
107 */
108 metadata?: Record<string, string>;
109}
110
111export type Coupon = Resource<
112 "Stripe.Coupon",
113 CouponProps,
114 {
115 /** Stripe coupon id (the redeemable code). */
116 id: string;
117 /** Name displayed to customers, if set. */
118 name: string | undefined;
119 /** Percent-off discount, if this is a percent coupon. */
120 percentOff: number | undefined;
121 /** Amount-off discount (minor units), if this is an amount coupon. */
122 amountOff: number | undefined;
123 /** Currency of `amountOff`, if this is an amount coupon. */
124 currency: string | undefined;
125 /** Per-currency amount-off values, if set. */
126 currencyOptions: Record<string, CouponCurrencyOption> | undefined;
127 /** How long the discount lasts. */
128 duration: CouponDuration;
129 /** Months the discount lasts when `duration` is `repeating`. */
130 durationInMonths: number | undefined;
131 /** Maximum redemptions, if set. */
132 maxRedemptions: number | undefined;
133 /** Unix timestamp after which the coupon cannot be redeemed. */
134 redeemBy: number | undefined;
135 /** Products this coupon applies to, if restricted. */
136 appliesTo: CouponAppliesTo | undefined;
137 /** Whether the coupon can still be applied. */
138 valid: boolean;
139 /** Times this coupon has been redeemed. */
140 timesRedeemed: number;
141 /** Unix timestamp when the coupon was created. */
142 created: number;
143 /** Whether the coupon exists in live mode. */
144 livemode: boolean;
145 /** User-defined metadata (Alchemy ownership keys stripped). */
146 metadata: Record<string, string>;
147 },
148 never,
149 Providers
150>;
151
152/**
153 * A Stripe Coupon — a percent-off or amount-off discount applied to
154 * subscriptions, invoices, Checkout sessions, and quotes. Coupons do not
155 * apply to one-off charges or PaymentIntents.
156 *
157 * Create with either `percentOff` or `amountOff` + `currency`. `duration`
158 * is `forever`, `once` (the default), or `repeating` (requires
159 * `durationInMonths`). Name, metadata, and `currencyOptions` update in
160 * place; the discount shape is immutable and changing it replaces the
161 * coupon.
162 *
163 * @see https://docs.stripe.com/api/coupons
164 *
165 * ### Creating a Coupon
166 * **Example:** Percent-off forever
167 * ```typescript
168 * const welcome = yield* Stripe.Coupon("welcome", {
169 * percentOff: 20,
170 * duration: "forever",
171 * name: "Welcome 20%",
172 * });
173 * ```
174 *
175 * **Example:** Amount-off once
176 * ```typescript
177 * const fiveOff = yield* Stripe.Coupon("five-off", {
178 * amountOff: 500,
179 * currency: "usd",
180 * duration: "once",
181 * name: "$5 off",
182 * });
183 * ```
184 *
185 * ### Repeating duration
186 * **Example:** 10% off for three months
187 * ```typescript
188 * const quarterly = yield* Stripe.Coupon("quarterly", {
189 * percentOff: 10,
190 * duration: "repeating",
191 * durationInMonths: 3,
192 * });
193 * ```
194 *
195 * ### Updating a Coupon
196 * **Example:** Rename and retag
197 * ```typescript
198 * const welcome = yield* Stripe.Coupon("welcome", {
199 * percentOff: 20,
200 * duration: "forever",
201 * name: "Welcome 20% (updated)",
202 * metadata: { campaign: "spring" },
203 * });
204 * ```
205 *
206 * @resource
207 * @product Product
208 */
209export const Coupon = Resource<Coupon>("Stripe.Coupon");
210
211export class CouponNotResolved extends Data.TaggedError(
212 "Stripe.CouponNotResolved",
213)<{
214 couponId: string | undefined;
215}> {}
216
217type CouponAttributes = Coupon["Attributes"];
218
219const userMetadata = (
220 metadata: Record<string, string | undefined> | null | undefined,
221): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
222
223const fromWireCurrencyOptions = (
224 options: StripeCoupon["currency_options"] | undefined,
225): Record<string, CouponCurrencyOption> | undefined => {
226 if (options === undefined) return undefined;
227 const out: Record<string, CouponCurrencyOption> = {};
228 for (const [currency, value] of Object.entries(options)) {
229 if (value === undefined) continue;
230 out[currency] = { amountOff: value.amount_off };
231 }
232 return Object.keys(out).length > 0 ? out : undefined;
233};
234
235const toWireCurrencyOptions = (
236 options: Record<string, CouponCurrencyOption> | undefined,
237): CreateCouponRequestCurrencyOptionsMap | undefined => {
238 if (options === undefined) return undefined;
239 return Object.fromEntries(
240 Object.entries(options).map(([currency, value]) => [
241 currency,
242 { amount_off: value.amountOff },
243 ]),
244 );
245};
246
247const fromObservedAppliesTo = (
248 appliesTo: StripeCoupon["applies_to"] | undefined,
249): CouponAppliesTo | undefined => {
250 if (appliesTo === undefined) return undefined;
251 return { products: appliesTo.products };
252};
253
254const toAttrs = (coupon: StripeCoupon): CouponAttributes => ({
255 id: coupon.id,
256 name: coupon.name ?? undefined,
257 percentOff: coupon.percent_off ?? undefined,
258 amountOff: coupon.amount_off ?? undefined,
259 currency: coupon.currency ?? undefined,
260 currencyOptions: fromWireCurrencyOptions(coupon.currency_options),
261 duration: coupon.duration,
262 durationInMonths: coupon.duration_in_months ?? undefined,
263 maxRedemptions: coupon.max_redemptions ?? undefined,
264 redeemBy: coupon.redeem_by ?? undefined,
265 appliesTo: fromObservedAppliesTo(coupon.applies_to),
266 valid: coupon.valid,
267 timesRedeemed: coupon.times_redeemed,
268 created: coupon.created,
269 livemode: coupon.livemode,
270 metadata: userMetadata(coupon.metadata),
271});
272
273const isMissingCoupon = isMissingStripeResource;
274
275const getById = (coupon: string) =>
276 GetCoupon({ coupon }).pipe(
277 Effect.catchIf(isMissingCoupon, () => Effect.succeed(undefined)),
278 );
279
280const listAllCoupons = Effect.fn(function* () {
281 const coupons: StripeCoupon[] = [];
282 let startingAfter: string | undefined;
283 for (let page = 0; page < LIST_MAX_PAGES; page++) {
284 const response = yield* GetCoupons({
285 limit: LIST_PAGE_SIZE,
286 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
287 });
288 coupons.push(...response.data);
289 if (!response.has_more || response.data.length === 0) {
290 break;
291 }
292 startingAfter = response.data[response.data.length - 1]?.id;
293 if (startingAfter === undefined) {
294 break;
295 }
296 }
297 return coupons;
298});
299
300const findByAlchemyId = Effect.fn(function* (id: string) {
301 const coupons = yield* listAllCoupons();
302 const matches: StripeCoupon[] = [];
303 for (const coupon of coupons) {
304 if (yield* hasAlchemyMetadata(id, tagRecord(coupon.metadata))) {
305 matches.push(coupon);
306 }
307 }
308 matches.sort((a, b) => b.created - a.created);
309 return matches[0];
310});
311
312const observe = Effect.fn(function* (input: {
313 id?: string;
314 logicalId: string;
315}) {
316 if (input.id !== undefined) {
317 const byId = yield* getById(input.id);
318 if (byId !== undefined) return byId;
319 }
320 return yield* findByAlchemyId(input.logicalId);
321});
322
323const desiredMetadata = Effect.fn(function* (
324 id: string,
325 metadata: Record<string, string> | undefined,
326) {
327 return {
328 ...toMetadata(metadata),
329 ...(yield* createInternalMetadata(id)),
330 };
331});
332
333const replaceOnCreateOnlyChange = (
334 olds: CouponProps | undefined,
335 news: CouponProps,
336 output: CouponAttributes | undefined,
337): boolean => {
338 if (
339 news.couponId !== undefined &&
340 news.couponId !== (output?.id ?? olds?.couponId)
341 ) {
342 return true;
343 }
344 if (
345 (news.percentOff ?? output?.percentOff ?? olds?.percentOff) !==
346 (output?.percentOff ?? olds?.percentOff)
347 ) {
348 return true;
349 }
350 if (
351 (news.amountOff ?? output?.amountOff ?? olds?.amountOff) !==
352 (output?.amountOff ?? olds?.amountOff)
353 ) {
354 return true;
355 }
356 if (
357 (news.currency ?? output?.currency ?? olds?.currency) !==
358 (output?.currency ?? olds?.currency)
359 ) {
360 return true;
361 }
362 if (
363 (news.duration ?? output?.duration ?? olds?.duration ?? "once") !==
364 (output?.duration ?? olds?.duration ?? "once")
365 ) {
366 return true;
367 }
368 if (
369 (news.durationInMonths ??
370 output?.durationInMonths ??
371 olds?.durationInMonths) !==
372 (output?.durationInMonths ?? olds?.durationInMonths)
373 ) {
374 return true;
375 }
376 if (
377 (news.maxRedemptions ?? output?.maxRedemptions ?? olds?.maxRedemptions) !==
378 (output?.maxRedemptions ?? olds?.maxRedemptions)
379 ) {
380 return true;
381 }
382 if (
383 (news.redeemBy ?? output?.redeemBy ?? olds?.redeemBy) !==
384 (output?.redeemBy ?? olds?.redeemBy)
385 ) {
386 return true;
387 }
388 if (
389 news.appliesTo !== undefined &&
390 !deepEqual(news.appliesTo, output?.appliesTo ?? olds?.appliesTo, {
391 stripNullish: true,
392 })
393 ) {
394 return true;
395 }
396 return false;
397};
398
399export const CouponProvider = () =>
400 Provider.succeed(Coupon, {
401 stables: [
402 "id",
403 "percentOff",
404 "amountOff",
405 "currency",
406 "duration",
407 "durationInMonths",
408 "created",
409 "livemode",
410 ],
411
412 diff: Effect.fn(function* ({ olds, news, output }) {
413 if (!isResolved(news)) return undefined;
414 if (replaceOnCreateOnlyChange(olds, news, output)) {
415 const previousId = output?.id ?? olds?.couponId;
416 const nextId = news.couponId ?? previousId;
417 return {
418 action: "replace" as const,
419 deleteFirst: nextId !== undefined && nextId === previousId,
420 };
421 }
422 return undefined;
423 }),
424
425 read: Effect.fn(function* ({ id, output }) {
426 const existing = yield* observe({
427 id: output?.id,
428 logicalId: id,
429 });
430 if (existing === undefined) return undefined;
431 const attrs = toAttrs(existing);
432 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
433 ? attrs
434 : Unowned(attrs);
435 }),
436
437 list: Effect.fn(function* () {
438 const coupons = yield* listAllCoupons();
439 return coupons
440 .filter((coupon) => {
441 const metadata = tagRecord(coupon.metadata);
442 return metadata[alchemyMetadataKeys.stack] !== undefined;
443 })
444 .map(toAttrs);
445 }),
446
447 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
448 const metadata = yield* desiredMetadata(id, news.metadata);
449 const currencyOptions = toWireCurrencyOptions(news.currencyOptions);
450
451 let current = yield* observe({
452 id: news.couponId ?? output?.id,
453 logicalId: id,
454 });
455
456 if (current === undefined) {
457 current = yield* CreateCoupon({
458 ...(news.couponId !== undefined ? { id: news.couponId } : {}),
459 ...(news.name !== undefined ? { name: news.name } : {}),
460 ...(news.percentOff !== undefined
461 ? { percent_off: news.percentOff }
462 : {}),
463 ...(news.amountOff !== undefined
464 ? { amount_off: news.amountOff }
465 : {}),
466 ...(news.currency !== undefined ? { currency: news.currency } : {}),
467 ...(currencyOptions !== undefined
468 ? { currency_options: currencyOptions }
469 : {}),
470 ...(news.duration !== undefined ? { duration: news.duration } : {}),
471 ...(news.durationInMonths !== undefined
472 ? { duration_in_months: news.durationInMonths }
473 : {}),
474 ...(news.maxRedemptions !== undefined
475 ? { max_redemptions: news.maxRedemptions }
476 : {}),
477 ...(news.redeemBy !== undefined ? { redeem_by: news.redeemBy } : {}),
478 ...(news.appliesTo?.products !== undefined
479 ? { applies_to: { products: news.appliesTo.products } }
480 : {}),
481 metadata,
482 }).pipe(
483 withRequestOptions({
484 idempotencyKey: `alchemy-coupon-${instanceId}`,
485 }),
486 );
487 }
488
489 if (current === undefined) {
490 return yield* new CouponNotResolved({ couponId: news.couponId });
491 }
492
493 const observedMetadata = tagRecord(current.metadata);
494 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
495 const metadataChanged = upsert.length > 0 || removed.length > 0;
496 const nameChanged =
497 news.name !== undefined && (current.name ?? undefined) !== news.name;
498 const observedCurrencyOptions = fromWireCurrencyOptions(
499 current.currency_options,
500 );
501 const currencyOptionsChanged =
502 news.currencyOptions !== undefined &&
503 !deepEqual(news.currencyOptions, observedCurrencyOptions, {
504 stripNullish: true,
505 });
506
507 if (!nameChanged && !metadataChanged && !currencyOptionsChanged) {
508 return toAttrs(current);
509 }
510
511 const updated = yield* UpdateCoupon({
512 coupon: current.id,
513 ...(nameChanged ? { name: news.name } : {}),
514 ...(currencyOptionsChanged
515 ? { currency_options: currencyOptions }
516 : {}),
517 ...(metadataChanged
518 ? {
519 metadata: {
520 ...Object.fromEntries(
521 upsert.map((tag) => [tag.Key, tag.Value]),
522 ),
523 ...Object.fromEntries(removed.map((key) => [key, ""])),
524 },
525 }
526 : {}),
527 });
528 return toAttrs(updated);
529 }),
530
531 delete: Effect.fn(function* ({ output }) {
532 yield* DeleteCoupon({ coupon: output.id }).pipe(
533 Effect.catchIf(isMissingCoupon, () => Effect.void),
534 );
535 }),
536 });