@stewardhq/sdk 0.3.0 → 0.5.0

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.
Files changed (39) hide show
  1. package/README.md +122 -92
  2. package/dist/_chunks/errors.js +1 -1
  3. package/dist/_chunks/events.d.ts +301 -4
  4. package/dist/_chunks/events.js +995 -17
  5. package/dist/_chunks/index.d.ts +3129 -63
  6. package/dist/_chunks/locale.d.ts +269 -1
  7. package/dist/_chunks/src.js +807 -25
  8. package/dist/_chunks/validators.d.ts +45 -1
  9. package/dist/_chunks/validators.js +128 -27
  10. package/dist/contract.d.ts +4 -4
  11. package/dist/contract.js +4 -4
  12. package/dist/index.d.ts +436 -22
  13. package/dist/index.js +1659 -26
  14. package/dist/server.d.ts +44 -6
  15. package/dist/server.js +29 -2
  16. package/package.json +2 -7
  17. package/dist/_chunks/steward.d.ts +0 -189
  18. package/dist/_chunks/steward.js +0 -564
  19. package/dist/testing/fixtures/events/LOCK.json +0 -27
  20. package/dist/testing/fixtures/events/account.deleted.json +0 -36
  21. package/dist/testing/fixtures/events/account.state_changed.json +0 -53
  22. package/dist/testing/fixtures/events/account.updated.json +0 -37
  23. package/dist/testing/fixtures/events/checkout.completed.json +0 -36
  24. package/dist/testing/fixtures/events/checkout.expired.json +0 -26
  25. package/dist/testing/fixtures/events/checkout.failed.json +0 -27
  26. package/dist/testing/fixtures/events/invoice.created.json +0 -37
  27. package/dist/testing/fixtures/events/invoice.issued.json +0 -38
  28. package/dist/testing/fixtures/events/invoice.voided.json +0 -39
  29. package/dist/testing/fixtures/events/subscription.activated.json +0 -40
  30. package/dist/testing/fixtures/events/subscription.cancel_scheduled.json +0 -38
  31. package/dist/testing/fixtures/events/subscription.canceled.json +0 -29
  32. package/dist/testing/fixtures/events/subscription.expired.json +0 -27
  33. package/dist/testing/fixtures/events/subscription.payment_failed.json +0 -38
  34. package/dist/testing/fixtures/events/subscription.reactivated.json +0 -36
  35. package/dist/testing/fixtures/events/subscription.renewed.json +0 -39
  36. package/dist/testing/fixtures/events/subscription.suspended.json +0 -36
  37. package/dist/testing/fixtures/events/subscription.terminated.json +0 -28
  38. package/dist/testing.d.ts +0 -656
  39. package/dist/testing.js +0 -3684
@@ -23,6 +23,31 @@ const EntitlementValueSchema = z.union([
23
23
  ]);
24
24
  const EntitlementsSchema = z.record(CodeSchema, EntitlementValueSchema);
25
25
  const MetadataSchema = z.record(z.string().max(64), z.string().max(500));
