1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetBillingMeters,
4 GetBillingMeter,
5 CreateBillingMeter,
6 UpdateBillingMeter,
7 CreateBillingMeterDeactivate,
8 CreateBillingMeterReactivate,
9 type BillingMeter as StripeBillingMeter,
10} from "@distilled.cloud/stripe/stripe";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import { isResolved } from "../Diff.ts";
14import { createPhysicalName } from "../PhysicalName.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import type { Providers } from "./Providers.ts";
18import { isMissingStripeResource } from "./missing.ts";
19
20const DISPLAY_NAME_MAX_LENGTH = 250;
21const EVENT_NAME_MAX_LENGTH = 100;
22const LIST_PAGE_SIZE = 100;
23const LIST_MAX_PAGES = 100;
24
25/** How meter events are aggregated over a billing period. */
26export type BillingMeterAggregationFormula = "count" | "last" | "sum";
27
28/** The meter's status. Inactive meters reject events and cannot attach to a price. */
29export type BillingMeterStatus = "active" | "inactive";
30
31/** Pre-aggregation window for meter events, if any. */
32export type BillingMeterEventTimeWindow = "day" | "hour";
33
34/** How a meter event is mapped to a Stripe customer. */
35export type BillingMeterCustomerMappingType = "by_id";
36
37export interface BillingMeterDefaultAggregation {
38 /**
39 * Aggregation formula applied to events over the billing period.
40 */
41 formula: BillingMeterAggregationFormula;
42}
43
44export interface BillingMeterCustomerMapping {
45 /**
46 * Key in the meter event payload used to map the event to a customer.
47 */
48 eventPayloadKey: string;
49 /**
50 * Mapping method. Must be `by_id`.
51 * @default "by_id"
52 */
53 type?: BillingMeterCustomerMappingType;
54}
55
56export interface BillingMeterValueSettings {
57 /**
58 * Key in the usage event payload used as this meter's value (e.g.
59 * `"bytes_used"`).
60 */
61 eventPayloadKey: string;
62}
63
64export interface BillingMeterProps {
65 /**
66 * The meter's name. Not visible to the customer. If omitted, a unique
67 * name is generated from the stack, stage, and logical id. Mutable.
68 */
69 displayName?: string;
70 /**
71 * Name of the meter event used to record usage. Corresponds with the
72 * `event_name` field on meter events. If omitted, a unique name is
73 * generated. Create-only — changing it replaces the meter. Stripe
74 * event names are unique per account, including deactivated meters.
75 */
76 eventName?: string;
77 /**
78 * How events are aggregated over a billing period. Create-only.
79 */
80 defaultAggregation: BillingMeterDefaultAggregation;
81 /**
82 * How a meter event is mapped to a customer. Create-only. Defaults to
83 * `{ type: "by_id", eventPayloadKey: "stripe_customer_id" }`.
84 */
85 customerMapping?: BillingMeterCustomerMapping;
86 /**
87 * Pre-aggregation window for meter events. Create-only.
88 */
89 eventTimeWindow?: BillingMeterEventTimeWindow;
90 /**
91 * How to calculate a meter event's value. Create-only. Defaults to
92 * `{ eventPayloadKey: "value" }`.
93 */
94 valueSettings?: BillingMeterValueSettings;
95 /**
96 * Whether the meter accepts events and can attach to a price.
97 * @default "active"
98 */
99 status?: BillingMeterStatus;
100}
101
102export type BillingMeter = Resource<
103 "Stripe.BillingMeter",
104 BillingMeterProps,
105 {
106 /** Stripe billing meter id (`mtr_…`). */
107 id: string;
108 /** The meter's name. Not visible to the customer. */
109 displayName: string;
110 /** Name of the meter event used to record usage. */
111 eventName: string;
112 /** How events are aggregated over a billing period. */
113 defaultAggregation: BillingMeterDefaultAggregation;
114 /** How a meter event is mapped to a customer. */
115 customerMapping: BillingMeterCustomerMapping;
116 /** Pre-aggregation window for meter events, if any. */
117 eventTimeWindow: BillingMeterEventTimeWindow | undefined;
118 /** How to calculate a meter event's value. */
119 valueSettings: BillingMeterValueSettings;
120 /** Whether the meter accepts events and can attach to a price. */
121 status: BillingMeterStatus;
122 /** Unix timestamp when the meter was created. */
123 created: number;
124 /** Unix timestamp when the meter was last updated. */
125 updated: number;
126 /** Whether the meter exists in live mode. */
127 livemode: boolean;
128 },
129 never,
130 Providers
131>;
132
133/**
134 * A Stripe Billing Meter — how usage events are aggregated over a billing
135 * period for usage-based prices. `eventName`, `defaultAggregation`,
136 * `customerMapping`, `eventTimeWindow`, and `valueSettings` are immutable.
137 * Changing `eventName` replaces the meter. Display name updates in place;
138 * `status` is changed with Stripe's activate/deactivate endpoints.
139 *
140 * Billing meters have no metadata field and cannot be hard-deleted.
141 * Destroying this resource deactivates the meter. Deactivated meters
142 * still occupy their `eventName`, so a later deploy with the same event
143 * name reactivates the existing meter.
144 *
145 * @see https://docs.stripe.com/api/billing/meter
146 *
147 * ### Creating a Billing Meter
148 * **Example:** Sum aggregation
149 * ```typescript
150 * const apiCalls = yield* Stripe.BillingMeter("api-calls", {
151 * displayName: "API Calls",
152 * eventName: "api_call",
153 * defaultAggregation: { formula: "sum" },
154 * });
155 * ```
156 *
157 * **Example:** Count with custom value key
158 * ```typescript
159 * const tokens = yield* Stripe.BillingMeter("tokens", {
160 * displayName: "Tokens",
161 * eventName: "token_used",
162 * defaultAggregation: { formula: "count" },
163 * valueSettings: { eventPayloadKey: "tokens" },
164 * customerMapping: {
165 * type: "by_id",
166 * eventPayloadKey: "stripe_customer_id",
167 * },
168 * });
169 * ```
170 *
171 * ### Updating a Billing Meter
172 * **Example:** Rename the meter
173 * ```typescript
174 * const apiCalls = yield* Stripe.BillingMeter("api-calls", {
175 * displayName: "API Calls (updated)",
176 * eventName: "api_call",
177 * defaultAggregation: { formula: "sum" },
178 * });
179 * ```
180 *
181 * ### Deactivating a Billing Meter
182 * **Example:** Destroy deactivates rather than deleting
183 * ```typescript
184 * // stack.destroy() / resource removal calls deactivate
185 * const apiCalls = yield* Stripe.BillingMeter("api-calls", {
186 * displayName: "API Calls",
187 * eventName: "api_call",
188 * defaultAggregation: { formula: "sum" },
189 * });
190 * ```
191 *
192 * @resource
193 * @product Billing
194 */
195export const BillingMeter = Resource<BillingMeter>("Stripe.BillingMeter");
196
197export class BillingMeterNotResolved extends Data.TaggedError(
198 "Stripe.BillingMeterNotResolved",
199)<{
200 eventName: string;
201}> {}
202
203type BillingMeterAttributes = BillingMeter["Attributes"];
204
205const toDisplayName = (
206 id: string,
207 displayName: string | undefined,
208 existing?: string,
209) =>
210 Effect.gen(function* () {
211 return (
212 displayName ??
213 existing ??
214 (yield* createPhysicalName({ id, maxLength: DISPLAY_NAME_MAX_LENGTH }))
215 );
216 });
217
218const toEventName = (
219 id: string,
220 eventName: string | undefined,
221 existing?: string,
222) =>
223 Effect.gen(function* () {
224 return (
225 eventName ??
226 existing ??
227 (yield* createPhysicalName({
228 id,
229 maxLength: EVENT_NAME_MAX_LENGTH,
230 lowercase: true,
231 delimiter: "_",
232 }))
233 );
234 });
235
236const toAttrs = (meter: StripeBillingMeter): BillingMeterAttributes => ({
237 id: meter.id,
238 displayName: meter.display_name,
239 eventName: meter.event_name,
240 defaultAggregation: { formula: meter.default_aggregation.formula },
241 customerMapping: {
242 eventPayloadKey: meter.customer_mapping.event_payload_key,
243 type: meter.customer_mapping.type,
244 },
245 eventTimeWindow: meter.event_time_window ?? undefined,
246 valueSettings: {
247 eventPayloadKey: meter.value_settings.event_payload_key,
248 },
249 status: meter.status,
250 created: meter.created,
251 updated: meter.updated,
252 livemode: meter.livemode,
253});
254
255const isMissingMeter = isMissingStripeResource;
256
257const getById = (id: string) =>
258 GetBillingMeter({ id }).pipe(
259 Effect.catchIf(isMissingMeter, () => Effect.succeed(undefined)),
260 );
261
262const listByStatus = Effect.fn(function* (status: BillingMeterStatus) {
263 const meters: StripeBillingMeter[] = [];
264 let startingAfter: string | undefined;
265 for (let page = 0; page < LIST_MAX_PAGES; page++) {
266 const response = yield* GetBillingMeters({
267 status,
268 limit: LIST_PAGE_SIZE,
269 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
270 });
271 meters.push(...response.data);
272 if (!response.has_more || response.data.length === 0) {
273 break;
274 }
275 startingAfter = response.data[response.data.length - 1]?.id;
276 if (startingAfter === undefined) {
277 break;
278 }
279 }
280 return meters;
281});
282
283const listAllMeters = Effect.fn(function* () {
284 const [active, inactive] = yield* Effect.all(
285 [listByStatus("active"), listByStatus("inactive")],
286 { concurrency: 2 },
287 );
288 const seen = new Set<string>();
289 const meters: StripeBillingMeter[] = [];
290 for (const meter of [...active, ...inactive]) {
291 if (seen.has(meter.id)) continue;
292 seen.add(meter.id);
293 meters.push(meter);
294 }
295 return meters;
296});
297
298const findByEventName = Effect.fn(function* (eventName: string) {
299 const meters = yield* listAllMeters();
300 const matches = meters.filter((meter) => meter.event_name === eventName);
301 matches.sort((a, b) => b.created - a.created);
302 return matches[0];
303});
304
305const observe = Effect.fn(function* (input: {
306 id?: string;
307 eventName?: string;
308}) {
309 if (input.id !== undefined) {
310 const byId = yield* getById(input.id);
311 if (byId !== undefined) return byId;
312 }
313 if (input.eventName !== undefined) {
314 return yield* findByEventName(input.eventName);
315 }
316 return undefined;
317});
318
319const shouldReplace = (
320 news: BillingMeterProps,
321 output: BillingMeterAttributes | undefined,
322): boolean => {
323 if (output === undefined) return false;
324 if (news.eventName !== undefined && news.eventName !== output.eventName) {
325 return true;
326 }
327 return false;
328};
329
330export const BillingMeterProvider = () =>
331 Provider.succeed(BillingMeter, {
332 stables: [
333 "id",
334 "eventName",
335 "defaultAggregation",
336 "customerMapping",
337 "eventTimeWindow",
338 "valueSettings",
339 "created",
340 "livemode",
341 ],
342
343 diff: Effect.fn(function* ({ news, output }) {
344 if (!isResolved(news)) return undefined;
345 if (shouldReplace(news, output)) {
346 return { action: "replace" } as const;
347 }
348 return undefined;
349 }),
350
351 read: Effect.fn(function* ({ output }) {
352 const existing = yield* observe({
353 id: output?.id,
354 eventName: output?.eventName,
355 });
356 if (existing === undefined) return undefined;
357 // Billing meters have no metadata. Identity is the Stripe id and the
358 // unique event_name; a match is treated as owned.
359 return toAttrs(existing);
360 }),
361
362 list: Effect.fn(function* () {
363 // No metadata on this resource. Default list is active meters;
364 // deactivated rows stay in Stripe (event_name remains reserved) but
365 // must not re-enter nuke.
366 const meters = yield* listByStatus("active");
367 return meters.map(toAttrs);
368 }),
369
370 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
371 const displayName = yield* toDisplayName(
372 id,
373 news.displayName,
374 output?.displayName,
375 );
376 const eventName = yield* toEventName(
377 id,
378 news.eventName,
379 output?.eventName,
380 );
381 const desiredStatus = news.status ?? "active";
382
383 let current = yield* observe({
384 id: output?.id,
385 eventName,
386 });
387 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
388 current = undefined;
389 }
390
391 if (current === undefined) {
392 current = yield* CreateBillingMeter({
393 display_name: displayName,
394 event_name: eventName,
395 default_aggregation: { formula: news.defaultAggregation.formula },
396 ...(news.customerMapping !== undefined
397 ? {
398 customer_mapping: {
399 event_payload_key: news.customerMapping.eventPayloadKey,
400 type: news.customerMapping.type ?? "by_id",
401 },
402 }
403 : {}),
404 ...(news.eventTimeWindow !== undefined
405 ? { event_time_window: news.eventTimeWindow }
406 : {}),
407 ...(news.valueSettings !== undefined
408 ? {
409 value_settings: {
410 event_payload_key: news.valueSettings.eventPayloadKey,
411 },
412 }
413 : {}),
414 }).pipe(
415 withRequestOptions({
416 idempotencyKey: `alchemy-billing-meter-${instanceId}`,
417 }),
418 );
419 }
420
421 if (current === undefined) {
422 return yield* new BillingMeterNotResolved({ eventName });
423 }
424
425 const displayNameChanged = current.display_name !== displayName;
426 if (displayNameChanged) {
427 current = yield* UpdateBillingMeter({
428 id: current.id,
429 display_name: displayName,
430 });
431 }
432
433 if (current.status !== desiredStatus) {
434 current =
435 desiredStatus === "inactive"
436 ? yield* CreateBillingMeterDeactivate({ id: current.id })
437 : yield* CreateBillingMeterReactivate({ id: current.id });
438 }
439
440 return toAttrs(current);
441 }),
442
443 delete: Effect.fn(function* ({ output }) {
444 const existing = yield* getById(output.id);
445 if (existing === undefined || existing.status === "inactive") return;
446 yield* CreateBillingMeterDeactivate({ id: existing.id }).pipe(
447 Effect.catchIf(isMissingMeter, () => Effect.void),
448 );
449 }),
450 });