@blamejs/blamejs-shop 0.0.64 → 0.0.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,782 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.meteredUsage
4
+ * @title Metered usage — usage-based pricing companion to
5
+ * `subscriptions` + `subscriptionBilling`.
6
+ *
7
+ * @intro
8
+ * Subscriptions where some portion of the bill scales with
9
+ * measured consumption (per-API-call, per-GB-transferred, per-
10
+ * seat-hour) need three things the flat-rate billing primitive
11
+ * doesn't model:
12
+ *
13
+ * 1. A catalog row defining the meter's unit, pricing schedule,
14
+ * and per-period free allowance.
15
+ * 2. An append-only event ledger that absorbs raw usage signals
16
+ * idempotently (instrumentation libraries retry on network
17
+ * errors; webhook deliveries replay).
18
+ * 3. A period-close roll-up that turns raw events into a
19
+ * billable line item the recurring-billing primitive can
20
+ * invoice against.
21
+ *
22
+ * This primitive owns all three. Storage lives in the two tables
23
+ * shipped by migration 0095 (`meters` + `meter_events`). The
24
+ * roll-up math runs in-process — no aggregate table — so a meter
25
+ * schedule change between periods retroactively applies only
26
+ * when the caller re-queries; written history isn't mutated.
27
+ *
28
+ * Composition:
29
+ * var meter = bShop.meteredUsage.create({
30
+ * query: q,
31
+ * subscriptions: subs.subscriptions, // optional handle —
32
+ * // validates subscription_id
33
+ * // at recordUsage when wired.
34
+ * subscriptionBilling: bill, // optional — wired to
35
+ * // recordPeriodInvoice for
36
+ * // period-close.
37
+ * });
38
+ * await meter.defineMeter({
39
+ * slug: "api_calls", title: "API calls", unit: "call",
40
+ * tier_price_minor: 1, included_units_per_period: 1000,
41
+ * currency: "USD",
42
+ * });
43
+ * await meter.recordUsage({
44
+ * subscription_id, meter_slug: "api_calls", quantity: 50,
45
+ * idempotency_key: "req_abc",
46
+ * });
47
+ * var summary = await meter.usageForPeriod({
48
+ * subscription_id, meter_slug: "api_calls",
49
+ * period_start, period_end,
50
+ * });
51
+ *
52
+ * Pricing shapes:
53
+ *
54
+ * * Flat-rate: `tier_price_minor` set, `tier_schedule` omitted.
55
+ * Every billable unit costs `tier_price_minor` minor units.
56
+ *
57
+ * * Tiered: `tier_schedule` is a JSON array of
58
+ * `[{ up_to, price_minor }, …]` rows ordered ascending.
59
+ * `up_to: null` is the open-ended tail tier. Quantities walk
60
+ * the tiers in order — first N units at tier 0's rate, next M
61
+ * at tier 1's, etc. The two shapes are mutually exclusive at
62
+ * define time; the schema enforces the XOR.
63
+ *
64
+ * `included_units_per_period` is subtracted from total_units
65
+ * before the tier walk. A meter with 1000 included units and a
66
+ * 200-unit period total bills zero billable units; a 1500-unit
67
+ * period total bills 500 billable units at the configured tier
68
+ * rate(s).
69
+ *
70
+ * `idempotency_key` on `recordUsage` is the operator's dedup
71
+ * contract — instrumentation libraries retry under network
72
+ * error; webhook deliveries replay. When a key is reused the
73
+ * surface returns the existing row instead of inserting a
74
+ * duplicate. NULL keys are accepted (the SQLite partial-unique
75
+ * index leaves them un-collapsed) for callers that don't track
76
+ * keys.
77
+ *
78
+ * `recordPeriodInvoice` requires the `subscriptionBilling` handle
79
+ * injected at factory time. It rolls up every meter for the
80
+ * subscription across [period_start, period_end] and enqueues
81
+ * one invoice line per currency the meters charge in (a single
82
+ * subscription with USD-priced api_calls + EUR-priced storage
83
+ * produces two invoice rows). The surface returns the list of
84
+ * `subscriptionBilling.recordInvoice` results so the caller can
85
+ * thread them through to a payment-attempt run on the same
86
+ * tick.
87
+ *
88
+ * @primitive meteredUsage
89
+ * @related shop.subscriptions, shop.subscriptionBilling
90
+ */
91
+
92
+ var bShop;
93
+ function _b() {
94
+ if (!bShop) bShop = require("./index");
95
+ return bShop.framework;
96
+ }
97
+
98
+ // ---- constants ----------------------------------------------------------
99
+
100
+ var MAX_SLUG_LEN = 80;
101
+ var MAX_TITLE_LEN = 200;
102
+ var MAX_UNIT_LEN = 64;
103
+ var MAX_IDEM_KEY_LEN = 200;
104
+ var MAX_TIER_COUNT = 50;
105
+ var MAX_QUANTITY = 9007199254740991; // Number.MAX_SAFE_INTEGER
106
+ var MAX_INCLUDED = 9007199254740991;
107
+ var MAX_PRICE_MINOR = 100000000; // 1e8 — same ceiling as loyalty
108
+ var MAX_LIST_LIMIT = 500;
109
+ var DEFAULT_LIST_LIMIT = 100;
110
+
111
+ // Slug shape matches the loyalty-redemption / catalog convention.
112
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
113
+
114
+ // Control-byte refusal posture inherited from the sibling billing
115
+ // primitives — operator-authored copy (title, unit) ends up on
116
+ // receipt emails + the operator dashboard, so the same "no direction-
117
+ // override / no invisible glyph" floor applies.
118
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
119
+ var ZERO_WIDTH_RE = new RegExp(
120
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
121
+ );
122
+
123
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
124
+ "title",
125
+ "unit",
126
+ "tier_price_minor",
127
+ "tier_schedule",
128
+ "included_units_per_period",
129
+ "currency",
130
+ ]);
131
+
132
+ // ---- validators ---------------------------------------------------------
133
+
134
+ function _uuid(s, label) {
135
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
136
+ catch (e) { throw new TypeError("meteredUsage: " + label + " — " + (e && e.message || "invalid UUID")); }
137
+ }
138
+
139
+ function _slug(s) {
140
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
141
+ throw new TypeError("meteredUsage: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
142
+ }
143
+ return s;
144
+ }
145
+
146
+ function _shortText(s, label, max) {
147
+ if (typeof s !== "string" || !s.length) {
148
+ throw new TypeError("meteredUsage: " + label + " must be a non-empty string");
149
+ }
150
+ if (s.length > max) {
151
+ throw new TypeError("meteredUsage: " + label + " must be <= " + max + " characters");
152
+ }
153
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
154
+ throw new TypeError("meteredUsage: " + label + " contains control / zero-width bytes");
155
+ }
156
+ return s;
157
+ }
158
+
159
+ function _currency(c) {
160
+ if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
161
+ throw new TypeError("meteredUsage: currency must be 3-letter uppercase ISO 4217");
162
+ }
163
+ return c;
164
+ }
165
+
166
+ function _quantity(n) {
167
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > MAX_QUANTITY) {
168
+ throw new TypeError("meteredUsage: quantity must be a non-negative integer (<= Number.MAX_SAFE_INTEGER)");
169
+ }
170
+ return n;
171
+ }
172
+
173
+ function _includedUnits(n) {
174
+ if (n == null) return 0;
175
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > MAX_INCLUDED) {
176
+ throw new TypeError("meteredUsage: included_units_per_period must be a non-negative integer");
177
+ }
178
+ return n;
179
+ }
180
+
181
+ function _epochMs(n, label) {
182
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
183
+ throw new TypeError("meteredUsage: " + label + " must be a positive integer (epoch ms)");
184
+ }
185
+ return n;
186
+ }
187
+
188
+ function _optEpochMs(n, label) {
189
+ if (n == null) return null;
190
+ return _epochMs(n, label);
191
+ }
192
+
193
+ function _tierPriceMinor(n) {
194
+ if (n == null) return null;
195
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > MAX_PRICE_MINOR) {
196
+ throw new TypeError("meteredUsage: tier_price_minor must be a non-negative integer <= " + MAX_PRICE_MINOR);
197
+ }
198
+ return n;
199
+ }
200
+
201
+ // Validate the tier schedule shape — array of
202
+ // `{ up_to: integer | null, price_minor: integer }` rows, ascending
203
+ // by `up_to`, with exactly one open tail (`up_to: null`) at the end.
204
+ // Refuses an empty schedule, an out-of-order tier list, a duplicate
205
+ // `up_to` bound, or a schedule whose tail isn't open. A schedule with
206
+ // only one open tier is allowed (it degenerates to flat-rate pricing
207
+ // expressed as a single tier — same math, separate storage shape).
208
+ function _tierSchedule(arr) {
209
+ if (arr == null) return null;
210
+ if (!Array.isArray(arr)) {
211
+ throw new TypeError("meteredUsage: tier_schedule must be an array of { up_to, price_minor } rows");
212
+ }
213
+ if (arr.length === 0 || arr.length > MAX_TIER_COUNT) {
214
+ throw new TypeError("meteredUsage: tier_schedule must be 1.." + MAX_TIER_COUNT + " rows");
215
+ }
216
+ var prevUpTo = 0;
217
+ for (var i = 0; i < arr.length; i += 1) {
218
+ var row = arr[i];
219
+ if (row == null || typeof row !== "object" || Array.isArray(row)) {
220
+ throw new TypeError("meteredUsage: tier_schedule[" + i + "] must be a plain object");
221
+ }
222
+ var isTail = i === arr.length - 1;
223
+ if (isTail) {
224
+ if (row.up_to !== null && row.up_to !== undefined) {
225
+ throw new TypeError("meteredUsage: tier_schedule tail tier must use `up_to: null` (open-ended)");
226
+ }
227
+ } else {
228
+ if (typeof row.up_to !== "number" || !Number.isInteger(row.up_to) || row.up_to <= prevUpTo) {
229
+ throw new TypeError(
230
+ "meteredUsage: tier_schedule[" + i + "].up_to must be an integer > previous tier's up_to (" + prevUpTo + ")"
231
+ );
232
+ }
233
+ prevUpTo = row.up_to;
234
+ }
235
+ if (typeof row.price_minor !== "number" || !Number.isInteger(row.price_minor) || row.price_minor < 0 || row.price_minor > MAX_PRICE_MINOR) {
236
+ throw new TypeError("meteredUsage: tier_schedule[" + i + "].price_minor must be a non-negative integer <= " + MAX_PRICE_MINOR);
237
+ }
238
+ }
239
+ // Normalize the tail row's `up_to` to explicit null so the stored
240
+ // JSON is canonical regardless of whether the caller used null or
241
+ // omitted the key entirely.
242
+ var normalized = [];
243
+ for (var j = 0; j < arr.length; j += 1) {
244
+ normalized.push({
245
+ up_to: j === arr.length - 1 ? null : arr[j].up_to,
246
+ price_minor: arr[j].price_minor,
247
+ });
248
+ }
249
+ return normalized;
250
+ }
251
+
252
+ function _idempotencyKey(s) {
253
+ if (s == null) return null;
254
+ if (typeof s !== "string" || !s.length) {
255
+ throw new TypeError("meteredUsage: idempotency_key must be a non-empty string when provided");
256
+ }
257
+ if (s.length > MAX_IDEM_KEY_LEN) {
258
+ throw new TypeError("meteredUsage: idempotency_key must be <= " + MAX_IDEM_KEY_LEN + " characters");
259
+ }
260
+ if (CONTROL_BYTE_RE.test(s)) {
261
+ throw new TypeError("meteredUsage: idempotency_key contains control bytes");
262
+ }
263
+ return s;
264
+ }
265
+
266
+ function _limit(n) {
267
+ if (n == null) return DEFAULT_LIST_LIMIT;
268
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
269
+ throw new TypeError("meteredUsage: limit must be a positive integer");
270
+ }
271
+ if (n > MAX_LIST_LIMIT) {
272
+ throw new TypeError("meteredUsage: limit must be <= " + MAX_LIST_LIMIT);
273
+ }
274
+ return n;
275
+ }
276
+
277
+ function _now() { return Date.now(); }
278
+
279
+ // ---- row hydration ------------------------------------------------------
280
+
281
+ function _safeParseTierSchedule(s) {
282
+ if (s == null) return null;
283
+ try {
284
+ var parsed = JSON.parse(s);
285
+ return Array.isArray(parsed) ? parsed : null;
286
+ } catch (_e) {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ function _hydrateMeter(r) {
292
+ if (!r) return null;
293
+ return {
294
+ slug: r.slug,
295
+ title: r.title,
296
+ unit: r.unit,
297
+ tier_price_minor: r.tier_price_minor == null ? null : Number(r.tier_price_minor),
298
+ tier_schedule: _safeParseTierSchedule(r.tier_schedule_json),
299
+ included_units_per_period: Number(r.included_units_per_period),
300
+ currency: r.currency,
301
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
302
+ created_at: Number(r.created_at),
303
+ updated_at: Number(r.updated_at),
304
+ };
305
+ }
306
+
307
+ function _hydrateEvent(r) {
308
+ if (!r) return null;
309
+ return {
310
+ id: r.id,
311
+ subscription_id: r.subscription_id,
312
+ meter_slug: r.meter_slug,
313
+ quantity: Number(r.quantity),
314
+ idempotency_key: r.idempotency_key == null ? null : r.idempotency_key,
315
+ occurred_at: Number(r.occurred_at),
316
+ period_start: r.period_start == null ? null : Number(r.period_start),
317
+ period_end: r.period_end == null ? null : Number(r.period_end),
318
+ created_at: Number(r.created_at),
319
+ };
320
+ }
321
+
322
+ // ---- billing math -------------------------------------------------------
323
+
324
+ // Walk the meter's pricing schedule against `billableUnits` and
325
+ // return the charge in minor units. `billableUnits` is the already-
326
+ // included-floor-applied unit count (i.e. total_units - included,
327
+ // clamped at zero). Flat-rate meters short-circuit to
328
+ // `billableUnits * tier_price_minor`. Tiered meters walk the
329
+ // schedule from tier 0 upward, consuming up to `up_to` units per
330
+ // tier at that tier's rate, until `billableUnits` is exhausted (the
331
+ // tail tier — `up_to: null` — absorbs whatever remains).
332
+ function _chargeFor(meter, billableUnits) {
333
+ if (billableUnits <= 0) return 0;
334
+ if (meter.tier_price_minor != null) {
335
+ return billableUnits * meter.tier_price_minor;
336
+ }
337
+ var schedule = meter.tier_schedule;
338
+ var remaining = billableUnits;
339
+ var consumedSoFar = 0;
340
+ var charge = 0;
341
+ for (var i = 0; i < schedule.length; i += 1) {
342
+ var tier = schedule[i];
343
+ if (remaining <= 0) break;
344
+ var tierCapacity;
345
+ if (tier.up_to == null) {
346
+ tierCapacity = remaining; // open tail
347
+ } else {
348
+ tierCapacity = Math.max(0, tier.up_to - consumedSoFar);
349
+ }
350
+ var consumeHere = Math.min(remaining, tierCapacity);
351
+ charge += consumeHere * tier.price_minor;
352
+ remaining -= consumeHere;
353
+ consumedSoFar += consumeHere;
354
+ }
355
+ return charge;
356
+ }
357
+
358
+ // ---- factory ------------------------------------------------------------
359
+
360
+ function create(opts) {
361
+ opts = opts || {};
362
+ var query = opts.query;
363
+ if (!query) {
364
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
365
+ }
366
+
367
+ // Subscriptions handle is optional — when wired, recordUsage
368
+ // validates the subscription_id exists before inserting. Without
369
+ // it, the FK constraint on `meter_slug` still catches a bogus
370
+ // meter; subscription_id stays free-form so the primitive can
371
+ // operate in a tenant without the recurring-billing tables wired
372
+ // (e.g. usage tracking for one-shot purchases).
373
+ var subscriptionsHandle = opts.subscriptions || null;
374
+ if (subscriptionsHandle != null && typeof subscriptionsHandle.get !== "function") {
375
+ throw new TypeError("meteredUsage.create: opts.subscriptions handle must expose get(id)");
376
+ }
377
+
378
+ var billingHandle = opts.subscriptionBilling || null;
379
+ if (billingHandle != null && typeof billingHandle.recordInvoice !== "function") {
380
+ throw new TypeError("meteredUsage.create: opts.subscriptionBilling handle must expose recordInvoice(...)");
381
+ }
382
+
383
+ async function _getMeter(slug) {
384
+ var r = await query("SELECT * FROM meters WHERE slug = ?1", [slug]);
385
+ return _hydrateMeter(r.rows[0]);
386
+ }
387
+
388
+ async function _subscriptionExists(subscriptionId) {
389
+ if (subscriptionsHandle == null) return true;
390
+ var row = await subscriptionsHandle.get(subscriptionId);
391
+ return row != null;
392
+ }
393
+
394
+ // ---- defineMeter --------------------------------------------------
395
+
396
+ async function defineMeter(input) {
397
+ if (!input || typeof input !== "object") {
398
+ throw new TypeError("meteredUsage.defineMeter: input object required");
399
+ }
400
+ var slug = _slug(input.slug);
401
+ var title = _shortText(input.title, "title", MAX_TITLE_LEN);
402
+ var unit = _shortText(input.unit, "unit", MAX_UNIT_LEN);
403
+ var currency = _currency(input.currency);
404
+ var included = _includedUnits(input.included_units_per_period);
405
+
406
+ var hasFlat = input.tier_price_minor != null;
407
+ var hasTiers = input.tier_schedule != null;
408
+ if (hasFlat === hasTiers) {
409
+ throw new TypeError("meteredUsage.defineMeter: exactly one of tier_price_minor / tier_schedule must be provided");
410
+ }
411
+ var tierPrice = hasFlat ? _tierPriceMinor(input.tier_price_minor) : null;
412
+ var tierSchedule = hasTiers ? _tierSchedule(input.tier_schedule) : null;
413
+
414
+ // Refuse redefine — operators `updateMeter` to mutate.
415
+ var existing = await _getMeter(slug);
416
+ if (existing) {
417
+ var err = new Error("meteredUsage.defineMeter: slug " + slug + " already defined");
418
+ err.code = "METER_ALREADY_DEFINED";
419
+ throw err;
420
+ }
421
+
422
+ var ts = _now();
423
+ await query(
424
+ "INSERT INTO meters " +
425
+ "(slug, title, unit, tier_price_minor, tier_schedule_json, " +
426
+ " included_units_per_period, currency, archived_at, created_at, updated_at) " +
427
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
428
+ [
429
+ slug,
430
+ title,
431
+ unit,
432
+ tierPrice,
433
+ tierSchedule == null ? null : JSON.stringify(tierSchedule),
434
+ included,
435
+ currency,
436
+ ts,
437
+ ],
438
+ );
439
+ return await _getMeter(slug);
440
+ }
441
+
442
+ // ---- recordUsage --------------------------------------------------
443
+
444
+ async function recordUsage(input) {
445
+ if (!input || typeof input !== "object") {
446
+ throw new TypeError("meteredUsage.recordUsage: input object required");
447
+ }
448
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
449
+ var meterSlug = _slug(input.meter_slug);
450
+ var quantity = _quantity(input.quantity);
451
+ var occurredAt = input.occurred_at == null ? _now() : _epochMs(input.occurred_at, "occurred_at");
452
+ var idemKey = _idempotencyKey(input.idempotency_key);
453
+ var periodStart = _optEpochMs(input.period_start, "period_start");
454
+ var periodEnd = _optEpochMs(input.period_end, "period_end");
455
+ if (periodStart != null && periodEnd != null && periodEnd < periodStart) {
456
+ throw new TypeError("meteredUsage.recordUsage: period_end must be >= period_start");
457
+ }
458
+
459
+ var meter = await _getMeter(meterSlug);
460
+ if (!meter) {
461
+ var notFound = new Error("meteredUsage.recordUsage: meter " + meterSlug + " not found");
462
+ notFound.code = "METER_NOT_FOUND";
463
+ throw notFound;
464
+ }
465
+ if (meter.archived_at != null) {
466
+ var archived = new Error("meteredUsage.recordUsage: meter " + meterSlug + " is archived");
467
+ archived.code = "METER_ARCHIVED";
468
+ throw archived;
469
+ }
470
+
471
+ if (!(await _subscriptionExists(subscriptionId))) {
472
+ var subMissing = new Error("meteredUsage.recordUsage: subscription " + subscriptionId + " not found");
473
+ subMissing.code = "SUBSCRIPTION_NOT_FOUND";
474
+ throw subMissing;
475
+ }
476
+
477
+ // Idempotency replay — the same key re-submitted collapses to
478
+ // the existing row. The UNIQUE-WHERE-NOT-NULL index would refuse
479
+ // a second INSERT anyway; this surface returns the existing row
480
+ // so the caller's retry loop is a no-op.
481
+ if (idemKey != null) {
482
+ var existing = await query(
483
+ "SELECT * FROM meter_events WHERE idempotency_key = ?1",
484
+ [idemKey],
485
+ );
486
+ if (existing.rows.length) return _hydrateEvent(existing.rows[0]);
487
+ }
488
+
489
+ var id = _b().uuid.v7();
490
+ var ts = _now();
491
+ await query(
492
+ "INSERT INTO meter_events " +
493
+ "(id, subscription_id, meter_slug, quantity, idempotency_key, " +
494
+ " occurred_at, period_start, period_end, created_at) " +
495
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
496
+ [id, subscriptionId, meterSlug, quantity, idemKey, occurredAt, periodStart, periodEnd, ts],
497
+ );
498
+ var r = await query("SELECT * FROM meter_events WHERE id = ?1", [id]);
499
+ return _hydrateEvent(r.rows[0]);
500
+ }
501
+
502
+ // ---- usageForPeriod -----------------------------------------------
503
+
504
+ async function usageForPeriod(input) {
505
+ if (!input || typeof input !== "object") {
506
+ throw new TypeError("meteredUsage.usageForPeriod: input object required");
507
+ }
508
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
509
+ var meterSlug = _slug(input.meter_slug);
510
+ var periodStart = _epochMs(input.period_start, "period_start");
511
+ var periodEnd = _epochMs(input.period_end, "period_end");
512
+ if (periodEnd < periodStart) {
513
+ throw new TypeError("meteredUsage.usageForPeriod: period_end must be >= period_start");
514
+ }
515
+
516
+ var meter = await _getMeter(meterSlug);
517
+ if (!meter) {
518
+ var notFound = new Error("meteredUsage.usageForPeriod: meter " + meterSlug + " not found");
519
+ notFound.code = "METER_NOT_FOUND";
520
+ throw notFound;
521
+ }
522
+
523
+ var r = await query(
524
+ "SELECT COALESCE(SUM(quantity), 0) AS total FROM meter_events " +
525
+ "WHERE subscription_id = ?1 AND meter_slug = ?2 " +
526
+ "AND occurred_at >= ?3 AND occurred_at <= ?4",
527
+ [subscriptionId, meterSlug, periodStart, periodEnd],
528
+ );
529
+ var totalUnits = Number(r.rows[0] && r.rows[0].total != null ? r.rows[0].total : 0);
530
+ var includedUnits = meter.included_units_per_period;
531
+ // Cap reported included units at the actual total — a 200-unit
532
+ // period with a 1000-unit allowance reports `included_units:
533
+ // 200`, not 1000, so the operator dashboard renders an accurate
534
+ // "free units consumed this period" number.
535
+ var includedConsumed = Math.min(includedUnits, totalUnits);
536
+ var billableUnits = Math.max(0, totalUnits - includedUnits);
537
+ var chargeMinor = _chargeFor(meter, billableUnits);
538
+
539
+ return {
540
+ meter_slug: meter.slug,
541
+ currency: meter.currency,
542
+ total_units: totalUnits,
543
+ included_units: includedConsumed,
544
+ billable_units: billableUnits,
545
+ charge_minor: chargeMinor,
546
+ };
547
+ }
548
+
549
+ // ---- periodSummary ------------------------------------------------
550
+
551
+ async function periodSummary(input) {
552
+ if (!input || typeof input !== "object") {
553
+ throw new TypeError("meteredUsage.periodSummary: input object required");
554
+ }
555
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
556
+ var periodStart = _epochMs(input.period_start, "period_start");
557
+ var periodEnd = _epochMs(input.period_end, "period_end");
558
+ if (periodEnd < periodStart) {
559
+ throw new TypeError("meteredUsage.periodSummary: period_end must be >= period_start");
560
+ }
561
+
562
+ // The distinct-meter scan is bounded by the catalog size, not
563
+ // the event volume — meter counts are operator-authored (tens,
564
+ // not millions) so the cross-product walk is cheap.
565
+ var meterRows = (await query(
566
+ "SELECT DISTINCT meter_slug FROM meter_events " +
567
+ "WHERE subscription_id = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3",
568
+ [subscriptionId, periodStart, periodEnd],
569
+ )).rows;
570
+
571
+ var lines = [];
572
+ var byCurrency = {};
573
+ for (var i = 0; i < meterRows.length; i += 1) {
574
+ var meterSlug = meterRows[i].meter_slug;
575
+ var line = await usageForPeriod({
576
+ subscription_id: subscriptionId,
577
+ meter_slug: meterSlug,
578
+ period_start: periodStart,
579
+ period_end: periodEnd,
580
+ });
581
+ lines.push(line);
582
+ if (!byCurrency[line.currency]) byCurrency[line.currency] = 0;
583
+ byCurrency[line.currency] += line.charge_minor;
584
+ }
585
+
586
+ // Deterministic ordering — meter_slug ASC — so the operator
587
+ // dashboard renders the same row order on every reload.
588
+ lines.sort(function (a, b) {
589
+ return a.meter_slug < b.meter_slug ? -1 : a.meter_slug > b.meter_slug ? 1 : 0;
590
+ });
591
+
592
+ return {
593
+ subscription_id: subscriptionId,
594
+ period_start: periodStart,
595
+ period_end: periodEnd,
596
+ lines: lines,
597
+ totals: byCurrency,
598
+ };
599
+ }
600
+
601
+ // ---- recordPeriodInvoice ------------------------------------------
602
+
603
+ async function recordPeriodInvoice(input) {
604
+ if (!input || typeof input !== "object") {
605
+ throw new TypeError("meteredUsage.recordPeriodInvoice: input object required");
606
+ }
607
+ if (billingHandle == null) {
608
+ throw new TypeError("meteredUsage.recordPeriodInvoice: subscriptionBilling handle required at factory time");
609
+ }
610
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
611
+ var periodStart = _epochMs(input.period_start, "period_start");
612
+ var periodEnd = _epochMs(input.period_end, "period_end");
613
+ if (periodEnd < periodStart) {
614
+ throw new TypeError("meteredUsage.recordPeriodInvoice: period_end must be >= period_start");
615
+ }
616
+
617
+ var summary = await periodSummary({
618
+ subscription_id: subscriptionId,
619
+ period_start: periodStart,
620
+ period_end: periodEnd,
621
+ });
622
+
623
+ var invoices = [];
624
+ var currencies = Object.keys(summary.totals).sort();
625
+ for (var i = 0; i < currencies.length; i += 1) {
626
+ var currency = currencies[i];
627
+ var amountMinor = summary.totals[currency];
628
+ // Skip zero-charge currencies — a period that consumed only
629
+ // included-floor units shouldn't enqueue a $0 invoice line.
630
+ if (amountMinor <= 0) continue;
631
+ var invoice = await billingHandle.recordInvoice({
632
+ subscription_id: subscriptionId,
633
+ period_start: periodStart,
634
+ period_end: periodEnd,
635
+ amount_minor: amountMinor,
636
+ currency: currency,
637
+ });
638
+ invoices.push(invoice);
639
+ }
640
+ return { summary: summary, invoices: invoices };
641
+ }
642
+
643
+ // ---- listMeters ---------------------------------------------------
644
+
645
+ async function listMeters(input) {
646
+ input = input || {};
647
+ var activeOnly = input.active_only === true;
648
+ var limit = _limit(input.limit);
649
+ var sql = "SELECT * FROM meters";
650
+ var params = [];
651
+ if (activeOnly) {
652
+ sql += " WHERE archived_at IS NULL";
653
+ }
654
+ sql += " ORDER BY slug ASC LIMIT ?1";
655
+ params.push(limit);
656
+ var r = await query(sql, params);
657
+ return r.rows.map(_hydrateMeter);
658
+ }
659
+
660
+ // ---- updateMeter --------------------------------------------------
661
+
662
+ async function updateMeter(slug, patch) {
663
+ slug = _slug(slug);
664
+ if (!patch || typeof patch !== "object") {
665
+ throw new TypeError("meteredUsage.updateMeter: patch object required");
666
+ }
667
+ var keys = Object.keys(patch);
668
+ if (keys.length === 0) {
669
+ throw new TypeError("meteredUsage.updateMeter: patch must contain at least one key");
670
+ }
671
+ for (var i = 0; i < keys.length; i += 1) {
672
+ if (ALLOWED_PATCH_COLUMNS.indexOf(keys[i]) === -1) {
673
+ throw new TypeError("meteredUsage.updateMeter: patch key " + keys[i] + " not allowed; allowed: " + ALLOWED_PATCH_COLUMNS.join(", "));
674
+ }
675
+ }
676
+
677
+ var existing = await _getMeter(slug);
678
+ if (!existing) {
679
+ var notFound = new Error("meteredUsage.updateMeter: meter " + slug + " not found");
680
+ notFound.code = "METER_NOT_FOUND";
681
+ throw notFound;
682
+ }
683
+ if (existing.archived_at != null) {
684
+ var archived = new Error("meteredUsage.updateMeter: meter " + slug + " is archived");
685
+ archived.code = "METER_ARCHIVED";
686
+ throw archived;
687
+ }
688
+
689
+ // The pricing-shape XOR carries forward — patching one pricing
690
+ // column must clear the other (the SQL CHECK constraint refuses
691
+ // both NULL or both NOT NULL). Apply the merged shape against
692
+ // the validators before writing.
693
+ var nextTierPrice = "tier_price_minor" in patch ? patch.tier_price_minor : existing.tier_price_minor;
694
+ var nextTierSchedule = "tier_schedule" in patch ? patch.tier_schedule : existing.tier_schedule;
695
+ var nextHasFlat = nextTierPrice != null;
696
+ var nextHasTiers = nextTierSchedule != null;
697
+ if (nextHasFlat && nextHasTiers) {
698
+ throw new TypeError("meteredUsage.updateMeter: cannot have both tier_price_minor and tier_schedule — set the other to null");
699
+ }
700
+ if (!nextHasFlat && !nextHasTiers) {
701
+ throw new TypeError("meteredUsage.updateMeter: exactly one of tier_price_minor / tier_schedule must remain set");
702
+ }
703
+ var tierPrice = nextHasFlat ? _tierPriceMinor(nextTierPrice) : null;
704
+ var tierSchedule = nextHasTiers ? _tierSchedule(nextTierSchedule) : null;
705
+
706
+ var nextTitle = "title" in patch ? _shortText(patch.title, "title", MAX_TITLE_LEN) : existing.title;
707
+ var nextUnit = "unit" in patch ? _shortText(patch.unit, "unit", MAX_UNIT_LEN) : existing.unit;
708
+ var nextIncluded = "included_units_per_period" in patch ? _includedUnits(patch.included_units_per_period) : existing.included_units_per_period;
709
+ var nextCurrency = "currency" in patch ? _currency(patch.currency) : existing.currency;
710
+
711
+ var ts = _now();
712
+ await query(
713
+ "UPDATE meters SET " +
714
+ "title = ?1, unit = ?2, tier_price_minor = ?3, tier_schedule_json = ?4, " +
715
+ "included_units_per_period = ?5, currency = ?6, updated_at = ?7 " +
716
+ "WHERE slug = ?8",
717
+ [
718
+ nextTitle,
719
+ nextUnit,
720
+ tierPrice,
721
+ tierSchedule == null ? null : JSON.stringify(tierSchedule),
722
+ nextIncluded,
723
+ nextCurrency,
724
+ ts,
725
+ slug,
726
+ ],
727
+ );
728
+ return await _getMeter(slug);
729
+ }
730
+
731
+ // ---- archiveMeter -------------------------------------------------
732
+
733
+ async function archiveMeter(slug) {
734
+ slug = _slug(slug);
735
+ var existing = await _getMeter(slug);
736
+ if (!existing) {
737
+ var notFound = new Error("meteredUsage.archiveMeter: meter " + slug + " not found");
738
+ notFound.code = "METER_NOT_FOUND";
739
+ throw notFound;
740
+ }
741
+ if (existing.archived_at != null) return existing; // no-op replay
742
+ var ts = _now();
743
+ await query(
744
+ "UPDATE meters SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
745
+ [ts, slug],
746
+ );
747
+ return await _getMeter(slug);
748
+ }
749
+
750
+ return {
751
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
752
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
753
+ MAX_UNIT_LEN: MAX_UNIT_LEN,
754
+ MAX_IDEM_KEY_LEN: MAX_IDEM_KEY_LEN,
755
+ MAX_TIER_COUNT: MAX_TIER_COUNT,
756
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
757
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
758
+
759
+ subscriptionBilling: billingHandle,
760
+ subscriptions: subscriptionsHandle,
761
+
762
+ defineMeter: defineMeter,
763
+ recordUsage: recordUsage,
764
+ usageForPeriod: usageForPeriod,
765
+ periodSummary: periodSummary,
766
+ recordPeriodInvoice: recordPeriodInvoice,
767
+ listMeters: listMeters,
768
+ updateMeter: updateMeter,
769
+ archiveMeter: archiveMeter,
770
+ };
771
+ }
772
+
773
+ module.exports = {
774
+ create: create,
775
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
776
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
777
+ MAX_UNIT_LEN: MAX_UNIT_LEN,
778
+ MAX_IDEM_KEY_LEN: MAX_IDEM_KEY_LEN,
779
+ MAX_TIER_COUNT: MAX_TIER_COUNT,
780
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
781
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
782
+ };