26
+ /**
27
+ * Why an invoice was written (Polar `billing_reason`): `subscription_create` first period,
28
+ * `subscription_cycle` renewal (phase 2 adds overage lines to it), `subscription_update` plan
29
+ * change, `purchase` reserved (one-off purchases are deferred), `manual` staff-written.
30
+ */
31
+ const INVOICE_BILLING_REASONS = [
32
+ "subscription_create",
33
+ "subscription_cycle",
34
+ "subscription_update",
35
+ "purchase",
36
+ "manual"
37
+ ];
38
+ const InvoiceBillingReasonSchema = z.enum(INVOICE_BILLING_REASONS);
39
+ /**
40
+ * Collection state of an invoice written before payment: `unpaid` (open, has `dueAt`), `paid`,
41
+ * `uncollectible` (given up after dunning), `waived` (operator forgave it). Invoices written the
42
+ * pre-billing-core way (after a payment, manual invoices) carry no payment status.
43
+ */
44
+ const INVOICE_PAYMENT_STATUSES = [
45
+ "unpaid",
46
+ "paid",
47
+ "uncollectible",
48
+ "waived"
49
+ ];
50
+ const InvoicePaymentStatusSchema = z.enum(INVOICE_PAYMENT_STATUSES);
26
51
  const ERROR_CATEGORIES = [
27
52
  "validation",
28
53
  "not_found",
@@ -46,6 +71,763 @@ const ErrorResponseSchema = z.object({
46
71
  requestId: z.string().optional().catch(void 0)
47
72
  });
48
73
  //#endregion
74
+ //#region ../contract/src/ingest.ts
75
+ /** Most events in one ingest batch (M11). */
76
+ const EVENTS_INGEST_MAX_EVENTS = 1e3;
77
+ /** Largest ingest request body in bytes (M11; enforced on the route, not by the schema). */
78
+ const EVENTS_INGEST_MAX_BYTES = 1048576;
79
+ const EVENT_NAME_MAX_LENGTH = 128;
80
+ const EVENT_METADATA_MAX_PAIRS = 50;
81
+ const EVENT_METADATA_KEY_MAX_LENGTH = 40;
82
+ const EVENT_METADATA_STRING_MAX_LENGTH = 500;
83
+ /** How far back an event `timestamp` may lie, relative to its receipt (M18). */
84
+ const EVENT_TIMESTAMP_MAX_AGE_DAYS = 35;
85
+ /** How far ahead an event `timestamp` may lie, relative to its receipt (clock skew, M18). */
86
+ const EVENT_TIMESTAMP_MAX_SKEW_MINUTES = 5;
87
+ /** Whether a string contains C0 control characters or DEL (kept out of names and keys shown in logs and doctor). */
88
+ function hasControlCharacters(value) {
89
+ for (let i = 0; i < value.length; i++) {
90
+ const c = value.charCodeAt(i);
91
+ if (c < 32 || c === 127) return true;
92
+ }
93
+ return false;
94
+ }
95
+ const noControlCharacters = (value) => !hasControlCharacters(value);
96
+ /** Event name (free text, e.g. `ai.completion`); meters select events by it. */
97
+ const EventNameSchema = z.string().min(1).max(128).refine(noControlCharacters, "no control characters");
98
+ /** Product-chosen dedup key of an event, unique within the product (`cmpl_<id>`, `req_<id>`; M10). */
99
+ const EventExternalIdSchema = z.string().regex(/^[A-Za-z0-9_.:-]{8,200}$/, "8–200 characters: letters, digits and _ . : -");
100
+ /** Metadata key; a meter filter reads it as `metadata.<key>`. `__proto__` is reserved. */
101
+ const EventMetadataKeySchema = z.string().min(1).max(40).refine(noControlCharacters, "no control characters").refine((key) => key !== "__proto__", "reserved key");
102
+ const EventMetadataValueSchema = z.union([
103
+ z.string().max(500),
104
+ z.number(),
105
+ z.boolean()
106
+ ]);
107
+ const EventMetadataSchema = z.record(EventMetadataKeySchema, EventMetadataValueSchema).refine((metadata) => Object.keys(metadata).length <= 50, `at most 50 metadata pairs`);
108
+ /** One event the product sends (product → steward; not a webhook `BillingEvent`). */
109
+ const IngestEventSchema = z.object({
110
+ /** The account's `externalRef`; the account must exist (`accounts.upsert` first, M12). */
111
+ accountRef: ExternalRefSchema,
112
+ name: EventNameSchema,
113
+ externalId: EventExternalIdSchema,
114
+ /** When it happened; default: when steward received it. Assigns the event to a meter period (M18). */
115
+ timestamp: DateTimeSchema.optional(),
116
+ metadata: EventMetadataSchema.optional()
117
+ });
118
+ /** `POST /v1/events/ingest` body. */
119
+ const EventsIngestInputSchema = z.object({ events: z.array(IngestEventSchema).min(1).max(EVENTS_INGEST_MAX_EVENTS) });
120
+ /** Per-event rejection codes this version knows (M12). The result schema accepts others too. */
121
+ const INGEST_REJECT_CODES = [
122
+ "account_not_found",
123
+ "account_deleted",
124
+ "timestamp_out_of_range"
125
+ ];
126
+ const IngestRejectCodeSchema = z.enum(INGEST_REJECT_CODES);
127
+ /**
128
+ * Rejection code in a result: a known `IngestRejectCode` or a code a newer steward added (open
129
+ * set, so an older client never fails a whole ingest result on it).
130
+ */
131
+ const RejectCodeReadSchema = z.string().min(1).max(100);
132
+ const IngestRejectionSchema = z.object({
133
+ /** Position of the event in the request's `events`. */
134
+ index: z.number().int().nonnegative(),
135
+ externalId: z.string(),
136
+ code: RejectCodeReadSchema
137
+ });
138
+ /**
139
+ * `POST /v1/events/ingest` result (200): `inserted` new events, `duplicates` already stored
140
+ * (`externalId` seen before), `rejected` per event (sorted by `index`; the SDK does not retry them).
141
+ */
142
+ const EventsIngestResultSchema = z.object({
143
+ inserted: z.number().int().nonnegative(),
144
+ duplicates: z.number().int().nonnegative(),
145
+ rejected: z.array(IngestRejectionSchema)
146
+ });
147
+ const DAY_MS = 864e5;
148
+ const MINUTE_MS = 6e4;
149
+ /**
150
+ * Whether an event `timestamp` is accepted for an event received at `receivedAt` (M18):
151
+ * [received − 35 days, received + 5 minutes], both ends inclusive. Outside: `timestamp_out_of_range`.
152
+ */
153
+ function isEventTimestampInRange(timestamp, receivedAt) {
154
+ const at = timestamp.getTime();
155
+ const received = receivedAt.getTime();
156
+ if (Number.isNaN(at) || Number.isNaN(received)) return false;
157
+ return at >= received - 35 * DAY_MS && at <= received + 5 * MINUTE_MS;
158
+ }
159
+ //#endregion
160
+ //#region ../contract/src/micro-units.ts
161
+ /** Decimal places of a meter unit value (10⁻⁶). */
162
+ const UNIT_DECIMALS = 6;
163
+ /** Micro units in one unit. */
164
+ const MICRO_UNITS_PER_UNIT = 1000000n;
165
+ /** A unit value that is not a finite, non-negative decimal (M4 `invalid_value`). */
166
+ var InvalidUnitValueError = class extends RangeError {
167
+ code = "invalid_value";
168
+ constructor(value) {
169
+ super(`invalid unit value: ${typeof value === "string" ? JSON.stringify(value.slice(0, 50)) : String(value)}`);
170
+ this.name = "InvalidUnitValueError";
171
+ }
172
+ };
173
+ /** Decimal text: optional minus, digits, optional fraction, optional exponent (`1e-7`, `1.5e+21`). */
174
+ const DECIMAL_TEXT = /^(-)?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/;
175
+ /** Longest decimal string accepted (a `numeric(30,6)` text is 32 characters). */
176
+ const MAX_TEXT_LENGTH = 400;
177
+ /** Exponents beyond this are outside the double range (a JS number never reaches them). */
178
+ const MAX_EXPONENT = 400;
179
+ function decimalParts(text) {
180
+ if (text.length > MAX_TEXT_LENGTH) return null;
181
+ const m = DECIMAL_TEXT.exec(text);
182
+ if (!m) return null;
183
+ const exponent = m[4] === void 0 ? 0 : Number(m[4]);
184
+ if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > MAX_EXPONENT) return null;
185
+ const fraction = m[3] ?? "";
186
+ return {
187
+ negative: m[1] === "-",
188
+ digits: BigInt(`${m[2]}${fraction}`),
189
+ scale: fraction.length - exponent
190
+ };
191
+ }
192
+ function numberParts(value) {
193
+ return Number.isFinite(value) ? decimalParts(String(value)) : null;
194
+ }
195
+ const pow10 = (n) => 10n ** BigInt(n);
196
+ /** `digits × 10^(6 - scale)`, rounded half up when digits are dropped. */
197
+ function microOf(parts) {
198
+ const shift = 6 - parts.scale;
199
+ if (shift >= 0) return parts.digits * pow10(shift);
200
+ const divisor = pow10(-shift);
201
+ const quotient = parts.digits / divisor;
202
+ return 2n * (parts.digits % divisor) >= divisor ? quotient + 1n : quotient;
203
+ }
204
+ /**
205
+ * Micro units (10⁻⁶) of a finite, non-negative decimal, or `null` when the value is not one.
206
+ * A number is read from its canonical decimal text (`String(value)`); a string must be a plain
207
+ * decimal (`"12"`, `"0.25"`, `"1e-7"`, Postgres `numeric` text). Beyond 6 decimals: half up.
208
+ */
209
+ function parseMicroUnits(value) {
210
+ const parts = typeof value === "number" ? numberParts(value) : typeof value === "string" ? decimalParts(value) : null;
211
+ if (parts === null) return null;
212
+ if (parts.negative && parts.digits !== 0n) return null;
213
+ return microOf(parts);
214
+ }
215
+ /**
216
+ * Micro units (10⁻⁶) of a finite, non-negative decimal: `toMicroUnits(0.25) === 250000n`,
217
+ * `toMicroUnits("123.4567895") === 123456790n` (half up), `toMicroUnits(1e-7) === 0n`.
218
+ * Throws `InvalidUnitValueError` (`code: "invalid_value"`) for a negative, non-finite or
219
+ * non-decimal value.
220
+ */
221
+ function toMicroUnits(value) {
222
+ const micro = parseMicroUnits(value);
223
+ if (micro === null) throw new InvalidUnitValueError(value);
224
+ return micro;
225
+ }
226
+ /**
227
+ * Canonical decimal text of micro units: no exponent, no leading zeros, no trailing fraction zeros
228
+ * (`1500000000n` → `"1500"`, `250000n` → `"0.25"`, `-1500000n` → `"-1.5"`). Invoice line `units`.
229
+ */
230
+ function microToDecimalString(micro) {
231
+ const negative = micro < 0n;
232
+ const abs = negative ? -micro : micro;
233
+ const whole = (abs / MICRO_UNITS_PER_UNIT).toString();
234
+ const fraction = (abs % MICRO_UNITS_PER_UNIT).toString().padStart(6, "0").replace(/0+$/, "");
235
+ return `${negative ? "-" : ""}${whole}${fraction === "" ? "" : `.${fraction}`}`;
236
+ }
237
+ /**
238
+ * Micro units as a JSON number (API responses): the double nearest to the exact decimal, so any
239
+ * value up to 15 significant digits round-trips (`toMicroUnits(fromMicroUnits(x)) === x`).
240
+ */
241
+ function fromMicroUnits(micro) {
242
+ return Number(microToDecimalString(micro));
243
+ }
244
+ /**
245
+ * Plain decimal text of a finite number, without exponent: `1e21` → `"1000000000000000000000"`,
246
+ * `1e-7` → `"0.0000001"`, `-0` → `"0"`. Throws for a non-finite number.
247
+ */
248
+ function plainDecimalOf(value) {
249
+ const parts = numberParts(value);
250
+ if (parts === null) throw new InvalidUnitValueError(value);
251
+ if (parts.digits === 0n) return "0";
252
+ const sign = parts.negative ? "-" : "";
253
+ if (parts.scale <= 0) return `${sign}${(parts.digits * pow10(-parts.scale)).toString()}`;
254
+ const text = parts.digits.toString().padStart(parts.scale + 1, "0");
255
+ const whole = text.slice(0, text.length - parts.scale).replace(/^0+(?=\d)/, "");
256
+ const fraction = text.slice(text.length - parts.scale).replace(/0+$/, "");
257
+ return `${sign}${whole}${fraction === "" ? "" : `.${fraction}`}`;
258
+ }
259
+ /**
260
+ * Whether a number is a finite value with at most 6 decimals, i.e. exactly representable in micro
261
+ * units (catalog credits, credit grants and `lowAt` must be; event values are rounded instead).
262
+ */
263
+ function isMicroPrecise(value) {
264
+ const parts = numberParts(value);
265
+ if (parts === null) return false;
266
+ if (parts.scale <= 6) return true;
267
+ return parts.digits % pow10(parts.scale - 6) === 0n;
268
+ }
269
+ //#endregion
270
+ //#region ../contract/src/meters.ts
271
+ const METER_FILTER_OPERATORS = [
272
+ "eq",
273
+ "ne",
274
+ "gt",
275
+ "gte",
276
+ "lt",
277
+ "lte",
278
+ "like",
279
+ "not_like"
280
+ ];
281
+ /** Deepest nesting of `and`/`or` groups. */
282
+ const METER_FILTER_MAX_DEPTH = 3;
283
+ /** Most conditions in one filter. */
284
+ const METER_FILTER_MAX_CONDITIONS = 20;
285
+ const METADATA_PREFIX = "metadata.";
286
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
287
+ const isOperator = (value) => METER_FILTER_OPERATORS.includes(value);
288
+ function valueIssue(property, operator, value) {
289
+ const isName = property === "name";
290
+ if (isName && ![
291
+ "eq",
292
+ "ne",
293
+ "like",
294
+ "not_like"
295
+ ].includes(operator)) return `operator ${operator} does not apply to name`;
296
+ switch (operator) {
297
+ case "eq":
298
+ case "ne":
299
+ if (isName) return typeof value === "string" && value.length <= 128 ? null : `name ${operator} needs a string of at most 128 characters`;
300
+ if (typeof value === "string") return value.length <= 500 ? null : `at most 500 characters`;
301
+ if (typeof value === "number") return Number.isFinite(value) ? null : "a finite number";
302
+ return typeof value === "boolean" ? null : `${operator} needs a string, number or boolean`;
303
+ case "gt":
304
+ case "gte":
305
+ case "lt":
306
+ case "lte": return typeof value === "number" && Number.isFinite(value) ? null : `${operator} needs a finite number`;
307
+ case "like":
308
+ case "not_like": return typeof value === "string" && value.length >= 1 && value.length <= 500 ? null : `${operator} needs a non-empty string of at most 500 characters`;
309
+ }
310
+ }
311
+ function propertyIssue(property) {
312
+ if (property === "name") return null;
313
+ if (!property.startsWith(METADATA_PREFIX)) return `unknown property ${JSON.stringify(property.slice(0, 50))}: use name or metadata.<key>`;
314
+ return EventMetadataKeySchema.safeParse(property.slice(9)).success ? null : `invalid metadata key in ${JSON.stringify(property.slice(0, 60))}`;
315
+ }
316
+ /**
317
+ * The explicit form of a condition, or `null` when it is not a valid condition (the schema reports
318
+ * why). `{name: "x"}` → `{property: "name", operator: "eq", value: "x"}`.
319
+ */
320
+ function meterFilterClauseOf(condition) {
321
+ if (!isPlainObject(condition)) return null;
322
+ const keys = Object.keys(condition);
323
+ if (keys.length !== 1) return null;
324
+ const property = keys[0];
325
+ if (property === "and" || property === "or" || propertyIssue(property) !== null) return null;
326
+ const raw = condition[property];
327
+ let operator = "eq";
328
+ let value = raw;
329
+ if (isPlainObject(raw)) {
330
+ const ops = Object.keys(raw);
331
+ if (ops.length !== 1 || !isOperator(ops[0])) return null;
332
+ operator = ops[0];
333
+ value = raw[operator];
334
+ }
335
+ if (valueIssue(property, operator, value) !== null) return null;
336
+ return {
337
+ property,
338
+ operator,
339
+ value
340
+ };
341
+ }
342
+ /** Structural issues of a filter (empty when valid). */
343
+ function meterFilterIssues(filter) {
344
+ const issues = [];
345
+ let conditions = 0;
346
+ let deepest = 0;
347
+ const visit = (node, path, depth) => {
348
+ if (!isPlainObject(node)) {
349
+ issues.push({
350
+ path,
351
+ message: "a filter is an object: a condition or an and/or group"
352
+ });
353
+ return;
354
+ }
355
+ const keys = Object.keys(node);
356
+ if (keys.includes("and") || keys.includes("or")) {
357
+ if (keys.length !== 1) {
358
+ issues.push({
359
+ path,
360
+ message: "a group has exactly one key: and or or"
361
+ });
362
+ return;
363
+ }
364
+ const key = keys[0];
365
+ const members = node[key];
366
+ deepest = Math.max(deepest, depth + 1);
367
+ if (!Array.isArray(members) || members.length === 0) {
368
+ issues.push({
369
+ path: [...path, key],
370
+ message: `${key} needs a non-empty array of filters`
371
+ });
372
+ return;
373
+ }
374
+ if (depth + 1 > 3) return;
375
+ members.forEach((member, i) => visit(member, [
376
+ ...path,
377
+ key,
378
+ i
379
+ ], depth + 1));
380
+ return;
381
+ }
382
+ conditions += 1;
383
+ if (keys.length !== 1) {
384
+ issues.push({
385
+ path,
386
+ message: "a condition has exactly one property (combine conditions with and)"
387
+ });
388
+ return;
389
+ }
390
+ const property = keys[0];
391
+ const problem = propertyIssue(property);
392
+ if (problem !== null) {
393
+ issues.push({
394
+ path: [...path, property],
395
+ message: problem
396
+ });
397
+ return;
398
+ }
399
+ const raw = node[property];
400
+ if (isPlainObject(raw)) {
401
+ const ops = Object.keys(raw);
402
+ if (ops.length !== 1 || !isOperator(ops[0])) {
403
+ issues.push({
404
+ path: [...path, property],
405
+ message: `exactly one operator of ${METER_FILTER_OPERATORS.join(", ")}`
406
+ });
407
+ return;
408
+ }
409
+ const issue = valueIssue(property, ops[0], raw[ops[0]]);
410
+ if (issue !== null) issues.push({
411
+ path: [
412
+ ...path,
413
+ property,
414
+ ops[0]
415
+ ],
416
+ message: issue
417
+ });
418
+ return;
419
+ }
420
+ const issue = valueIssue(property, "eq", raw);
421
+ if (issue !== null) issues.push({
422
+ path: [...path, property],
423
+ message: issue
424
+ });
425
+ };
426
+ visit(filter, [], 0);
427
+ if (deepest > 3) issues.push({
428
+ path: [],
429
+ message: `and/or groups nest at most 3 deep`
430
+ });
431
+ if (conditions > 20) issues.push({
432
+ path: [],
433
+ message: `at most 20 conditions`
434
+ });
435
+ return issues;
436
+ }
437
+ /** Meter filter (see the syntax above). Validates structure; the value is kept as given. */
438
+ const MeterFilterSchema = z.custom().superRefine((value, ctx) => {
439
+ for (const issue of meterFilterIssues(value)) ctx.addIssue({
440
+ code: "custom",
441
+ path: issue.path,
442
+ message: issue.message
443
+ });
444
+ });
445
+ /**
446
+ * Canonical form of a valid filter: every condition explicit (`{name: "x"}` → `{name: {eq: "x"}}`),
447
+ * a fresh object. Two filters that differ only by the shorthand have the same canonical JSON
448
+ * (the lock check, M4, compares canonical filters). Throws on an invalid filter.
449
+ */
450
+ function canonicalMeterFilter(filter) {
451
+ if ("and" in filter && Array.isArray(filter.and)) return { and: filter.and.map(canonicalMeterFilter) };
452
+ if ("or" in filter && Array.isArray(filter.or)) return { or: filter.or.map(canonicalMeterFilter) };
453
+ const clause = meterFilterClauseOf(filter);
454
+ if (clause === null) throw new Error("invalid meter filter condition");
455
+ return { [clause.property]: { [clause.operator]: clause.value } };
456
+ }
457
+ /**
458
+ * What a meter computes over its matching events in a period (M4; no `min`/`avg`):
459
+ * `count` events; `sum` / `max` of the finite, non-negative number at `metadata.<key>` (decimals
460
+ * allowed); `unique` count of distinct values at `metadata.<key>`. An event without a usable
461
+ * value adds nothing to that meter (`invalid_value`, shown by doctor).
462
+ */
463
+ const METER_AGGREGATION_KINDS = [
464
+ "count",
465
+ "sum",
466
+ "max",
467
+ "unique"
468
+ ];
469
+ const MeterAggregationSchema = z.discriminatedUnion("kind", [
470
+ z.object({ kind: z.literal("count") }),
471
+ z.object({
472
+ kind: z.literal("sum"),
473
+ key: EventMetadataKeySchema
474
+ }),
475
+ z.object({
476
+ kind: z.literal("max"),
477
+ key: EventMetadataKeySchema
478
+ }),
479
+ z.object({
480
+ kind: z.literal("unique"),
481
+ key: EventMetadataKeySchema
482
+ })
483
+ ]);
484
+ /** Largest unit amount in a definition (credit, rollover cap, credit grant): exact as a JSON number. */
485
+ const MAX_METER_UNITS = 0x38d7ea4c68000;
486
+ /** Default `lowAt` (M14): standing becomes `low` at 80% of the credit. */
487
+ const DEFAULT_METER_LOW_AT = .8;
488
+ const microPrecise = (value) => isMicroPrecise(value);
489
+ /** A positive unit amount in a definition: at most 6 decimals (M4), at most `MAX_METER_UNITS`. */
490
+ const MeterUnitsSchema = z.number().positive().max(MAX_METER_UNITS).refine(microPrecise, "at most 6 decimal places");
491
+ const LabelTextSchema = z.string().trim().min(1).max(100).refine((value) => !hasControlCharacters(value), "no control characters");
492
+ /** Customer-facing text per locale (hosted checkout and portal). Both locales are required. */
493
+ const MeterLabelSchema = z.object({
494
+ tr: LabelTextSchema,
495
+ en: LabelTextSchema
496
+ });
497
+ /** Share of the credit at which standing becomes `low`: 0 < x ≤ 1, at most 6 decimals. */
498
+ const MeterLowAtSchema = z.number().gt(0).lte(1).refine(microPrecise, "at most 6 decimal places");
499
+ const MeterDefFieldsSchema = z.object({
500
+ /** Shorthand: only events with this name (`filter` `{name: {eq: event}}`); with `filter`, both must hold. */
501
+ event: EventNameSchema.optional(),
502
+ filter: MeterFilterSchema.optional(),
503
+ aggregation: MeterAggregationSchema,
504
+ label: MeterLabelSchema,
505
+ /** Unit name, e.g. `{tr: "token", en: "tokens"}`. */
506
+ unitLabel: MeterLabelSchema.optional(),
507
+ /** Display divisor on hosted pages, e.g. 1000 → "12.5K tokens" (display only). */
508
+ displayPer: z.number().int().min(1).max(1e9).optional(),
509
+ /** Standing `low` threshold (M14); default 0.8; `null` disables `low`. */
510
+ lowAt: MeterLowAtSchema.nullable().default(DEFAULT_METER_LOW_AT)
511
+ });
512
+ function combinedFilter(event, filter) {
513
+ const byName = { name: { eq: event ?? "" } };
514
+ if (filter === void 0) return byName;
515
+ const canonical = canonicalMeterFilter(filter);
516
+ if (event === void 0) return canonical;
517
+ return "and" in canonical && Array.isArray(canonical.and) ? { and: [byName, ...canonical.and] } : { and: [byName, canonical] };
518
+ }
519
+ /**
520
+ * Meter definition (catalog sync input; the record key is the meter code). Input takes `event`
521
+ * and/or `filter`; the output has only `filter`, canonical, `event` folded in (`{and: [{name:
522
+ * {eq: event}}, …filter]}`). Filter and aggregation lock once the meter's first period opened
523
+ * (`422 meter_locked`; M4): a change means a new code.
524
+ */
525
+ const MeterDefSchema = MeterDefFieldsSchema.superRefine((def, ctx) => {
526
+ if (def.event === void 0 && def.filter === void 0) {
527
+ ctx.addIssue({
528
+ code: "custom",
529
+ path: ["filter"],
530
+ message: "event or filter is required"
531
+ });
532
+ return;
533
+ }
534
+ if (def.event !== void 0 && def.filter !== void 0 && meterFilterIssues(def.filter).length === 0) for (const issue of meterFilterIssues(combinedFilter(def.event, def.filter))) ctx.addIssue({
535
+ code: "custom",
536
+ path: ["filter", ...issue.path],
537
+ message: `with event: ${issue.message}`
538
+ });
539
+ }).transform(({ event, filter, ...rest }) => ({
540
+ filter: combinedFilter(event, filter),
541
+ ...rest
542
+ }));
543
+ /**
544
+ * A meter as returned by `GET /v1/catalog` (active meters). Lenient for forward compatibility: a
545
+ * newer steward may use filter or aggregation forms this version does not define; narrow `filter`
546
+ * with `MeterFilterSchema` and `aggregation` with `MeterAggregationSchema`.
547
+ */
548
+ const CatalogMeterSchema = z.object({
549
+ filter: z.unknown(),
550
+ aggregation: z.looseObject({
551
+ kind: z.string().min(1).max(64),
552
+ key: z.string().optional()
553
+ }),
554
+ label: MeterLabelSchema,
555
+ unitLabel: MeterLabelSchema.optional(),
556
+ displayPer: z.number().int().positive().optional(),
557
+ lowAt: z.number().nullable()
558
+ });
559
+ /**
560
+ * `meter_credit` benefit properties (M5, M6): `units` granted at the start of every meter period
561
+ * (plan + add-ons for the same meter add up; the default plan's credit only when no other layer
562
+ * credits the meter — `meterCreditsOf`); `rolloverCapUnits` turns rollover on — the unused
563
+ * balance carries over up to the cap (absent: unused credit expires; debt never carries).
564
+ */
565
+ const MeterCreditPropertiesSchema = z.object({
566
+ meter: CodeSchema,
567
+ units: MeterUnitsSchema,
568
+ rolloverCapUnits: MeterUnitsSchema.optional()
569
+ });
570
+ /** Largest `perUnits` of a metered price. */
571
+ const MAX_METERED_PRICE_PER_UNITS = 0xe8d4a51000;
572
+ /**
573
+ * Metered price (catalog sync input; MF5, M20): overage above the credit is billed
574
+ * `amountMinor` per `perUnits` units ("₺12,50 / 1.000 token" = `{amountMinor: 1250, perUnits:
575
+ * 1000}`; an integer pair, no decimal unit price). Key `(planCode, meter, currency)`. Tax terms
576
+ * must equal the plan's base prices in that currency (`metered_price_tax_mismatch`). `capMinor`
577
+ * caps the overage amount per meter period. Mutable in place like base prices (M36); a
578
+ * subscription locks the rate it started with.
579
+ */
580
+ const MeteredPriceDefSchema = z.object({
581
+ planCode: CodeSchema,
582
+ meter: CodeSchema,
583
+ currency: CurrencySchema,
584
+ amountMinor: AmountMinorSchema,
585
+ perUnits: z.number().int().min(1).max(MAX_METERED_PRICE_PER_UNITS),
586
+ /** Same meaning as `PriceDef.taxInclusive`; must equal the plan's base price. */
587
+ taxInclusive: z.boolean(),
588
+ /** Basis points (20% = 2000); must equal the plan's base price. */
589
+ taxRateBps: z.number().int().min(0).max(1e4),
590
+ capMinor: AmountMinorSchema.optional()
591
+ });
592
+ /** Metered price as returned by `GET /v1/catalog` (active ones). */
593
+ const MeteredPriceSchema = MeteredPriceDefSchema.extend({
594
+ id: IdSchema,
595
+ active: z.boolean(),
596
+ capMinor: AmountMinorSchema.nullable().optional(),
597
+ createdAt: DateTimeSchema
598
+ });
599
+ /**
600
+ * Credit standing of a meter in its period (M14): `low` once consumption reaches `lowAt` × credit,
601
+ * `exhausted` once the balance is ≤ 0; a meter without credit is always `ok`. Standing describes
602
+ * the credit only: a billable meter (metered price) keeps working past `exhausted` (M15).
603
+ */
604
+ const METER_STANDINGS = [
605
+ "ok",
606
+ "low",
607
+ "exhausted"
608
+ ];
609
+ const MeterStandingSchema = z.enum(METER_STANDINGS);
610
+ /** Units in a response: a JSON number with at most 6 decimals (`fromMicroUnits`). */
611
+ const UnitsNumberSchema = z.number();
612
+ const NonNegativeUnitsNumberSchema = z.number().nonnegative();
613
+ /**
614
+ * Units as a decimal string (invoice lines, meter charges): non-negative, at most 6 decimals.
615
+ * Steward writes the canonical form (`microToDecimalString`: no exponent, no trailing zeros).
616
+ */
617
+ const DecimalUnitsStringSchema = z.string().regex(/^\d{1,24}(\.\d{1,6})?$/, "non-negative decimal with at most 6 decimals");
618
+ /** One meter of `GET /v1/accounts/:ref/meters`: the meter's OPEN period (periods are per meter, M7). */
619
+ const AccountMeterSchema = z.object({
620
+ periodStart: DateTimeSchema,
621
+ periodEnd: DateTimeSchema,
622
+ /**
623
+ * Units the account's `meter_credit` benefits grant per period (`meterCreditsOf`); `null` when
624
+ * none does — such a meter is not gated by credit (M15).
625
+ */
626
+ includedUnits: NonNegativeUnitsNumberSchema.nullable(),
627
+ consumedUnits: NonNegativeUnitsNumberSchema,
628
+ /** Credit of the period: cycle grants, rollover, plan-change top-ups, manual grants. */
629
+ creditedUnits: NonNegativeUnitsNumberSchema,
630
+ /** `creditedUnits − consumedUnits`; negative past the credit. */
631
+ balance: UnitsNumberSchema,
632
+ /** `max(0, consumedUnits − creditedUnits)`. */
633
+ overageUnits: NonNegativeUnitsNumberSchema,
634
+ standing: MeterStandingSchema,
635
+ /** A live subscription has a metered rate for this meter: overage is billed, the gate stays open (M15) until `capReached`. */
636
+ billable: z.boolean(),
637
+ /** Estimated overage amount of the open period (billable meters); otherwise null. */
638
+ amountDueMinor: AmountMinorSchema.nullable(),
639
+ currency: CurrencySchema.nullable(),
640
+ /**
641
+ * The period's priced overage reached the locked rate's `capMinor` (`overageCapReached`): further
642
+ * use is not billed, so the gate closes (M15). False when not billable or the rate has no cap.
643
+ * Absent from an older steward (read as false).
644
+ */
645
+ capReached: z.boolean().optional()
646
+ });
647
+ /**
648
+ * `GET /v1/accounts/:ref/meters` (M13): a meter READ, not a second state model — no version, no
649
+ * events. ETag = the account's `meters_seq` (rollup and credit writes); `If-None-Match` → 304.
650
+ * `rolledUpThrough`: events received before this moment are counted.
651
+ */
652
+ const AccountMetersSchema = z.object({
653
+ ref: ExternalRefSchema,
654
+ asOf: DateTimeSchema,
655
+ rolledUpThrough: DateTimeSchema,
656
+ meters: z.record(CodeSchema, AccountMeterSchema)
657
+ });
658
+ const METER_PERIOD_STATUSES = [
659
+ "open",
660
+ "closing",
661
+ "closed"
662
+ ];
663
+ const MeterPeriodStatusSchema = z.enum(METER_PERIOD_STATUSES);
664
+ /** A meter period (`GET /v1/accounts/:ref/meters/periods`; closed periods, newest first). */
665
+ const MeterPeriodSchema = z.object({
666
+ meter: CodeSchema,
667
+ periodStart: DateTimeSchema,
668
+ periodEnd: DateTimeSchema,
669
+ status: MeterPeriodStatusSchema,
670
+ /** Subscription the period is anchored to (M7); null for the account anchor. */
671
+ subscriptionId: IdSchema.nullable(),
672
+ consumedUnits: NonNegativeUnitsNumberSchema,
673
+ creditedUnits: NonNegativeUnitsNumberSchema,
674
+ overageUnits: NonNegativeUnitsNumberSchema,
675
+ closedAt: DateTimeSchema.nullable()
676
+ });
677
+ const MeterPeriodListSchema = z.object({
678
+ ref: ExternalRefSchema,
679
+ periods: z.array(MeterPeriodSchema)
680
+ });
681
+ /**
682
+ * `POST /v1/accounts/:ref/credits` (M5): a one-off credit for the meter's open period (goodwill,
683
+ * campaign). Needs no benefit; recorded in the append-only credit ledger with the actor.
684
+ * Unknown meter: `422 meter_not_found`.
685
+ */
686
+ const CreditGrantInputSchema = z.object({
687
+ meter: CodeSchema,
688
+ units: MeterUnitsSchema,
689
+ reason: z.string().trim().min(3).max(500)
690
+ });
691
+ /** Credit ledger entry kinds (M5). Entries are never rewritten. */
692
+ const METER_CREDIT_ENTRY_KINDS = [
693
+ "cycle_grant",
694
+ "rollover",
695
+ "plan_change",
696
+ "manual_grant",
697
+ "adjustment"
698
+ ];
699
+ const MeterCreditEntryKindSchema = z.enum(METER_CREDIT_ENTRY_KINDS);
700
+ const MeterCreditEntrySchema = z.object({
701
+ id: IdSchema,
702
+ meter: CodeSchema,
703
+ kind: MeterCreditEntryKindSchema,
704
+ /** Signed units (an adjustment may be negative). */
705
+ units: UnitsNumberSchema,
706
+ periodStart: DateTimeSchema,
707
+ reason: z.string().nullable(),
708
+ actorRef: z.string().nullable(),
709
+ createdAt: DateTimeSchema
710
+ });
711
+ const METER_CHARGE_STATUSES = [
712
+ "dry_run",
713
+ "carried",
714
+ "invoiced"
715
+ ];
716
+ const MeterChargeStatusSchema = z.enum(METER_CHARGE_STATUSES);
717
+ /** One priced meter of a charge (M21): overage × locked rate, capped. Units are decimal strings. */
718
+ const MeterChargeLineSchema = z.object({
719
+ meter: CodeSchema,
720
+ consumedUnits: DecimalUnitsStringSchema,
721
+ creditedUnits: DecimalUnitsStringSchema,
722
+ overageUnits: DecimalUnitsStringSchema,
723
+ priceAmountMinor: AmountMinorSchema,
724
+ pricePerUnits: z.number().int().min(1),
725
+ capMinor: AmountMinorSchema.nullable(),
726
+ /** `rateOverage(...)`: tax terms of the price (gross when tax-inclusive). */
727
+ amountMinor: AmountMinorSchema
728
+ });
729
+ /**
730
+ * Pricing record of a closed meter period (M21; money data, never rewritten). Totals use the
731
+ * invoice rule (`meterChargeTotals`, one rounding). `status`: `dry_run` (chargeMode `dry_run`:
732
+ * recorded, not invoiced), `carried` (below `billing.minInvoiceMinor` on an overage-only invoice:
733
+ * carried into the next invoice), `invoiced` (`invoiceId` set). `carriedInMinor`: earlier carried
734
+ * amounts included here.
735
+ */
736
+ const MeterChargeSchema = z.object({
737
+ id: IdSchema,
738
+ subscriptionId: IdSchema.nullable(),
739
+ periodStart: DateTimeSchema,
740
+ periodEnd: DateTimeSchema,
741
+ currency: CurrencySchema,
742
+ taxInclusive: z.boolean(),
743
+ taxRateBps: z.number().int().min(0).max(1e4),
744
+ lines: z.array(MeterChargeLineSchema),
745
+ subtotalMinor: AmountMinorSchema,
746
+ taxMinor: AmountMinorSchema,
747
+ totalMinor: AmountMinorSchema,
748
+ carriedInMinor: AmountMinorSchema,
749
+ status: MeterChargeStatusSchema,
750
+ invoiceId: IdSchema.nullable(),
751
+ createdAt: DateTimeSchema
752
+ });
753
+ /** `GET /v1/accounts/:ref/meter-charges` (newest first). */
754
+ const MeterChargeListSchema = z.object({
755
+ ref: ExternalRefSchema,
756
+ charges: z.array(MeterChargeSchema)
757
+ });
758
+ //#endregion
759
+ //#region ../contract/src/benefits.ts
760
+ /** Benefit types this contract version defines. */
761
+ const BENEFIT_TYPES = ["feature_flag", "meter_credit"];
762
+ const BenefitTypeSchema = z.enum(BENEFIT_TYPES);
763
+ const DescriptionTextSchema = z.string().trim().min(1).max(500);
764
+ /** Customer-facing description per locale (hosted checkout and portal "what's included"). */
765
+ const BenefitDescriptionSchema = z.object({
766
+ tr: DescriptionTextSchema.optional(),
767
+ en: DescriptionTextSchema.optional()
768
+ });
769
+ /** `feature_flag` properties: catalog feature key → value (K4 typing; checked against `features` on catalog sync). */
770
+ const FeatureFlagPropertiesSchema = z.record(CodeSchema, EntitlementValueSchema);
771
+ const FeatureFlagBenefitDefSchema = z.object({
772
+ type: z.literal("feature_flag"),
773
+ properties: FeatureFlagPropertiesSchema,
774
+ description: BenefitDescriptionSchema.optional()
775
+ });
776
+ /** `meter_credit` definition (metering phase 2): the meter must be a catalog meter (checked on catalog sync). */
777
+ const MeterCreditBenefitDefSchema = z.object({
778
+ type: z.literal("meter_credit"),
779
+ properties: MeterCreditPropertiesSchema,
780
+ description: BenefitDescriptionSchema.optional()
781
+ });
782
+ /** Catalog benefit definition (catalog sync input), discriminated by `type`. */
783
+ const BenefitDefSchema = z.discriminatedUnion("type", [FeatureFlagBenefitDefSchema, MeterCreditBenefitDefSchema]);
784
+ /**
785
+ * Benefit definition as returned by `GET /v1/catalog`. `type` is an open set (a newer steward may
786
+ * return types this version does not define); narrow with `type === "feature_flag"`.
787
+ */
788
+ const CatalogBenefitSchema = z.object({
789
+ type: z.string().min(1).max(64),
790
+ properties: z.record(z.string(), z.unknown()),
791
+ description: BenefitDescriptionSchema.optional()
792
+ });
793
+ /**
794
+ * Where a granted benefit comes from, e.g. `plan:<code>` (default plan or a live subscription's
795
+ * plan), `grant:<id>` (a grant), `override` (account overrides). Open set: informational.
796
+ */
797
+ const BenefitSourceSchema = z.string().min(1).max(200);
798
+ /**
799
+ * A benefit granted to the account (`AccountState.benefits[]`; Polar granted benefit).
800
+ * `code` is the catalog benefit code; `null` for an implicit benefit that has no catalog code
801
+ * (a plan's entitlement matrix, account overrides). `type` is an open set (see
802
+ * `CatalogBenefitSchema`); `properties` are the values granted.
803
+ */
804
+ const AccountBenefitSchema = z.object({
805
+ code: CodeSchema.nullable(),
806
+ type: z.string().min(1).max(64),
807
+ properties: z.record(z.string(), z.unknown()),
808
+ source: BenefitSourceSchema,
809
+ grantedAt: DateTimeSchema
810
+ });
811
+ /** Narrows a granted benefit to `feature_flag` (type and property values checked). */
812
+ function isFeatureFlagBenefit(benefit) {
813
+ return benefit.type === "feature_flag" && FeatureFlagPropertiesSchema.safeParse(benefit.properties).success;
814
+ }
815
+ /** Narrows a granted benefit to `meter_credit` (type and properties checked). */
816
+ function isMeterCreditBenefit(benefit) {
817
+ return benefit.type === "meter_credit" && MeterCreditPropertiesSchema.safeParse(benefit.properties).success;
818
+ }
819
+ /**
820
+ * Whether a value fits a feature type (K4): `limit` → integer ≥ 0 or `null` (unlimited),
821
+ * `boolean` → boolean, `string` → string. `null` is only valid for `limit`.
822
+ */
823
+ function featureValueMatchesType(type, value) {
824
+ switch (type) {
825
+ case "limit": return value === null || typeof value === "number" && Number.isInteger(value) && value >= 0;
826
+ case "boolean": return typeof value === "boolean";
827
+ case "string": return typeof value === "string";
828
+ }
829
+ }
830
+ //#endregion
49
831
  //#region ../contract/src/emails.ts
