1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetBillingAlerts,
4 GetBillingAlert,
5 CreateBillingAlert,
6 CreateBillingAlertActivate,
7 CreateBillingAlertArchive,
8 CreateBillingAlertDeactivate,
9 type BillingAlert as StripeBillingAlert,
10 type ThresholdsResourceUsageAlertFilterCustomer,
11 type ThresholdsResourceUsageThresholdConfig,
12 type ThresholdsResourceUsageThresholdConfigMeter,
13} from "@distilled.cloud/stripe/stripe";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import { isResolved } from "../Diff.ts";
17import { createPhysicalName } from "../PhysicalName.ts";
18import * as Provider from "../Provider.ts";
19import { Resource } from "../Resource.ts";
20import type { Providers } from "./Providers.ts";
21import { isMissingStripeResource } from "./missing.ts";
22
23const TITLE_MAX_LENGTH = 256;
24const LIST_PAGE_SIZE = 100;
25const LIST_MAX_PAGES = 100;
26
27/** The type of billing alert. Currently only usage-threshold alerts. */
28export type AlertType = "usage_threshold";
29
30/** Status of a billing alert. Archived alerts cannot be reactivated. */
31export type AlertStatus = "active" | "archived" | "inactive";
32
33/** Desired status for a managed alert. Archived is delete-only. */
34export type AlertDesiredStatus = "active" | "inactive";
35
36/** How a usage-threshold alert fires. */
37export type AlertRecurrence = "one_time";
38
39/** Restricts a usage-threshold alert to a single customer. */
40export interface AlertUsageThresholdFilter {
41 /**
42 * Filter kind. Must be `customer`.
43 */
44 type: "customer";
45 /**
46 * Stripe Customer id (`cus_…`) this alert is limited to.
47 */
48 customer?: string;
49}
50
51/** Configuration for a usage-threshold billing alert. Create-only. */
52export interface AlertUsageThreshold {
53 /**
54 * Usage value at which the alert fires. Create-only — changing it
55 * replaces the alert.
56 */
57 gte: number;
58 /**
59 * Id of the Billing Meter whose usage is monitored (`mtr_…`).
60 * Create-only — changing it replaces the alert.
61 */
62 meter: string;
63 /**
64 * How the alert behaves after firing.
65 * @default "one_time"
66 */
67 recurrence?: AlertRecurrence;
68 /**
69 * Optional filters that limit the alert's scope. Stripe currently
70 * accepts at most one `customer` filter. Create-only.
71 */
72 filters?: AlertUsageThresholdFilter[];
73}
74
75export interface AlertProps {
76 /**
77 * Title of the alert. If omitted, a unique title is generated from the
78 * stack, stage, and logical id. Create-only — changing it replaces the
79 * alert. Max 256 characters.
80 */
81 title?: string;
82 /**
83 * Type of alert to create.
84 * @default "usage_threshold"
85 */
86 alertType?: AlertType;
87 /**
88 * Usage-threshold configuration. Create-only — changing `gte`, `meter`,
89 * `recurrence`, or `filters` replaces the alert.
90 */
91 usageThreshold: AlertUsageThreshold;
92 /**
93 * Whether the alert can fire. `inactive` deactivates in place (and can
94 * be reactivated). Destroy archives the alert (irreversible).
95 * @default "active"
96 */
97 status?: AlertDesiredStatus;
98}
99
100export type Alert = Resource<
101 "Stripe.Alert",
102 AlertProps,
103 {
104 /** Stripe billing alert id (`alrt_…`). */
105 id: string;
106 /** Title of the alert. */
107 title: string;
108 /** Type of the alert. */
109 alertType: AlertType;
110 /** Status of the alert. */
111 status: AlertStatus;
112 /** Usage-threshold configuration, if set. */
113 usageThreshold: AlertUsageThreshold | undefined;
114 /** Whether the alert exists in live mode. */
115 livemode: boolean;
116 },
117 never,
118 Providers
119>;
120
121/**
122 * A Stripe Billing Alert — notifies you when a usage threshold on a
123 * [Billing Meter](https://docs.stripe.com/api/billing/meter) is crossed.
124 * `title`, `alertType`, and `usageThreshold` are immutable; changing them
125 * replaces the alert. `status` is changed with Stripe's activate/deactivate
126 * endpoints.
127 *
128 * Billing alerts have no metadata field and cannot be hard-deleted.
129 * Destroying this resource archives the alert (irreversible). Archived
130 * alerts leave the list API and cannot be reactivated.
131 *
132 * @see https://docs.stripe.com/api/billing/alert
133 *
134 * ### Creating an Alert
135 * **Example:** Usage threshold on a meter
136 * ```typescript
137 * const apiCalls = yield* Stripe.BillingMeter("api-calls", {
138 * displayName: "API Calls",
139 * eventName: "api_call",
140 * defaultAggregation: { formula: "sum" },
141 * });
142 * const highUsage = yield* Stripe.Alert("high-usage", {
143 * title: "API Request usage alert",
144 * usageThreshold: {
145 * gte: 10000,
146 * meter: apiCalls.id,
147 * recurrence: "one_time",
148 * },
149 * });
150 * ```
151 *
152 * **Example:** Limit the alert to one customer
153 * ```typescript
154 * const highUsage = yield* Stripe.Alert("high-usage", {
155 * title: "Customer API usage alert",
156 * usageThreshold: {
157 * gte: 1000,
158 * meter: apiCalls.id,
159 * recurrence: "one_time",
160 * filters: [{ type: "customer", customer: customer.id }],
161 * },
162 * });
163 * ```
164 *
165 * ### Updating an Alert
166 * **Example:** Deactivate without archiving
167 * ```typescript
168 * const highUsage = yield* Stripe.Alert("high-usage", {
169 * title: "API Request usage alert",
170 * usageThreshold: {
171 * gte: 10000,
172 * meter: apiCalls.id,
173 * recurrence: "one_time",
174 * },
175 * status: "inactive",
176 * });
177 * ```
178 *
179 * ### Archiving an Alert
180 * **Example:** Destroy archives rather than deleting
181 * ```typescript
182 * // stack.destroy() / resource removal archives the alert
183 * const highUsage = yield* Stripe.Alert("high-usage", {
184 * title: "API Request usage alert",
185 * usageThreshold: {
186 * gte: 10000,
187 * meter: apiCalls.id,
188 * recurrence: "one_time",
189 * },
190 * });
191 * ```
192 *
193 * @resource
194 * @product Billing
195 */
196export const Alert = Resource<Alert>("Stripe.Alert");
197
198export class AlertNotResolved extends Data.TaggedError(
199 "Stripe.AlertNotResolved",
200)<{
201 title: string;
202}> {}
203
204type AlertAttributes = Alert["Attributes"];
205
206const toTitle = (id: string, title: string | undefined, existing?: string) =>
207 Effect.gen(function* () {
208 return (
209 title ??
210 existing ??
211 (yield* createPhysicalName({ id, maxLength: TITLE_MAX_LENGTH }))
212 );
213 });
214
215const toMeterId = (
216 meter: ThresholdsResourceUsageThresholdConfigMeter,
217): string => (typeof meter === "string" ? meter : meter.id);
218
219const toCustomerId = (
220 customer: ThresholdsResourceUsageAlertFilterCustomer | null,
221): string | undefined => {
222 if (customer == null) return undefined;
223 if (typeof customer === "string") return customer;
224 return customer.id;
225};
226
227const toUsageThreshold = (
228 config: ThresholdsResourceUsageThresholdConfig | null,
229): AlertUsageThreshold | undefined => {
230 if (config === null) return undefined;
231 const filters = (config.filters ?? []).map((filter) => {
232 const customer = toCustomerId(filter.customer);
233 return customer !== undefined
234 ? { type: filter.type, customer }
235 : { type: filter.type };
236 });
237 return {
238 gte: config.gte,
239 meter: toMeterId(config.meter),
240 recurrence: config.recurrence,
241 ...(filters.length > 0 ? { filters } : {}),
242 };
243};
244
245const toAttrs = (alert: StripeBillingAlert): AlertAttributes => ({
246 id: alert.id,
247 title: alert.title,
248 alertType: alert.alert_type,
249 status: alert.status ?? "active",
250 usageThreshold: toUsageThreshold(alert.usage_threshold),
251 livemode: alert.livemode,
252});
253
254const isMissingAlert = isMissingStripeResource;
255
256const getById = (id: string) =>
257 GetBillingAlert({ id }).pipe(
258 Effect.catchIf(isMissingAlert, () => Effect.succeed(undefined)),
259 );
260
261const listAlerts = Effect.fn(function* () {
262 const alerts: StripeBillingAlert[] = [];
263 let startingAfter: string | undefined;
264 for (let page = 0; page < LIST_MAX_PAGES; page++) {
265 const response = yield* GetBillingAlerts({
266 limit: LIST_PAGE_SIZE,
267 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
268 });
269 alerts.push(...response.data);
270 if (!response.has_more || response.data.length === 0) {
271 break;
272 }
273 startingAfter = response.data[response.data.length - 1]?.id;
274 if (startingAfter === undefined) {
275 break;
276 }
277 }
278 return alerts;
279});
280
281const findByTitle = Effect.fn(function* (title: string) {
282 const alerts = yield* listAlerts();
283 return alerts.find((alert) => alert.title === title);
284});
285
286const isArchived = (alert: StripeBillingAlert): boolean =>
287 alert.status === "archived";
288
289const observe = Effect.fn(function* (input: { id?: string; title?: string }) {
290 if (input.id !== undefined) {
291 const byId = yield* getById(input.id);
292 if (byId !== undefined && !isArchived(byId)) return byId;
293 }
294 if (input.title !== undefined) {
295 return yield* findByTitle(input.title);
296 }
297 return undefined;
298});
299
300const filtersEqual = (
301 a: AlertUsageThresholdFilter[] | undefined,
302 b: AlertUsageThresholdFilter[] | undefined,
303): boolean => {
304 const left = a ?? [];
305 const right = b ?? [];
306 if (left.length !== right.length) return false;
307 for (let i = 0; i < left.length; i++) {
308 const other = right[i];
309 if (
310 other === undefined ||
311 left[i]?.type !== other.type ||
312 left[i]?.customer !== other.customer
313 ) {
314 return false;
315 }
316 }
317 return true;
318};
319
320const shouldReplace = (
321 news: AlertProps,
322 output: AlertAttributes | undefined,
323): boolean => {
324 if (output === undefined) return false;
325 if (news.title !== undefined && news.title !== output.title) return true;
326 if (news.alertType !== undefined && news.alertType !== output.alertType) {
327 return true;
328 }
329 const current = output.usageThreshold;
330 if (current === undefined) return true;
331 if (news.usageThreshold.gte !== current.gte) return true;
332 if (news.usageThreshold.meter !== current.meter) return true;
333 const desiredRecurrence = news.usageThreshold.recurrence ?? "one_time";
334 if (desiredRecurrence !== current.recurrence) return true;
335 if (
336 news.usageThreshold.filters !== undefined &&
337 !filtersEqual(news.usageThreshold.filters, current.filters)
338 ) {
339 return true;
340 }
341 return false;
342};
343
344const toCreateUsageThreshold = (threshold: AlertUsageThreshold) => ({
345 gte: threshold.gte,
346 meter: threshold.meter,
347 recurrence: threshold.recurrence ?? "one_time",
348 ...(threshold.filters !== undefined
349 ? {
350 filters: threshold.filters.map((filter) => ({
351 type: filter.type,
352 ...(filter.customer !== undefined
353 ? { customer: filter.customer }
354 : {}),
355 })),
356 }
357 : {}),
358});
359
360export const AlertProvider = () =>
361 Provider.succeed(Alert, {
362 stables: ["id", "title", "alertType", "usageThreshold", "livemode"],
363
364 diff: Effect.fn(function* ({ news, output }) {
365 if (!isResolved(news)) return undefined;
366 if (shouldReplace(news, output)) {
367 return { action: "replace" } as const;
368 }
369 return undefined;
370 }),
371
372 read: Effect.fn(function* ({ output }) {
373 const existing = yield* observe({
374 id: output?.id,
375 title: output?.title,
376 });
377 if (existing === undefined) return undefined;
378 // Billing alerts have no metadata. Identity is the Stripe id and
379 // title; a match is treated as owned.
380 return toAttrs(existing);
381 }),
382
383 list: Effect.fn(function* () {
384 // No metadata on this resource. The list API returns active and
385 // inactive alerts; archived rows stay in Stripe but leave the list
386 // and must not re-enter nuke.
387 const alerts = yield* listAlerts();
388 return alerts.map(toAttrs);
389 }),
390
391 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
392 const title = yield* toTitle(id, news.title, output?.title);
393 const alertType = news.alertType ?? "usage_threshold";
394 const desiredStatus = news.status ?? "active";
395
396 let current = yield* observe({
397 id: output?.id,
398 title,
399 });
400 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
401 current = undefined;
402 }
403
404 if (current === undefined) {
405 current = yield* CreateBillingAlert({
406 alert_type: alertType,
407 title,
408 usage_threshold: toCreateUsageThreshold(news.usageThreshold),
409 }).pipe(
410 withRequestOptions({
411 idempotencyKey: `alchemy-billing-alert-${instanceId}`,
412 }),
413 );
414 }
415
416 if (current === undefined) {
417 return yield* new AlertNotResolved({ title });
418 }
419
420 const currentStatus = current.status ?? "active";
421 if (currentStatus !== desiredStatus) {
422 current =
423 desiredStatus === "inactive"
424 ? yield* CreateBillingAlertDeactivate({ id: current.id })
425 : yield* CreateBillingAlertActivate({ id: current.id });
426 }
427
428 return toAttrs(current);
429 }),
430
431 delete: Effect.fn(function* ({ output }) {
432 const existing = yield* getById(output.id);
433 if (existing === undefined || existing.status === "archived") return;
434 yield* CreateBillingAlertArchive({ id: existing.id }).pipe(
435 Effect.catchIf(isMissingAlert, () => Effect.void),
436 );
437 }),
438 });