@stewardhq/sdk 0.3.0 → 0.6.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 (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +332 -273
  3. package/dist/_chunks/errors.js +1 -1
  4. package/dist/_chunks/events.d.ts +386 -78
  5. package/dist/_chunks/events.js +1035 -19
  6. package/dist/_chunks/index.d.ts +3752 -305
  7. package/dist/_chunks/locale.d.ts +270 -2
  8. package/dist/_chunks/src.js +925 -31
  9. package/dist/_chunks/validators.d.ts +46 -2
  10. package/dist/_chunks/validators.js +128 -27
  11. package/dist/_chunks/webhook-core.d.ts +2 -2
  12. package/dist/contract.d.ts +4 -4
  13. package/dist/contract.js +4 -4
  14. package/dist/index.d.ts +447 -33
  15. package/dist/index.js +1659 -26
  16. package/dist/server.d.ts +66 -16
  17. package/dist/server.js +35 -4
  18. package/package.json +14 -13
  19. package/dist/_chunks/steward.d.ts +0 -189
  20. package/dist/_chunks/steward.js +0 -564
  21. package/dist/testing/fixtures/events/LOCK.json +0 -27
  22. package/dist/testing/fixtures/events/account.deleted.json +0 -36
  23. package/dist/testing/fixtures/events/account.state_changed.json +0 -53
  24. package/dist/testing/fixtures/events/account.updated.json +0 -37
  25. package/dist/testing/fixtures/events/checkout.completed.json +0 -36
  26. package/dist/testing/fixtures/events/checkout.expired.json +0 -26
  27. package/dist/testing/fixtures/events/checkout.failed.json +0 -27
  28. package/dist/testing/fixtures/events/invoice.created.json +0 -37
  29. package/dist/testing/fixtures/events/invoice.issued.json +0 -38
  30. package/dist/testing/fixtures/events/invoice.voided.json +0 -39
  31. package/dist/testing/fixtures/events/subscription.activated.json +0 -40
  32. package/dist/testing/fixtures/events/subscription.cancel_scheduled.json +0 -38
  33. package/dist/testing/fixtures/events/subscription.canceled.json +0 -29
  34. package/dist/testing/fixtures/events/subscription.expired.json +0 -27
  35. package/dist/testing/fixtures/events/subscription.payment_failed.json +0 -38
  36. package/dist/testing/fixtures/events/subscription.reactivated.json +0 -36
  37. package/dist/testing/fixtures/events/subscription.renewed.json +0 -39
  38. package/dist/testing/fixtures/events/subscription.suspended.json +0 -36
  39. package/dist/testing/fixtures/events/subscription.terminated.json +0 -28
  40. package/dist/testing.d.ts +0 -656
  41. 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
+ for (const [i, member] of members.entries()) 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,97 @@ 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",
843
+ "bank_transfer_instructions"
59
844
  ];
60
845
  const EmailTemplateSchema = z.enum(EMAIL_TEMPLATES);