50
832
  const EMAIL_TEMPLATES = [
51
833
  "subscription_started",
@@ -55,10 +837,77 @@ const EMAIL_TEMPLATES = [
55
837
  "subscription_ended",
56
838
  "access_suspended",
57
839
  "subscription_terminated",
58
- "invoice_issued"
840
+ "invoice_issued",
841
+ "invoice_payment_due",
842
+ "price_change_notice"
59
843
  ];
60
844
  const EmailTemplateSchema = z.enum(EMAIL_TEMPLATES);
61
- /** Which event triggers which template. Events not listed send no email. */
845
+ /** `invoice_payment_due`: summary of an unpaid invoice, its due date and the hosted pay page link. */
846
+ const InvoicePaymentDueEmailSchema = z.object({
847
+ invoiceId: IdSchema,
848
+ /** Invoice number once issued; drafts have none. */
849
+ number: z.string().nullable(),
850
+ billingReason: InvoiceBillingReasonSchema,
851
+ planName: z.string().nullable(),
852
+ totalMinor: AmountMinorSchema,
853
+ currency: CurrencySchema,
854
+ periodStart: DateTimeSchema.nullable(),
855
+ periodEnd: DateTimeSchema.nullable(),
856
+ dueAt: DateTimeSchema,
857
+ /** Steward's hosted pay page for this invoice. */
858
+ payUrl: z.url({ protocol: /^https?$/ }),
859
+ /** false: the invoice was just written; true: a reminder before the due date. */
860
+ reminder: z.boolean()
861
+ });
862
+ /** A metered (overage) rate as a notice shows it: `amountMinor` per `perUnits` units, at most `capMinor` per usage period. */
863
+ const NoticedMeteredRateSchema = z.object({
864
+ amountMinor: AmountMinorSchema,
865
+ perUnits: z.number().int().positive(),
866
+ capMinor: AmountMinorSchema.nullable()
867
+ });
868
+ /**
869
+ * One meter whose overage rate changes with the move to the current terms (metering M20, M36): the
870
+ * subscription's locked rate and the plan's current one, in the notice's currency and tax terms.
871
+ * The meter's labels as they were when the notice was written (the e-mail is never recomputed).
872
+ */
873
+ const PriceChangeNoticeMeteredRateSchema = z.object({
874
+ meter: CodeSchema,
875
+ label: MeterLabelSchema,
876
+ unitLabel: MeterLabelSchema.nullable(),
877
+ /** The meter's `displayPer` (price lines are shown per that many units); null: per the rate's `perUnits`. */
878
+ displayPer: z.number().int().positive().nullable(),
879
+ /** The locked rate; null: the meter is not billed yet (a metered price added to the plan later). */
880
+ oldRate: NoticedMeteredRateSchema.nullable(),
881
+ /** The current rate; null: the meter is no longer billed. */
882
+ newRate: NoticedMeteredRateSchema.nullable()
883
+ });
884
+ /**
885
+ * `price_change_notice`: a subscription moves to the current terms of its plan from `effectiveAt` —
886
+ * its per-period amount and/or its metered rates (`meteredRates`: only the meters whose rate
887
+ * changes; absent when none does). Old and new amount are equal when only metered rates change.
888
+ */
889
+ const PriceChangeNoticeEmailSchema = z.object({
890
+ subscriptionId: IdSchema,
891
+ planCode: CodeSchema,
892
+ planName: z.string(),
893
+ interval: IntervalSchema,
894
+ currency: CurrencySchema,
895
+ oldAmountMinor: AmountMinorSchema,
896
+ newAmountMinor: AmountMinorSchema,
897
+ /** Both amounts are VAT inclusive (true) or exclusive (false), as the price. */
898
+ taxInclusive: z.boolean(),
899
+ effectiveAt: DateTimeSchema,
900
+ meteredRates: z.array(PriceChangeNoticeMeteredRateSchema).optional()
901
+ });
902
+ /** Payload schema per billing core template. */
903
+ const EMAIL_PAYLOAD_SCHEMAS = Object.freeze({
904
+ invoice_payment_due: InvoicePaymentDueEmailSchema,
905
+ price_change_notice: PriceChangeNoticeEmailSchema
906
+ });
907
+ /**
908
+ * Which event triggers which template. Events not listed send no email. The billing core
909
+ * templates (`invoice_payment_due`, `price_change_notice`) are not triggered by an event type alone.
910
+ */
62
911
  const EMAIL_TEMPLATE_BY_EVENT = Object.freeze({
63
912
  "subscription.activated": "subscription_started",
64
913
  "subscription.renewed": "payment_receipt",
@@ -183,21 +1032,34 @@ function billingPageUrl(appUrl, billingPath, locale, accountRef) {
183
1032
  }
184
1033
  }
185
1034
  /**
1035
+ * The event's subscription among the account's live subscriptions at send time, or null when it
1036
+ * is not live. Looked up by id in `subscriptions` (billing core, M34: any live subscription, not
1037
+ * only the primary one); a state without that list (older steward) has only the primary
1038
+ * `subscription`.
1039
+ */
1040
+ function liveSubscriptionOf(state, subscriptionId) {
1041
+ if (subscriptionId === null) return null;
1042
+ if (state.subscriptions !== void 0) return state.subscriptions.find((s) => s.id === subscriptionId) ?? null;
1043
+ return state.subscription?.id === subscriptionId ? state.subscription : null;
1044
+ }
1045
+ /**
186
1046
  * Whether the email still describes the account at send time. Receipts, the welcome email
187
1047
  * (legal confirmation) and the final termination notice always go; the others only while
188
- * the situation they describe still holds.
1048
+ * the situation they describe still holds for the event's subscription (which need not be the
1049
+ * primary one when the account has several).
189
1050
  */
190
1051
  function isEmailRelevant(template, facts) {
191
- const live = facts.state.subscription;
192
- const same = live !== null && live.id === facts.subscriptionId;
1052
+ const sub = liveSubscriptionOf(facts.state, facts.subscriptionId);
193
1053
  switch (template) {
194
1054
  case "subscription_started":
195
1055
  case "payment_receipt":
196
- case "subscription_terminated": return true;
197
- case "payment_failed": return same && live.status === "past_due";
198
- case "cancel_scheduled": return same && live.cancelAtPeriodEnd;
199
- case "subscription_ended": return live === null;
200
- case "access_suspended": return same && live.status === "suspended";
1056
+ case "subscription_terminated":
1057
+ case "price_change_notice": return true;
1058
+ case "invoice_payment_due": return facts.invoicePaymentStatus === "unpaid";
1059
+ case "payment_failed": return sub?.status === "past_due";
1060
+ case "cancel_scheduled": return sub?.cancelAtPeriodEnd === true;
1061
+ case "subscription_ended": return facts.state.subscriptions !== void 0 ? sub === null : facts.state.subscription === null;
1062
+ case "access_suspended": return sub?.status === "suspended";
201
1063
  case "invoice_issued": return facts.invoiceStatus === "issued";
202
1064
  }
203
1065
  }
@@ -256,6 +1118,10 @@ const SubscriptionSummarySchema = z.object({
256
1118
  status: SubscriptionStatusSchema,
257
1119
  planCode: CodeSchema,
258
1120
  interval: IntervalSchema,
1121
+ /**
1122
+ * Amount the subscription pays per period. Billing core (M36): the subscription's LOCKED amount
1123
+ * (copied when it started); a later in-place price change does not alter it.
1124
+ */
259
1125
  amountMinor: AmountMinorSchema,
260
1126
  currency: CurrencySchema,
261
1127
  currentPeriodEnd: DateTimeSchema.nullable(),
@@ -275,11 +1141,16 @@ const AccountDunningStateSchema = z.object({
275
1141
  /** Sıradaki dunning aksiyonunun zamanı; kalmadıysa null. */
276
1142
  nextActionAt: DateTimeSchema.nullable()
277
1143
  });
278
- /** Dönem sonunda uygulanacak plan değişikliği. F4b'ye kadar her zaman null. */
1144
+ /**
1145
+ * Change applied at the period end (`POST /v1/subscriptions/:id/change`, MF9). Null until then.
1146
+ * `price: "current"` on the same plan shows the same `planCode`/`interval` with the new `amountMinor`.
1147
+ */
279
1148
  const AccountScheduledChangeSchema = z.object({
280
1149
  planCode: CodeSchema,
281
1150
  interval: IntervalSchema,
282
- effectiveAt: DateTimeSchema
1151
+ effectiveAt: DateTimeSchema,
1152
+ /** Amount locked from `effectiveAt` (billing core; absent from older steward). */
1153
+ amountMinor: AmountMinorSchema.optional()
283
1154
  });
284
1155
  const AccountSubscriptionStateSchema = SubscriptionSummarySchema.extend({
285
1156
  cancelAtPeriodEnd: z.boolean(),
@@ -288,7 +1159,10 @@ const AccountSubscriptionStateSchema = SubscriptionSummarySchema.extend({
288
1159
  currentPeriodStart: DateTimeSchema.nullable(),
289
1160
  /** Abonelik dunning'de değilse null. */
290
1161
  dunning: AccountDunningStateSchema.nullable(),
291
- /** F4b (`POST /v1/subscriptions/:id/change`) gelene kadar her zaman null. */
1162
+ /**
1163
+ * The pending plan/interval/price change (billing core MF9: `POST /v1/subscriptions/:id/change`,
1164
+ * the portal's Change plan, the operator's `reprice-subscriptions`); null when none.
1165
+ */
292
1166
  scheduledChange: AccountScheduledChangeSchema.nullable()
293
1167
  });
294
1168
  /** Hesabın açık (tamamlanmamış, süresi dolmamış) checkout oturumu. */
@@ -301,6 +1175,38 @@ const AccountOpenCheckoutSchema = z.object({
301
1175
  amountMinor: AmountMinorSchema,
302
1176
  expiresAt: DateTimeSchema
303
1177
  });
1178
+ /**
1179
+ * An unpaid invoice of the account (billing core, M22/M34): renewal or final invoices written
1180
+ * before payment. Account level (not under a subscription) so the final invoice of an ended
1181
+ * subscription stays visible; the product can show a banner before `dueAt`.
1182
+ */
1183
+ const AccountOpenInvoiceSchema = z.object({
1184
+ id: IdSchema,
1185
+ /** Null for an invoice not tied to a subscription. */
1186
+ subscriptionId: IdSchema.nullable(),
1187
+ billingReason: InvoiceBillingReasonSchema,
1188
+ totalMinor: AmountMinorSchema,
1189
+ currency: CurrencySchema,
1190
+ dueAt: DateTimeSchema
1191
+ });
1192
+ /**
1193
+ * Low-churn meter facts of the account state (metering, M13; Polar `active_meters`, coarse). One
1194
+ * entry per catalog meter. Balances and period bounds are NOT here (they change with every event;
1195
+ * read them with `GET /v1/accounts/:ref/meters`): this block changes when the plan changes, a
1196
+ * threshold is crossed or a new period resets the standing — never per event.
1197
+ */
1198
+ const AccountMeterStateSchema = z.object({
1199
+ /**
1200
+ * Units the account's `meter_credit` benefits grant per period (`meterCreditsOf`: benefits sum;
1201
+ * the default plan's credit only when no other layer credits the meter); `null`: no credit (not
1202
+ * gated by credit, M15).
1203
+ */
1204
+ includedUnits: z.number().nonnegative().nullable(),
1205
+ /** A live subscription bills overage of this meter (metered rate): the gate stays open (M15) until the rate's cap is reached. */
1206
+ billable: z.boolean(),
1207
+ standing: MeterStandingSchema
1208
+ });
1209
+ const AccountMetersStateSchema = z.record(CodeSchema, AccountMeterStateSchema);
304
1210
  /** Fatura profili özeti: yalnızca var mı ve türü (alanlar PII, burada yok). */
305
1211
  const AccountProfileStateSchema = z.object({
306
1212
  present: z.boolean(),
@@ -319,7 +1225,16 @@ const AccountStateSchema = EntitlementSnapshotSchema.extend({
319
1225
  catalogVersion: z.string().nullable(),
320
1226
  subscription: AccountSubscriptionStateSchema.nullable(),
321
1227
  openCheckout: AccountOpenCheckoutSchema.nullable(),
322
- profile: AccountProfileStateSchema
1228
+ profile: AccountProfileStateSchema,
1229
+ subscriptions: z.array(AccountSubscriptionStateSchema).optional(),
1230
+ openInvoices: z.array(AccountOpenInvoiceSchema).optional(),
1231
+ benefits: z.array(AccountBenefitSchema).optional(),
1232
+ /**
1233
+ * Metering phase 2 (M13): per catalog meter `{includedUnits, billable, standing}`. Present only
1234
+ * for a product whose catalog defines meters (the state of other products, and so its version
1235
+ * and fingerprint, stays exactly as before).
1236
+ */
1237
+ meters: AccountMetersStateSchema.optional()
323
1238
  });
324
1239
  const AccountSchema = z.object({
325
1240
  ref: ExternalRefSchema,
@@ -349,6 +1264,11 @@ const ACCOUNT_INCLUDES = [
349
1264
  const EventIdSchema = z.string().regex(/^evt_[0-9a-f-]{36}$/);
350
1265
  const ApiVersionSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
351
1266
  const sub = { subscriptionId: IdSchema };
1267
+ /** `benefit_grant.*` detail base: the grant log row id and the granted benefit (as in `AccountState.benefits[]`). */
1268
+ const benefitGrant = {
1269
+ grantId: IdSchema,
1270
+ benefit: AccountBenefitSchema
1271
+ };
352
1272
  const EVENT_DETAIL_SCHEMAS = {
353
1273
  "account.updated": z.object({
354
1274
  /** Snapshot'ın hangi alanları değişti (planCode, accessState, entitlements, subscription). */
@@ -382,7 +1302,9 @@ changed: z.array(z.string()) }),
382
1302
  paymentId: IdSchema.nullable(),
383
1303
  /** Dunning'in kaçıncı başarısız denemesi (1'den başlar). */
384
1304
  attempt: z.number().int().positive(),
385
- nextRetryAt: DateTimeSchema.nullable()
1305
+ nextRetryAt: DateTimeSchema.nullable(),
1306
+ /** Billing core (M23): the unpaid invoice past its due date that started dunning; absent on the legacy provider path. */
1307
+ invoiceId: IdSchema.optional()
386
1308
  }),
387
1309
  "subscription.suspended": z.object({
388
1310
  ...sub,
@@ -439,6 +1361,51 @@ changed: z.array(z.string()) }),
439
1361
  "account.deleted": z.object({
440
1362
  deletedAt: DateTimeSchema,
441
1363
  actorRef: z.string().nullable()
1364
+ }),
1365
+ "invoice.paid": z.object({
1366
+ invoiceId: IdSchema,
1367
+ subscriptionId: IdSchema.nullable(),
1368
+ paymentId: IdSchema,
1369
+ /** Null for an invoice written the pre-billing-core way (after a payment). */
1370
+ billingReason: InvoiceBillingReasonSchema.nullable(),
1371
+ totalMinor: AmountMinorSchema,
1372
+ currency: CurrencySchema,
1373
+ paidAt: DateTimeSchema
1374
+ }),
1375
+ "benefit_grant.created": z.object({ ...benefitGrant }),
1376
+ "benefit_grant.updated": z.object({
1377
+ ...benefitGrant,
1378
+ previousProperties: z.record(z.string(), z.unknown())
1379
+ }),
1380
+ "benefit_grant.revoked": z.object({
1381
+ ...benefitGrant,
1382
+ revokedAt: DateTimeSchema
1383
+ }),
1384
+ "benefit_grant.cycled": z.object({
1385
+ ...benefitGrant,
1386
+ periodStart: DateTimeSchema,
1387
+ periodEnd: DateTimeSchema
1388
+ }),
1389
+ "meter.threshold_crossed": z.object({
1390
+ meter: CodeSchema,
1391
+ standing: MeterStandingSchema,
1392
+ previous: MeterStandingSchema,
1393
+ periodStart: DateTimeSchema,
1394
+ periodEnd: DateTimeSchema,
1395
+ consumedUnits: z.number().nonnegative(),
1396
+ creditedUnits: z.number().nonnegative()
1397
+ }),
1398
+ "meter.period_closed": z.object({
1399
+ periodStart: DateTimeSchema,
1400
+ periodEnd: DateTimeSchema,
1401
+ subscriptionId: IdSchema.nullable(),
1402
+ meters: z.array(z.object({
1403
+ meter: CodeSchema,
1404
+ consumedUnits: z.number().nonnegative(),
1405
+ creditedUnits: z.number().nonnegative(),
1406
+ overageUnits: z.number().nonnegative()
1407
+ })).min(1),
1408
+ meterChargeId: IdSchema.nullable()
442
1409
  })
443
1410
  };
444
1411
  /** Tüm event tipleri (yenileri dahil). */
@@ -464,7 +1431,18 @@ const LEGACY_EVENT_TYPES = Object.freeze([
464
1431
  "invoice.created",
465
1432
  "invoice.issued"
466
1433
  ]);
467
- const FULL_STATE_EVENT_TYPES = /* @__PURE__ */ new Set(["account.state_changed", "account.deleted"]);
1434
+ /** Event types whose `data.account` is the full `AccountState` (the others: the entitlement snapshot). */
1435
+ const FULL_STATE_EVENT_TYPES = /* @__PURE__ */ new Set([
1436
+ "account.state_changed",
1437
+ "account.deleted",
1438
+ "invoice.paid",
1439
+ "benefit_grant.created",
1440
+ "benefit_grant.updated",
1441
+ "benefit_grant.revoked",
1442
+ "benefit_grant.cycled",
1443
+ "meter.threshold_crossed",
1444
+ "meter.period_closed"
1445
+ ]);
468
1446
  function accountSchemaOf(type) {
469
1447
  return FULL_STATE_EVENT_TYPES.has(type) ? AccountStateSchema : EntitlementSnapshotSchema;
470
1448
  }
@@ -515,4 +1493,4 @@ const PingSchema = z.object({
515
1493
  endpointId: IdSchema
516
1494
  });
517
1495
  //#endregion
518
- export { CurrencySchema as $, EMAIL_LOCALES as A, NotificationEmailsSchema as B, BillingProfileKindSchema as C, SubscriptionSummarySchema as D, SubscriptionStatusSchema as E, EmailSettingsPatchSchema as F, emailTemplateOf as G, billingPageUrl as H, EmailSettingsSchema as I, uniqueEmails as J, emailTemplateSettings as K, EmailTemplateSchema as L, EMAIL_TEMPLATE_BY_EVENT as M, EmailExtraTextSchema as N, BILLING_PATH_PLACEHOLDERS as O, EmailSenderSchema as P, CodeSchema as Q, EmailTemplateSettingsSchema as R, AccountUpsertInputSchema as S, EntitlementSnapshotSchema as T, emailLocaleOf as U, applyEmailSettingsPatch as V, emailRecipients as W, ActorRefSchema as X, API_VERSION as Y, AmountMinorSchema as Z, AccountProfileStateSchema as _, EVENT_TYPES as a, ErrorResponseSchema as at, AccountStateSchema as b, PING_EVENT_TYPE as c, IntervalSchema as ct, ACCOUNT_INCLUDES as d, DateTimeSchema as et, AccessStateSchema as f, AccountOpenCheckoutSchema as g, AccountLocaleSchema as h, EVENT_DETAIL_SCHEMAS as i, ErrorCategorySchema as it, EMAIL_TEMPLATES as j, BillingPathSchema as k, PingSchema as l, MetadataSchema as lt, AccountDunningStateSchema as m, ApiVersionSchema as n, EntitlementValueSchema as nt, EventIdSchema as o, ExternalRefSchema as ot, AccountDeleteResponseSchema as p, isEmailRelevant as q, BillingEventSchema as r, EntitlementsSchema as rt, LEGACY_EVENT_TYPES as s, IdSchema as st, AnyBillingEventSchema as t, ERROR_CATEGORIES as tt, isEventType as u, AccountScheduledChangeSchema as v, BillingProfileSchema as w, AccountSubscriptionStateSchema as x, AccountSchema as y, MAX_NOTIFICATION_EMAILS as z };
1496
+ export { emailRecipients as $, canonicalMeterFilter as $t, SubscriptionStatusSchema as A, isEventTimestampInRange as An, METER_FILTER_MAX_DEPTH as At, EmailSettingsPatchSchema as B, ErrorCategorySchema as Bn, MeterCreditEntrySchema as Bt, AccountSchema as C, EventsIngestInputSchema as Cn, DecimalUnitsStringSchema as Ct, BillingProfileKindSchema as D, IngestRejectCodeSchema as Dn, METER_CHARGE_STATUSES as Dt, AccountUpsertInputSchema as E, IngestEventSchema as En, METER_AGGREGATION_KINDS as Et, EMAIL_PAYLOAD_SCHEMAS as F, CurrencySchema as Fn, MeterChargeLineSchema as Ft, MAX_NOTIFICATION_EMAILS as G, IdSchema as Gn, MeterLowAtSchema as Gt, EmailTemplateSchema as H, ExternalRefSchema as Hn, MeterDefSchema as Ht, EMAIL_TEMPLATES as I, DateTimeSchema as In, MeterChargeListSchema as It, PriceChangeNoticeEmailSchema as J, InvoicePaymentStatusSchema as Jn, MeterPeriodStatusSchema as Jt, NoticedMeteredRateSchema as K, IntervalSchema as Kn, MeterPeriodListSchema as Kt, EMAIL_TEMPLATE_BY_EVENT as L, ERROR_CATEGORIES as Ln, MeterChargeSchema as Lt, BILLING_PATH_PLACEHOLDERS as M, ActorRefSchema as Mn, METER_PERIOD_STATUSES as Mt, BillingPathSchema as N, AmountMinorSchema as Nn, METER_STANDINGS as Nt, BillingProfileSchema as O, IngestRejectionSchema as On, METER_CREDIT_ENTRY_KINDS as Ot, EMAIL_LOCALES as P, CodeSchema as Pn, MeterAggregationSchema as Pt, emailLocaleOf as Q, MeteredPriceSchema as Qt, EmailExtraTextSchema as R, EntitlementValueSchema as Rn, MeterChargeStatusSchema as Rt, AccountScheduledChangeSchema as S, EventNameSchema as Sn, DEFAULT_METER_LOW_AT as St, AccountSubscriptionStateSchema as T, INGEST_REJECT_CODES as Tn, MAX_METER_UNITS as Tt, EmailTemplateSettingsSchema as U, INVOICE_BILLING_REASONS as Un, MeterFilterSchema as Ut, EmailSettingsSchema as V, ErrorResponseSchema as Vn, MeterCreditPropertiesSchema as Vt, InvoicePaymentDueEmailSchema as W, INVOICE_PAYMENT_STATUSES as Wn, MeterLabelSchema as Wt, applyEmailSettingsPatch as X, MeterUnitsSchema as Xt, PriceChangeNoticeMeteredRateSchema as Y, MetadataSchema as Yn, MeterStandingSchema as Yt, billingPageUrl as Z, MeteredPriceDefSchema as Zt, AccountMeterStateSchema as _, EVENT_TIMESTAMP_MAX_SKEW_MINUTES as _n, isMeterCreditBenefit as _t, EVENT_TYPES as a, isMicroPrecise as an, AccountBenefitSchema as at, AccountOpenInvoiceSchema as b, EventMetadataSchema as bn, CatalogMeterSchema as bt, LEGACY_EVENT_TYPES as c, plainDecimalOf as cn, BenefitDescriptionSchema as ct, isEventType as d, EVENTS_INGEST_MAX_EVENTS as dn, CatalogBenefitSchema as dt, meterFilterClauseOf as en, emailTemplateOf as et, ACCOUNT_INCLUDES as f, EVENT_METADATA_KEY_MAX_LENGTH as fn, FeatureFlagBenefitDefSchema as ft, AccountLocaleSchema as g, EVENT_TIMESTAMP_MAX_AGE_DAYS as gn, isFeatureFlagBenefit as gt, AccountDunningStateSchema as h, EVENT_NAME_MAX_LENGTH as hn, featureValueMatchesType as ht, EVENT_DETAIL_SCHEMAS as i, fromMicroUnits as in, uniqueEmails as it, SubscriptionSummarySchema as j, API_VERSION as jn, METER_FILTER_OPERATORS as jt, EntitlementSnapshotSchema as k, hasControlCharacters as kn, METER_FILTER_MAX_CONDITIONS as kt, PING_EVENT_TYPE as l, toMicroUnits as ln, BenefitSourceSchema as lt, AccountDeleteResponseSchema as m, EVENT_METADATA_STRING_MAX_LENGTH as mn, MeterCreditBenefitDefSchema as mt, ApiVersionSchema as n, MICRO_UNITS_PER_UNIT as nn, isEmailRelevant as nt, EventIdSchema as o, microToDecimalString as on, BENEFIT_TYPES as ot, AccessStateSchema as p, EVENT_METADATA_MAX_PAIRS as pn, FeatureFlagPropertiesSchema as pt, NotificationEmailsSchema as q, InvoiceBillingReasonSchema as qn, MeterPeriodSchema as qt, BillingEventSchema as r, UNIT_DECIMALS as rn, liveSubscriptionOf as rt, FULL_STATE_EVENT_TYPES as s, parseMicroUnits as sn, BenefitDefSchema as st, AnyBillingEventSchema as t, InvalidUnitValueError as tn, emailTemplateSettings as tt, PingSchema as u, EVENTS_INGEST_MAX_BYTES as un, BenefitTypeSchema as ut, AccountMetersStateSchema as v, EventExternalIdSchema as vn, AccountMeterSchema as vt, AccountStateSchema as w, EventsIngestResultSchema as wn, MAX_METERED_PRICE_PER_UNITS as wt, AccountProfileStateSchema as x, EventMetadataValueSchema as xn, CreditGrantInputSchema as xt, AccountOpenCheckoutSchema as y, EventMetadataKeySchema as yn, AccountMetersSchema as yt, EmailSenderSchema as z, EntitlementsSchema as zn, MeterCreditEntryKindSchema as zt };