| 1 | import { withRequestOptions } from "@distilled.cloud/stripe"; |
| 2 | import { |
| 3 | GetBillingMeters, |
| 4 | GetBillingMeter, |
| 5 | CreateBillingMeter, |
| 6 | UpdateBillingMeter, |
| 7 | CreateBillingMeterDeactivate, |
| 8 | CreateBillingMeterReactivate, |
| 9 | type BillingMeter as StripeBillingMeter, |
| 10 | } from "@distilled.cloud/stripe/stripe"; |
| 11 | import * as Data from "effect/Data"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import { isResolved } from "../Diff.ts"; |
| 14 | import { createPhysicalName } from "../PhysicalName.ts"; |
| 15 | import * as Provider from "../Provider.ts"; |
| 16 | import { Resource } from "../Resource.ts"; |
| 17 | import type { Providers } from "./Providers.ts"; |
| 18 | import { isMissingStripeResource } from "./missing.ts"; |
| 19 | |
| 20 | const DISPLAY_NAME_MAX_LENGTH = 250; |
| 21 | const EVENT_NAME_MAX_LENGTH = 100; |
| 22 | const LIST_PAGE_SIZE = 100; |
| 23 | const LIST_MAX_PAGES = 100; |
| 24 | |
| 25 | /** How meter events are aggregated over a billing period. */ |
| 26 | export type BillingMeterAggregationFormula = "count" | "last" | "sum"; |
| 27 | |
| 28 | /** The meter's status. Inactive meters reject events and cannot attach to a price. */ |
| 29 | export type BillingMeterStatus = "active" | "inactive"; |
| 30 | |
| 31 | /** Pre-aggregation window for meter events, if any. */ |
| 32 | export type BillingMeterEventTimeWindow = "day" | "hour"; |
| 33 | |
| 34 | /** How a meter event is mapped to a Stripe customer. */ |
| 35 | export type BillingMeterCustomerMappingType = "by_id"; |
| 36 | |
| 37 | export interface BillingMeterDefaultAggregation { |
| 38 | /** |
| 39 | * Aggregation formula applied to events over the billing period. |
| 40 | */ |
| 41 | formula: BillingMeterAggregationFormula; |
| 42 | } |
| 43 | |
| 44 | export 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 | |
| 56 | export 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 | |
| 64 | export 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 | |
| 102 | export 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 | */ |
| 195 | export const BillingMeter = Resource<BillingMeter>("Stripe.BillingMeter"); |
| 196 | |
| 197 | export class BillingMeterNotResolved extends Data.TaggedError( |
| 198 | "Stripe.BillingMeterNotResolved", |
| 199 | )<{ |
| 200 | eventName: string; |
| 201 | }> {} |
| 202 | |
| 203 | type BillingMeterAttributes = BillingMeter["Attributes"]; |
| 204 | |
| 205 | const 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 | |
| 218 | const 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 | |
| 236 | const 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 | |
| 255 | const isMissingMeter = isMissingStripeResource; |
| 256 | |
| 257 | const getById = (id: string) => |
| 258 | GetBillingMeter({ id }).pipe( |
| 259 | Effect.catchIf(isMissingMeter, () => Effect.succeed(undefined)), |
| 260 | ); |
| 261 | |
| 262 | const 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 | |
| 283 | const 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 | |
| 298 | const 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 | |
| 305 | const 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 | |
| 319 | const 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 | |
| 330 | export 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 | }); |