1import {
2 withRequestOptions,
3 type StripeOpError,
4} from "@distilled.cloud/stripe";
5import {
6 GetBillingCreditGrants,
7 GetBillingCreditGrant,
8 CreateBillingCreditGrant,
9 UpdateBillingCreditGrant,
10 CreateBillingCreditGrantVoid,
11 type BillingCreditGrant as StripeCreditGrant,
12 type CreateBillingCreditGrantRequestAmount,
13 type CreateBillingCreditGrantRequestApplicabilityConfig,
14} from "@distilled.cloud/stripe/stripe";
15import * as Data from "effect/Data";
16import * as Effect from "effect/Effect";
17import { Unowned } from "../AdoptPolicy.ts";
18import { deepEqual, isResolved } from "../Diff.ts";
19import { createPhysicalName } from "../PhysicalName.ts";
20import * as Provider from "../Provider.ts";
21import { Resource } from "../Resource.ts";
22import { tagRecord } from "../Tags.ts";
23import {
24 alchemyMetadataKeys,
25 createInternalMetadata,
26 diffMetadata,
27 hasAlchemyMetadata,
28 stripInternalMetadata,
29 toMetadata,
30} from "./Metadata.ts";
31import type { Providers } from "./Providers.ts";
32import { isMissingStripeResource } from "./missing.ts";
33
34const NAME_MAX_LENGTH = 250;
35const LIST_PAGE_SIZE = 100;
36const LIST_MAX_PAGES = 100;
37
38/** The type of this amount. Stripe currently only supports monetary credits. */
39export type CreditGrantAmountType = "monetary";
40
41/** The category of this credit grant. Tracking only — not shown to customers. */
42export type CreditGrantCategory = "paid" | "promotional";
43
44/**
45 * The price type that credit grants can apply to. Stripe currently only
46 * supports metered prices (prices with a Billing Meter).
47 */
48export type CreditGrantPriceType = "metered";
49
50export interface CreditGrantMonetaryAmount {
51 /**
52 * Three-letter ISO currency code, lowercase (e.g. `"usd"`).
53 */
54 currency: string;
55 /**
56 * Positive integer amount in the currency's minor units.
57 */
58 value: number;
59}
60
61export interface CreditGrantAmount {
62 /**
63 * Amount type. Stripe currently only supports `"monetary"`.
64 */
65 type: CreditGrantAmountType;
66 /**
67 * Monetary amount. Required when `type` is `"monetary"`. Create-only —
68 * changing it replaces the grant.
69 */
70 monetary?: CreditGrantMonetaryAmount;
71}
72
73export interface CreditGrantApplicablePrice {
74 /**
75 * Stripe Price id (`price_…`) this grant can apply to. Metered prices
76 * only. Cannot be combined with `priceType`.
77 */
78 id: string;
79}
80
81export interface CreditGrantScope {
82 /**
83 * Price type this grant applies to. Cannot be combined with `prices`.
84 * @default "metered"
85 */
86 priceType?: CreditGrantPriceType;
87 /**
88 * Specific metered prices this grant applies to (max 20). Cannot be
89 * combined with `priceType`.
90 */
91 prices?: CreditGrantApplicablePrice[];
92}
93
94export interface CreditGrantApplicabilityConfig {
95 /**
96 * Scope of prices this credit grant can apply to.
97 */
98 scope: CreditGrantScope;
99}
100
101export interface CreditGrantProps {
102 /**
103 * Id of the customer receiving the billing credits (`cus_…`). Required
104 * unless `customerAccount` is set. Create-only — changing it replaces
105 * the grant.
106 */
107 customer?: string;
108 /**
109 * Id of the account representing the customer receiving the billing
110 * credits. Required unless `customer` is set. Create-only.
111 */
112 customerAccount?: string;
113 /**
114 * Amount of this credit grant. Create-only — changing it replaces the
115 * grant.
116 */
117 amount: CreditGrantAmount;
118 /**
119 * What this credit grant applies to. Stripe currently only supports
120 * metered prices that have a Billing Meter. Create-only.
121 * @default { scope: { priceType: "metered" } }
122 */
123 applicabilityConfig?: CreditGrantApplicabilityConfig;
124 /**
125 * Category of this credit grant. Tracking only — not shown to the
126 * customer. Create-only.
127 * @default "paid"
128 */
129 category?: CreditGrantCategory;
130 /**
131 * Descriptive name shown in the Stripe Dashboard. If omitted, a unique
132 * name is generated from the stack, stage, and logical id. Create-only
133 * (the update API does not accept `name`).
134 */
135 name?: string;
136 /**
137 * Unix timestamp when the credits become eligible for use. Defaults to
138 * the current time. Create-only.
139 */
140 effectiveAt?: number;
141 /**
142 * Unix timestamp when the credits expire. If omitted, they never expire.
143 * Mutable — pass a new timestamp to update.
144 */
145 expiresAt?: number;
146 /**
147 * Priority for applying this grant (0 highest, 100 lowest).
148 * @default 50
149 */
150 priority?: number;
151 /**
152 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
153 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
154 * not contain `:`. Mutable.
155 */
156 metadata?: Record<string, string>;
157}
158
159export type CreditGrant = Resource<
160 "Stripe.CreditGrant",
161 CreditGrantProps,
162 {
163 /** Stripe credit grant id (`credgr_…`). */
164 id: string;
165 /** Dashboard name, if set. */
166 name: string | undefined;
167 /** Customer id receiving the credits (`cus_…`). */
168 customer: string | undefined;
169 /** Customer-account id receiving the credits, if set. */
170 customerAccount: string | undefined;
171 /** Amount of this credit grant. */
172 amount: CreditGrantAmount;
173 /** What this credit grant applies to. */
174 applicabilityConfig: CreditGrantApplicabilityConfig;
175 /** Category of this credit grant. */
176 category: CreditGrantCategory;
177 /** Unix timestamp when the credits become eligible for use. */
178 effectiveAt: number | undefined;
179 /** Unix timestamp when the credits expire, if they expire. */
180 expiresAt: number | undefined;
181 /** Priority for applying this grant (0 highest, 100 lowest). */
182 priority: number | undefined;
183 /** Unix timestamp when this grant was voided, if voided. */
184 voidedAt: number | undefined;
185 /** Unix timestamp when the grant was created. */
186 created: number;
187 /** Unix timestamp when the grant was last updated. */
188 updated: number;
189 /** Whether the grant exists in live mode. */
190 livemode: boolean;
191 /** User-defined metadata (Alchemy ownership keys stripped). */
192 metadata: Record<string, string>;
193 },
194 never,
195 Providers
196>;
197
198/**
199 * A Stripe Credit Grant — prepaid or promotional billing credits allocated
200 * to a customer and applied against metered prices. Amount, customer,
201 * applicability, category, effective time, and priority are immutable;
202 * changing them replaces the grant. `expiresAt` and `metadata` update in
203 * place. Credit grants cannot be hard-deleted: destroying this resource
204 * voids the grant (`voided_at` is set). Already-voided grants are treated
205 * as success.
206 *
207 * @see https://docs.stripe.com/api/billing/credit-grant
208 *
209 * ### Creating a Credit Grant
210 * **Example:** Promotional credits for metered prices
211 * ```typescript
212 * const customer = yield* Stripe.Customer("alice", {
213 * email: "alice@example.com",
214 * });
215 * const grant = yield* Stripe.CreditGrant("welcome-credits", {
216 * customer: customer.id,
217 * amount: { type: "monetary", monetary: { currency: "usd", value: 1000 } },
218 * applicabilityConfig: { scope: { priceType: "metered" } },
219 * category: "promotional",
220 * name: "Welcome credits",
221 * });
222 * ```
223 *
224 * **Example:** Paid credits scoped to specific prices
225 * ```typescript
226 * const grant = yield* Stripe.CreditGrant("prepaid", {
227 * customer: customer.id,
228 * amount: { type: "monetary", monetary: { currency: "usd", value: 5000 } },
229 * applicabilityConfig: {
230 * scope: { prices: [{ id: price.id }] },
231 * },
232 * category: "paid",
233 * });
234 * ```
235 *
236 * ### Updating a Credit Grant
237 * **Example:** Extend expiry and retag
238 * ```typescript
239 * const grant = yield* Stripe.CreditGrant("welcome-credits", {
240 * customer: customer.id,
241 * amount: { type: "monetary", monetary: { currency: "usd", value: 1000 } },
242 * expiresAt: 4102444800,
243 * metadata: { campaign: "spring" },
244 * });
245 * ```
246 *
247 * ### Voiding a Credit Grant
248 * **Example:** Destroy voids rather than deleting
249 * ```typescript
250 * // stack.destroy() / resource removal calls void
251 * const grant = yield* Stripe.CreditGrant("welcome-credits", {
252 * customer: customer.id,
253 * amount: { type: "monetary", monetary: { currency: "usd", value: 1000 } },
254 * });
255 * ```
256 *
257 * @resource
258 * @product Billing
259 */
260export const CreditGrant = Resource<CreditGrant>("Stripe.CreditGrant");
261
262export class CreditGrantNotResolved extends Data.TaggedError(
263 "Stripe.CreditGrantNotResolved",
264)<{
265 customer: string | undefined;
266}> {}
267
268type CreditGrantAttributes = CreditGrant["Attributes"];
269
270const DEFAULT_APPLICABILITY: CreditGrantApplicabilityConfig = {
271 scope: { priceType: "metered" },
272};
273
274const userMetadata = (
275 metadata: Record<string, string | undefined> | null | undefined,
276): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
277
278const idOf = (value: unknown): string | undefined => {
279 if (typeof value === "string") return value;
280 if (value !== null && typeof value === "object" && "id" in value) {
281 const id = (value as { id?: unknown }).id;
282 if (typeof id === "string") return id;
283 }
284 return undefined;
285};
286
287const isVoided = (grant: StripeCreditGrant): boolean => grant.voided_at != null;
288
289const fromWireAmount = (
290 amount: StripeCreditGrant["amount"],
291): CreditGrantAmount => ({
292 type: amount.type,
293 ...(amount.monetary != null
294 ? {
295 monetary: {
296 currency: amount.monetary.currency,
297 value: amount.monetary.value,
298 },
299 }
300 : {}),
301});
302
303const toWireAmount = (
304 amount: CreditGrantAmount,
305): CreateBillingCreditGrantRequestAmount => ({
306 type: amount.type,
307 ...(amount.monetary !== undefined
308 ? {
309 monetary: {
310 currency: amount.monetary.currency,
311 value: amount.monetary.value,
312 },
313 }
314 : {}),
315});
316
317const fromWireApplicability = (
318 config: StripeCreditGrant["applicability_config"],
319): CreditGrantApplicabilityConfig => {
320 const prices = config.scope.prices
321 ?.map((price) => (price.id != null ? { id: price.id } : undefined))
322 .filter(
323 (price): price is CreditGrantApplicablePrice => price !== undefined,
324 );
325 return {
326 scope: {
327 ...(config.scope.price_type !== undefined
328 ? { priceType: config.scope.price_type }
329 : {}),
330 ...(prices !== undefined && prices.length > 0 ? { prices } : {}),
331 },
332 };
333};
334
335const toWireApplicability = (
336 config: CreditGrantApplicabilityConfig,
337): CreateBillingCreditGrantRequestApplicabilityConfig => ({
338 scope: {
339 ...(config.scope.priceType !== undefined
340 ? { price_type: config.scope.priceType }
341 : {}),
342 ...(config.scope.prices !== undefined
343 ? { prices: config.scope.prices.map((price) => ({ id: price.id })) }
344 : {}),
345 },
346});
347
348const toAttrs = (grant: StripeCreditGrant): CreditGrantAttributes => ({
349 id: grant.id,
350 name: grant.name ?? undefined,
351 customer: idOf(grant.customer),
352 customerAccount: grant.customer_account ?? undefined,
353 amount: fromWireAmount(grant.amount),
354 applicabilityConfig: fromWireApplicability(grant.applicability_config),
355 category: grant.category,
356 effectiveAt: grant.effective_at ?? undefined,
357 expiresAt: grant.expires_at ?? undefined,
358 priority: grant.priority ?? undefined,
359 voidedAt: grant.voided_at ?? undefined,
360 created: grant.created,
361 updated: grant.updated,
362 livemode: grant.livemode,
363 metadata: userMetadata(grant.metadata),
364});
365
366const toName = (id: string, name: string | undefined, existing?: string) =>
367 Effect.gen(function* () {
368 return (
369 name ??
370 existing ??
371 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
372 );
373 });
374
375const isMissingGrant = isMissingStripeResource;
376
377const alreadyVoided = (error: StripeOpError): boolean =>
378 error._tag === "InvalidRequestError" &&
379 (error.message?.toLowerCase().includes("void") ?? false);
380
381const getById = (id: string) =>
382 GetBillingCreditGrant({ id }).pipe(
383 Effect.catchIf(isMissingGrant, () => Effect.succeed(undefined)),
384 );
385
386const listAllCreditGrants = Effect.fn(function* () {
387 const grants: StripeCreditGrant[] = [];
388 let startingAfter: string | undefined;
389 for (let page = 0; page < LIST_MAX_PAGES; page++) {
390 const response = yield* GetBillingCreditGrants({
391 limit: LIST_PAGE_SIZE,
392 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
393 });
394 grants.push(...response.data);
395 if (!response.has_more || response.data.length === 0) {
396 break;
397 }
398 startingAfter = response.data[response.data.length - 1]?.id;
399 if (startingAfter === undefined) {
400 break;
401 }
402 }
403 return grants;
404});
405
406const findByAlchemyId = Effect.fn(function* (id: string) {
407 const grants = yield* listAllCreditGrants();
408 const matches: StripeCreditGrant[] = [];
409 for (const grant of grants) {
410 if (isVoided(grant)) continue;
411 if (yield* hasAlchemyMetadata(id, tagRecord(grant.metadata))) {
412 matches.push(grant);
413 }
414 }
415 matches.sort((a, b) => b.created - a.created);
416 return matches[0];
417});
418
419const observe = Effect.fn(function* (input: {
420 id?: string;
421 logicalId: string;
422}) {
423 if (input.id !== undefined) {
424 const byId = yield* getById(input.id);
425 if (byId !== undefined && !isVoided(byId)) return byId;
426 }
427 return yield* findByAlchemyId(input.logicalId);
428});
429
430const desiredMetadata = Effect.fn(function* (
431 id: string,
432 metadata: Record<string, string> | undefined,
433) {
434 return {
435 ...toMetadata(metadata),
436 ...(yield* createInternalMetadata(id)),
437 };
438});
439
440const replaceOnCreateOnlyChange = (
441 olds: CreditGrantProps | undefined,
442 news: CreditGrantProps,
443 output: CreditGrantAttributes | undefined,
444): boolean => {
445 if (output === undefined && olds === undefined) return false;
446 if (
447 !deepEqual(news.amount, output?.amount ?? olds?.amount, {
448 stripNullish: true,
449 })
450 ) {
451 return true;
452 }
453 const desiredApplicability =
454 news.applicabilityConfig ?? DEFAULT_APPLICABILITY;
455 const previousApplicability =
456 output?.applicabilityConfig ?? olds?.applicabilityConfig;
457 if (
458 previousApplicability !== undefined &&
459 !deepEqual(desiredApplicability, previousApplicability, {
460 stripNullish: true,
461 })
462 ) {
463 return true;
464 }
465 const previousCategory = output?.category ?? olds?.category ?? "paid";
466 if ((news.category ?? previousCategory) !== previousCategory) {
467 return true;
468 }
469 const previousCustomer = output?.customer ?? olds?.customer;
470 if (
471 news.customer !== undefined &&
472 previousCustomer !== undefined &&
473 news.customer !== previousCustomer
474 ) {
475 return true;
476 }
477 const previousAccount = output?.customerAccount ?? olds?.customerAccount;
478 if (
479 news.customerAccount !== undefined &&
480 previousAccount !== undefined &&
481 news.customerAccount !== previousAccount
482 ) {
483 return true;
484 }
485 if (
486 news.effectiveAt !== undefined &&
487 news.effectiveAt !== (output?.effectiveAt ?? olds?.effectiveAt)
488 ) {
489 return true;
490 }
491 if (
492 news.priority !== undefined &&
493 news.priority !== (output?.priority ?? olds?.priority)
494 ) {
495 return true;
496 }
497 return false;
498};
499
500export const CreditGrantProvider = () =>
501 Provider.succeed(CreditGrant, {
502 stables: [
503 "id",
504 "customer",
505 "customerAccount",
506 "amount",
507 "applicabilityConfig",
508 "category",
509 "effectiveAt",
510 "priority",
511 "created",
512 "livemode",
513 ],
514
515 diff: Effect.fn(function* ({ olds, news, output }) {
516 if (!isResolved(news)) return undefined;
517 if (replaceOnCreateOnlyChange(olds, news, output)) {
518 return { action: "replace" as const };
519 }
520 return undefined;
521 }),
522
523 read: Effect.fn(function* ({ id, output }) {
524 const existing = yield* observe({
525 id: output?.id,
526 logicalId: id,
527 });
528 if (existing === undefined) return undefined;
529 const attrs = toAttrs(existing);
530 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
531 ? attrs
532 : Unowned(attrs);
533 }),
534
535 list: Effect.fn(function* () {
536 const grants = yield* listAllCreditGrants();
537 return grants
538 .filter((grant) => {
539 if (isVoided(grant)) return false;
540 const metadata = tagRecord(grant.metadata);
541 return metadata[alchemyMetadataKeys.stack] !== undefined;
542 })
543 .map(toAttrs);
544 }),
545
546 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
547 const metadata = yield* desiredMetadata(id, news.metadata);
548 const name = yield* toName(id, news.name, output?.name);
549 const applicabilityConfig =
550 news.applicabilityConfig ?? DEFAULT_APPLICABILITY;
551
552 let current: StripeCreditGrant | undefined = yield* observe({
553 id: output?.id,
554 logicalId: id,
555 });
556 if (
557 current !== undefined &&
558 replaceOnCreateOnlyChange(undefined, news, toAttrs(current))
559 ) {
560 current = undefined;
561 }
562
563 if (current === undefined) {
564 current = yield* CreateBillingCreditGrant({
565 amount: toWireAmount(news.amount),
566 applicability_config: toWireApplicability(applicabilityConfig),
567 name,
568 metadata,
569 ...(news.customer !== undefined ? { customer: news.customer } : {}),
570 ...(news.customerAccount !== undefined
571 ? { customer_account: news.customerAccount }
572 : {}),
573 ...(news.category !== undefined ? { category: news.category } : {}),
574 ...(news.effectiveAt !== undefined
575 ? { effective_at: news.effectiveAt }
576 : {}),
577 ...(news.expiresAt !== undefined
578 ? { expires_at: news.expiresAt }
579 : {}),
580 ...(news.priority !== undefined ? { priority: news.priority } : {}),
581 }).pipe(
582 withRequestOptions({
583 idempotencyKey: `alchemy-credit-grant-${instanceId}`,
584 }),
585 );
586 }
587
588 if (current === undefined) {
589 return yield* new CreditGrantNotResolved({ customer: news.customer });
590 }
591
592 const observedMetadata = tagRecord(current.metadata);
593 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
594 const metadataChanged = upsert.length > 0 || removed.length > 0;
595 const expiresAtChanged =
596 news.expiresAt !== undefined &&
597 (current.expires_at ?? undefined) !== news.expiresAt;
598
599 if (!metadataChanged && !expiresAtChanged) {
600 return toAttrs(current);
601 }
602
603 const updated = yield* UpdateBillingCreditGrant({
604 id: current.id,
605 ...(expiresAtChanged ? { expires_at: news.expiresAt } : {}),
606 ...(metadataChanged
607 ? {
608 metadata: {
609 ...Object.fromEntries(
610 upsert.map((tag) => [tag.Key, tag.Value]),
611 ),
612 ...Object.fromEntries(removed.map((key) => [key, ""])),
613 },
614 }
615 : {}),
616 });
617 return toAttrs(updated);
618 }),
619
620 delete: Effect.fn(function* ({ output }) {
621 const existing = yield* getById(output.id);
622 if (existing === undefined || isVoided(existing)) return;
623 yield* CreateBillingCreditGrantVoid({ id: existing.id }).pipe(
624 Effect.catchIf(isMissingGrant, () => Effect.void),
625 Effect.catchIf(alreadyVoided, () => Effect.void),
626 );
627 }),
628 });