61
- /** Which event triggers which template. Events not listed send no email. */
846
+ /** `invoice_payment_due`: summary of an unpaid invoice, its due date and the hosted pay page link. */
847
+ const InvoicePaymentDueEmailSchema = z.object({
848
+ invoiceId: IdSchema,
849
+ /** Invoice number once issued; drafts have none. */
850
+ number: z.string().nullable(),
851
+ billingReason: InvoiceBillingReasonSchema,
852
+ planName: z.string().nullable(),
853
+ totalMinor: AmountMinorSchema,
854
+ currency: CurrencySchema,
855
+ periodStart: DateTimeSchema.nullable(),
856
+ periodEnd: DateTimeSchema.nullable(),
857
+ dueAt: DateTimeSchema,
858
+ /** Steward's hosted pay page for this invoice. */
859
+ payUrl: z.url({ protocol: /^https?$/ }),
860
+ /** false: the invoice was just written; true: a reminder before the due date. */
861
+ reminder: z.boolean()
862
+ });
863
+ /** A metered (overage) rate as a notice shows it: `amountMinor` per `perUnits` units, at most `capMinor` per usage period. */
864
+ const NoticedMeteredRateSchema = z.object({
865
+ amountMinor: AmountMinorSchema,
866
+ perUnits: z.number().int().positive(),
867
+ capMinor: AmountMinorSchema.nullable()
868
+ });
869
+ /**
870
+ * One meter whose overage rate changes with the move to the current terms (metering M20, M36): the
871
+ * subscription's locked rate and the plan's current one, in the notice's currency and tax terms.
872
+ * The meter's labels as they were when the notice was written (the e-mail is never recomputed).
873
+ */
874
+ const PriceChangeNoticeMeteredRateSchema = z.object({
875
+ meter: CodeSchema,
876
+ label: MeterLabelSchema,
877
+ unitLabel: MeterLabelSchema.nullable(),
878
+ /** The meter's `displayPer` (price lines are shown per that many units); null: per the rate's `perUnits`. */
879
+ displayPer: z.number().int().positive().nullable(),
880
+ /** The locked rate; null: the meter is not billed yet (a metered price added to the plan later). */
881
+ oldRate: NoticedMeteredRateSchema.nullable(),
882
+ /** The current rate; null: the meter is no longer billed. */
883
+ newRate: NoticedMeteredRateSchema.nullable()
884
+ });
885
+ /**
886
+ * `price_change_notice`: a subscription moves to the current terms of its plan from `effectiveAt` —
887
+ * its per-period amount and/or its metered rates (`meteredRates`: only the meters whose rate
888
+ * changes; absent when none does). Old and new amount are equal when only metered rates change.
889
+ */
890
+ const PriceChangeNoticeEmailSchema = z.object({
891
+ subscriptionId: IdSchema,
892
+ planCode: CodeSchema,
893
+ planName: z.string(),
894
+ interval: IntervalSchema,
895
+ currency: CurrencySchema,
896
+ oldAmountMinor: AmountMinorSchema,
897
+ newAmountMinor: AmountMinorSchema,
898
+ /** Both amounts are VAT inclusive (true) or exclusive (false), as the price. */
899
+ taxInclusive: z.boolean(),
900
+ effectiveAt: DateTimeSchema,
901
+ meteredRates: z.array(PriceChangeNoticeMeteredRateSchema).optional()
902
+ });
903
+ /**
904
+ * `bank_transfer_instructions` (bank transfer plan H12): how to pay a checkout by bank transfer —
905
+ * the deployment's bank account for the currency, the exact amount, the account's order code to
906
+ * write in the description and the deadline. `reminder`: sent the day before the deadline.
907
+ */
908
+ const BankTransferInstructionsEmailSchema = z.object({
909
+ checkoutSessionId: IdSchema,
910
+ planName: z.string(),
911
+ totalMinor: AmountMinorSchema,
912
+ currency: CurrencySchema,
913
+ beneficiary: z.string(),
914
+ bank: z.string().nullable(),
915
+ iban: z.string(),
916
+ /** The account's order code (`XXXX-XXXX-X`). */
917
+ code: z.string(),
918
+ deadline: DateTimeSchema,
919
+ reminder: z.boolean()
920
+ });
921
+ /** Payload schema per billing core template. */
922
+ const EMAIL_PAYLOAD_SCHEMAS = Object.freeze({
923
+ bank_transfer_instructions: BankTransferInstructionsEmailSchema,
924
+ invoice_payment_due: InvoicePaymentDueEmailSchema,
925
+ price_change_notice: PriceChangeNoticeEmailSchema
926
+ });
927
+ /**
928
+ * Which event triggers which template. Events not listed send no email. The billing core
929
+ * templates (`invoice_payment_due`, `price_change_notice`) are not triggered by an event type alone.
930
+ */
62
931
  const EMAIL_TEMPLATE_BY_EVENT = Object.freeze({
63
932
  "subscription.activated": "subscription_started",
64
933
  "subscription.renewed": "payment_receipt",
@@ -183,21 +1052,35 @@ function billingPageUrl(appUrl, billingPath, locale, accountRef) {
183
1052
  }
184
1053
  }
185
1054
  /**
1055
+ * The event's subscription among the account's live subscriptions at send time, or null when it
1056
+ * is not live. Looked up by id in `subscriptions` (billing core, M34: any live subscription, not
1057
+ * only the primary one); a state without that list (older steward) has only the primary
1058
+ * `subscription`.
1059
+ */
1060
+ function liveSubscriptionOf(state, subscriptionId) {
1061
+ if (subscriptionId === null) return null;
1062
+ if (state.subscriptions !== void 0) return state.subscriptions.find((s) => s.id === subscriptionId) ?? null;
1063
+ return state.subscription?.id === subscriptionId ? state.subscription : null;
1064
+ }
1065
+ /**
186
1066
  * Whether the email still describes the account at send time. Receipts, the welcome email
187
1067
  * (legal confirmation) and the final termination notice always go; the others only while
188
- * the situation they describe still holds.
1068
+ * the situation they describe still holds for the event's subscription (which need not be the
1069
+ * primary one when the account has several).
189
1070
  */
190
1071
  function isEmailRelevant(template, facts) {
191
- const live = facts.state.subscription;
192
- const same = live !== null && live.id === facts.subscriptionId;
1072
+ const sub = liveSubscriptionOf(facts.state, facts.subscriptionId);
193
1073
  switch (template) {
194
1074
  case "subscription_started":
195
1075
  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";
1076
+ case "subscription_terminated":
1077
+ case "price_change_notice": return true;
1078
+ case "invoice_payment_due": return facts.invoicePaymentStatus === "unpaid";
1079
+ case "bank_transfer_instructions": return facts.transferPending === true;
1080
+ case "payment_failed": return sub?.status === "past_due";
1081
+ case "cancel_scheduled": return sub?.cancelAtPeriodEnd === true;
1082
+ case "subscription_ended": return facts.state.subscriptions !== void 0 ? sub === null : facts.state.subscription === null;
1083
+ case "access_suspended": return sub?.status === "suspended";
201
1084
  case "invoice_issued": return facts.invoiceStatus === "issued";
202
1085
  }
203
1086
  }
@@ -256,6 +1139,10 @@ const SubscriptionSummarySchema = z.object({
256
1139
  status: SubscriptionStatusSchema,
257
1140
  planCode: CodeSchema,
258
1141
  interval: IntervalSchema,
1142
+ /**
1143
+ * Amount the subscription pays per period. Billing core (M36): the subscription's LOCKED amount
1144
+ * (copied when it started); a later in-place price change does not alter it.
1145
+ */
259
1146
  amountMinor: AmountMinorSchema,
260
1147
  currency: CurrencySchema,
261
1148
  currentPeriodEnd: DateTimeSchema.nullable(),
@@ -275,11 +1162,16 @@ const AccountDunningStateSchema = z.object({
275
1162
  /** Sıradaki dunning aksiyonunun zamanı; kalmadıysa null. */
276
1163
  nextActionAt: DateTimeSchema.nullable()
277
1164
  });
278
- /** Dönem sonunda uygulanacak plan değişikliği. F4b'ye kadar her zaman null. */
1165
+ /**
1166
+ * Change applied at the period end (`POST /v1/subscriptions/:id/change`, MF9). Null until then.
1167
+ * `price: "current"` on the same plan shows the same `planCode`/`interval` with the new `amountMinor`.
1168
+ */
279
1169
  const AccountScheduledChangeSchema = z.object({
280
1170
  planCode: CodeSchema,
281
1171
  interval: IntervalSchema,
282
- effectiveAt: DateTimeSchema
1172
+ effectiveAt: DateTimeSchema,
1173
+ /** Amount locked from `effectiveAt` (billing core; absent from older steward). */
1174
+ amountMinor: AmountMinorSchema.optional()
283
1175
  });
284
1176
  const AccountSubscriptionStateSchema = SubscriptionSummarySchema.extend({
285
1177
  cancelAtPeriodEnd: z.boolean(),
@@ -288,7 +1180,10 @@ const AccountSubscriptionStateSchema = SubscriptionSummarySchema.extend({
288
1180
  currentPeriodStart: DateTimeSchema.nullable(),
289
1181
  /** Abonelik dunning'de değilse null. */
290
1182
  dunning: AccountDunningStateSchema.nullable(),
291
- /** F4b (`POST /v1/subscriptions/:id/change`) gelene kadar her zaman null. */
1183
+ /**
1184
+ * The pending plan/interval/price change (billing core MF9: `POST /v1/subscriptions/:id/change`,
1185
+ * the portal's Change plan, the operator's `reprice-subscriptions`); null when none.
1186
+ */
292
1187
  scheduledChange: AccountScheduledChangeSchema.nullable()
293
1188
  });
294
1189
  /** Hesabın açık (tamamlanmamış, süresi dolmamış) checkout oturumu. */
@@ -299,8 +1194,46 @@ const AccountOpenCheckoutSchema = z.object({
299
1194
  intervalCount: z.number().int().min(1),
300
1195
  currency: CurrencySchema,
301
1196
  amountMinor: AmountMinorSchema,
302
- expiresAt: DateTimeSchema
1197
+ expiresAt: DateTimeSchema,
1198
+ /**
1199
+ * Bank transfer plan (H6): `bank_transfer` when the session waits for the customer's transfer
1200
+ * until `expiresAt` (the product shows "payment pending" with `AccountState.bankTransferCode`).
1201
+ * Absent otherwise (a card checkout's state is unchanged).
1202
+ */
1203
+ paymentMethod: z.enum(["card", "bank_transfer"]).optional()
1204
+ });
1205
+ /**
1206
+ * An unpaid invoice of the account (billing core, M22/M34): renewal or final invoices written
1207
+ * before payment. Account level (not under a subscription) so the final invoice of an ended
1208
+ * subscription stays visible; the product can show a banner before `dueAt`.
1209
+ */
1210
+ const AccountOpenInvoiceSchema = z.object({
1211
+ id: IdSchema,
1212
+ /** Null for an invoice not tied to a subscription. */
1213
+ subscriptionId: IdSchema.nullable(),
1214
+ billingReason: InvoiceBillingReasonSchema,
1215
+ totalMinor: AmountMinorSchema,
1216
+ currency: CurrencySchema,
1217
+ dueAt: DateTimeSchema
303
1218
  });
1219
+ /**
1220
+ * Low-churn meter facts of the account state (metering, M13; Polar `active_meters`, coarse). One
1221
+ * entry per catalog meter. Balances and period bounds are NOT here (they change with every event;
1222
+ * read them with `GET /v1/accounts/:ref/meters`): this block changes when the plan changes, a
1223
+ * threshold is crossed or a new period resets the standing — never per event.
1224
+ */
1225
+ const AccountMeterStateSchema = z.object({
1226
+ /**
1227
+ * Units the account's `meter_credit` benefits grant per period (`meterCreditsOf`: benefits sum;
1228
+ * the default plan's credit only when no other layer credits the meter); `null`: no credit (not
1229
+ * gated by credit, M15).
1230
+ */
1231
+ includedUnits: z.number().nonnegative().nullable(),
1232
+ /** A live subscription bills overage of this meter (metered rate): the gate stays open (M15) until the rate's cap is reached. */
1233
+ billable: z.boolean(),
1234
+ standing: MeterStandingSchema
1235
+ });
1236
+ const AccountMetersStateSchema = z.record(CodeSchema, AccountMeterStateSchema);
304
1237
  /** Fatura profili özeti: yalnızca var mı ve türü (alanlar PII, burada yok). */
305
1238
  const AccountProfileStateSchema = z.object({
306
1239
  present: z.boolean(),
@@ -319,7 +1252,21 @@ const AccountStateSchema = EntitlementSnapshotSchema.extend({
319
1252
  catalogVersion: z.string().nullable(),
320
1253
  subscription: AccountSubscriptionStateSchema.nullable(),
321
1254
  openCheckout: AccountOpenCheckoutSchema.nullable(),
322
- profile: AccountProfileStateSchema
1255
+ profile: AccountProfileStateSchema,
1256
+ subscriptions: z.array(AccountSubscriptionStateSchema).optional(),
1257
+ openInvoices: z.array(AccountOpenInvoiceSchema).optional(),
1258
+ benefits: z.array(AccountBenefitSchema).optional(),
1259
+ /**
1260
+ * Metering phase 2 (M13): per catalog meter `{includedUnits, billable, standing}`. Present only
1261
+ * for a product whose catalog defines meters (the state of other products, and so its version
1262
+ * and fingerprint, stays exactly as before).
1263
+ */
1264
+ meters: AccountMetersStateSchema.optional(),
1265
+ /**
1266
+ * Bank transfer plan (H4, H6): the account's order code the customer writes in the transfer
1267
+ * description (`XXXX-XXXX-X`); present once the account chose bank transfer, then permanent.
1268
+ */
1269
+ bankTransferCode: z.string().optional()
323
1270
  });
324
1271
  const AccountSchema = z.object({
325
1272
  ref: ExternalRefSchema,
@@ -349,6 +1296,11 @@ const ACCOUNT_INCLUDES = [
349
1296
  const EventIdSchema = z.string().regex(/^evt_[0-9a-f-]{36}$/);
350
1297
  const ApiVersionSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
351
1298
  const sub = { subscriptionId: IdSchema };
1299
+ /** `benefit_grant.*` detail base: the grant log row id and the granted benefit (as in `AccountState.benefits[]`). */
1300
+ const benefitGrant = {
1301
+ grantId: IdSchema,
1302
+ benefit: AccountBenefitSchema
1303
+ };
352
1304
  const EVENT_DETAIL_SCHEMAS = {
353
1305
  "account.updated": z.object({
354
1306
  /** Snapshot'ın hangi alanları değişti (planCode, accessState, entitlements, subscription). */
@@ -368,7 +1320,13 @@ changed: z.array(z.string()) }),
368
1320
  interval: IntervalSchema,
369
1321
  amountMinor: AmountMinorSchema,
370
1322
  currency: CurrencySchema,
371
- actorRef: z.string().nullable()
1323
+ actorRef: z.string().nullable(),
1324
+ /**
1325
+ * Upgrade (bank transfer plan H16): the subscription this one replaced — canceled immediately in
1326
+ * the same transaction (`cancelReason` `replaced_by_checkout`, no `subscription.canceled` of its
1327
+ * own). Absent when the checkout replaced nothing.
1328
+ */
1329
+ replacedSubscriptionId: IdSchema.optional()
372
1330
  }),
373
1331
  "subscription.renewed": z.object({
374
1332
  ...sub,
@@ -382,7 +1340,9 @@ changed: z.array(z.string()) }),
382
1340
  paymentId: IdSchema.nullable(),
383
1341
  /** Dunning'in kaçıncı başarısız denemesi (1'den başlar). */
384
1342
  attempt: z.number().int().positive(),
385
- nextRetryAt: DateTimeSchema.nullable()
1343
+ nextRetryAt: DateTimeSchema.nullable(),
1344
+ /** Billing core (M23): the unpaid invoice past its due date that started dunning; absent on the legacy provider path. */
1345
+ invoiceId: IdSchema.optional()
386
1346
  }),
387
1347
  "subscription.suspended": z.object({
388
1348
  ...sub,
@@ -439,6 +1399,51 @@ changed: z.array(z.string()) }),
439
1399
  "account.deleted": z.object({
440
1400
  deletedAt: DateTimeSchema,
441
1401
  actorRef: z.string().nullable()
1402
+ }),
1403
+ "invoice.paid": z.object({
1404
+ invoiceId: IdSchema,
1405
+ subscriptionId: IdSchema.nullable(),
1406
+ paymentId: IdSchema,
1407
+ /** Null for an invoice written the pre-billing-core way (after a payment). */
1408
+ billingReason: InvoiceBillingReasonSchema.nullable(),
1409
+ totalMinor: AmountMinorSchema,
1410
+ currency: CurrencySchema,
1411
+ paidAt: DateTimeSchema
1412
+ }),
1413
+ "benefit_grant.created": z.object({ ...benefitGrant }),
1414
+ "benefit_grant.updated": z.object({
1415
+ ...benefitGrant,
1416
+ previousProperties: z.record(z.string(), z.unknown())
1417
+ }),
1418
+ "benefit_grant.revoked": z.object({
1419
+ ...benefitGrant,
1420
+ revokedAt: DateTimeSchema
1421
+ }),
1422
+ "benefit_grant.cycled": z.object({
1423
+ ...benefitGrant,
1424
+ periodStart: DateTimeSchema,
1425
+ periodEnd: DateTimeSchema
1426
+ }),
1427
+ "meter.threshold_crossed": z.object({
1428
+ meter: CodeSchema,
1429
+ standing: MeterStandingSchema,
1430
+ previous: MeterStandingSchema,
1431
+ periodStart: DateTimeSchema,
1432
+ periodEnd: DateTimeSchema,
1433
+ consumedUnits: z.number().nonnegative(),
1434
+ creditedUnits: z.number().nonnegative()
1435
+ }),
1436
+ "meter.period_closed": z.object({
1437
+ periodStart: DateTimeSchema,
1438
+ periodEnd: DateTimeSchema,
1439
+ subscriptionId: IdSchema.nullable(),
1440
+ meters: z.array(z.object({
1441
+ meter: CodeSchema,
1442
+ consumedUnits: z.number().nonnegative(),
1443
+ creditedUnits: z.number().nonnegative(),
1444
+ overageUnits: z.number().nonnegative()
1445
+ })).min(1),
1446
+ meterChargeId: IdSchema.nullable()
442
1447
  })
443
1448
  };
444
1449
  /** Tüm event tipleri (yenileri dahil). */
@@ -464,7 +1469,18 @@ const LEGACY_EVENT_TYPES = Object.freeze([
464
1469
  "invoice.created",
465
1470
  "invoice.issued"
466
1471
  ]);
467
- const FULL_STATE_EVENT_TYPES = /* @__PURE__ */ new Set(["account.state_changed", "account.deleted"]);
1472
+ /** Event types whose `data.account` is the full `AccountState` (the others: the entitlement snapshot). */
1473
+ const FULL_STATE_EVENT_TYPES = /* @__PURE__ */ new Set([
1474
+ "account.state_changed",
1475
+ "account.deleted",
1476
+ "invoice.paid",
1477
+ "benefit_grant.created",
1478
+ "benefit_grant.updated",
1479
+ "benefit_grant.revoked",
1480
+ "benefit_grant.cycled",
1481
+ "meter.threshold_crossed",
1482
+ "meter.period_closed"
1483
+ ]);
468
1484
  function accountSchemaOf(type) {
469
1485
  return FULL_STATE_EVENT_TYPES.has(type) ? AccountStateSchema : EntitlementSnapshotSchema;
470
1486
  }
@@ -515,4 +1531,4 @@ const PingSchema = z.object({
515
1531
  endpointId: IdSchema
516
1532
  });
517
1533
  //#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 };
1534
+ export { emailLocaleOf as $, MeteredPriceSchema as $t, SubscriptionStatusSchema as A, hasControlCharacters as An, METER_FILTER_MAX_CONDITIONS as At, EmailSenderSchema as B, EntitlementsSchema as Bn, MeterCreditEntryKindSchema as Bt, AccountSchema as C, EventNameSchema as Cn, DEFAULT_METER_LOW_AT as Ct, BillingProfileKindSchema as D, IngestEventSchema as Dn, METER_AGGREGATION_KINDS as Dt, AccountUpsertInputSchema as E, INGEST_REJECT_CODES as En, MAX_METER_UNITS as Et, EMAIL_LOCALES as F, CodeSchema as Fn, MeterAggregationSchema as Ft, InvoicePaymentDueEmailSchema as G, INVOICE_PAYMENT_STATUSES as Gn, MeterLabelSchema as Gt, EmailSettingsSchema as H, ErrorResponseSchema as Hn, MeterCreditPropertiesSchema as Ht, EMAIL_PAYLOAD_SCHEMAS as I, CurrencySchema as In, MeterChargeLineSchema as It, NotificationEmailsSchema as J, InvoiceBillingReasonSchema as Jn, MeterPeriodSchema as Jt, MAX_NOTIFICATION_EMAILS as K, IdSchema as Kn, MeterLowAtSchema as Kt, EMAIL_TEMPLATES as L, DateTimeSchema as Ln, MeterChargeListSchema as Lt, BILLING_PATH_PLACEHOLDERS as M, API_VERSION as Mn, METER_FILTER_OPERATORS as Mt, BankTransferInstructionsEmailSchema as N, ActorRefSchema as Nn, METER_PERIOD_STATUSES as Nt, BillingProfileSchema as O, IngestRejectCodeSchema as On, METER_CHARGE_STATUSES as Ot, BillingPathSchema as P, AmountMinorSchema as Pn, METER_STANDINGS as Pt, billingPageUrl as Q, MeteredPriceDefSchema as Qt, EMAIL_TEMPLATE_BY_EVENT as R, ERROR_CATEGORIES as Rn, MeterChargeSchema as Rt, AccountScheduledChangeSchema as S, EventMetadataValueSchema as Sn, CreditGrantInputSchema as St, AccountSubscriptionStateSchema as T, EventsIngestResultSchema as Tn, MAX_METERED_PRICE_PER_UNITS as Tt, EmailTemplateSchema as U, ExternalRefSchema as Un, MeterDefSchema as Ut, EmailSettingsPatchSchema as V, ErrorCategorySchema as Vn, MeterCreditEntrySchema as Vt, EmailTemplateSettingsSchema as W, INVOICE_BILLING_REASONS as Wn, MeterFilterSchema as Wt, PriceChangeNoticeMeteredRateSchema as X, MetadataSchema as Xn, MeterStandingSchema as Xt, PriceChangeNoticeEmailSchema as Y, InvoicePaymentStatusSchema as Yn, MeterPeriodStatusSchema as Yt, applyEmailSettingsPatch as Z, MeterUnitsSchema as Zt, AccountMeterStateSchema as _, EVENT_TIMESTAMP_MAX_AGE_DAYS as _n, isFeatureFlagBenefit as _t, EVENT_TYPES as a, fromMicroUnits as an, uniqueEmails as at, AccountOpenInvoiceSchema as b, EventMetadataKeySchema as bn, AccountMetersSchema as bt, LEGACY_EVENT_TYPES as c, parseMicroUnits as cn, BenefitDefSchema as ct, isEventType as d, EVENTS_INGEST_MAX_BYTES as dn, BenefitTypeSchema as dt, canonicalMeterFilter as en, emailRecipients as et, ACCOUNT_INCLUDES as f, EVENTS_INGEST_MAX_EVENTS as fn, CatalogBenefitSchema as ft, AccountLocaleSchema as g, EVENT_NAME_MAX_LENGTH as gn, featureValueMatchesType as gt, AccountDunningStateSchema as h, EVENT_METADATA_STRING_MAX_LENGTH as hn, MeterCreditBenefitDefSchema as ht, EVENT_DETAIL_SCHEMAS as i, UNIT_DECIMALS as in, liveSubscriptionOf as it, SubscriptionSummarySchema as j, isEventTimestampInRange as jn, METER_FILTER_MAX_DEPTH as jt, EntitlementSnapshotSchema as k, IngestRejectionSchema as kn, METER_CREDIT_ENTRY_KINDS as kt, PING_EVENT_TYPE as l, plainDecimalOf as ln, BenefitDescriptionSchema as lt, AccountDeleteResponseSchema as m, EVENT_METADATA_MAX_PAIRS as mn, FeatureFlagPropertiesSchema as mt, ApiVersionSchema as n, InvalidUnitValueError as nn, emailTemplateSettings as nt, EventIdSchema as o, isMicroPrecise as on, AccountBenefitSchema as ot, AccessStateSchema as p, EVENT_METADATA_KEY_MAX_LENGTH as pn, FeatureFlagBenefitDefSchema as pt, NoticedMeteredRateSchema as q, IntervalSchema as qn, MeterPeriodListSchema as qt, BillingEventSchema as r, MICRO_UNITS_PER_UNIT as rn, isEmailRelevant as rt, FULL_STATE_EVENT_TYPES as s, microToDecimalString as sn, BENEFIT_TYPES as st, AnyBillingEventSchema as t, meterFilterClauseOf as tn, emailTemplateOf as tt, PingSchema as u, toMicroUnits as un, BenefitSourceSchema as ut, AccountMetersStateSchema as v, EVENT_TIMESTAMP_MAX_SKEW_MINUTES as vn, isMeterCreditBenefit as vt, AccountStateSchema as w, EventsIngestInputSchema as wn, DecimalUnitsStringSchema as wt, AccountProfileStateSchema as x, EventMetadataSchema as xn, CatalogMeterSchema as xt, AccountOpenCheckoutSchema as y, EventExternalIdSchema as yn, AccountMeterSchema as yt, EmailExtraTextSchema as z, EntitlementValueSchema as zn, MeterChargeStatusSchema as zt };