@stewardhq/sdk 0.2.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.
- package/README.md +123 -88
- package/dist/_chunks/errors.js +1 -1
- package/dist/_chunks/events.d.ts +315 -4
- package/dist/_chunks/events.js +1155 -9
- package/dist/_chunks/index.d.ts +3473 -90
- package/dist/_chunks/locale.d.ts +269 -1
- package/dist/_chunks/src.js +842 -24
- package/dist/_chunks/validators.d.ts +45 -1
- package/dist/_chunks/validators.js +128 -27
- package/dist/contract.d.ts +4 -4
- package/dist/contract.js +4 -4
- package/dist/index.d.ts +436 -22
- package/dist/index.js +1659 -26
- package/dist/server.d.ts +44 -6
- package/dist/server.js +29 -2
- package/package.json +4 -10
- package/dist/_chunks/steward.d.ts +0 -189
- package/dist/_chunks/steward.js +0 -560
- package/dist/testing/fixtures/events/LOCK.json +0 -27
- package/dist/testing/fixtures/events/account.deleted.json +0 -36
- package/dist/testing/fixtures/events/account.state_changed.json +0 -53
- package/dist/testing/fixtures/events/account.updated.json +0 -37
- package/dist/testing/fixtures/events/checkout.completed.json +0 -36
- package/dist/testing/fixtures/events/checkout.expired.json +0 -26
- package/dist/testing/fixtures/events/checkout.failed.json +0 -27
- package/dist/testing/fixtures/events/invoice.created.json +0 -37
- package/dist/testing/fixtures/events/invoice.issued.json +0 -38
- package/dist/testing/fixtures/events/invoice.voided.json +0 -39
- package/dist/testing/fixtures/events/subscription.activated.json +0 -40
- package/dist/testing/fixtures/events/subscription.cancel_scheduled.json +0 -38
- package/dist/testing/fixtures/events/subscription.canceled.json +0 -29
- package/dist/testing/fixtures/events/subscription.expired.json +0 -27
- package/dist/testing/fixtures/events/subscription.payment_failed.json +0 -38
- package/dist/testing/fixtures/events/subscription.reactivated.json +0 -36
- package/dist/testing/fixtures/events/subscription.renewed.json +0 -39
- package/dist/testing/fixtures/events/subscription.suspended.json +0 -36
- package/dist/testing/fixtures/events/subscription.terminated.json +0 -28
- package/dist/testing.d.ts +0 -626
- package/dist/testing.js +0 -3638
package/dist/_chunks/events.js
CHANGED
|
@@ -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,10 +71,1011 @@ 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
|
|
831
|
+
//#region ../contract/src/emails.ts
|
|
832
|
+
const EMAIL_TEMPLATES = [
|
|
833
|
+
"subscription_started",
|
|
834
|
+
"payment_receipt",
|
|
835
|
+
"payment_failed",
|
|
836
|
+
"cancel_scheduled",
|
|
837
|
+
"subscription_ended",
|
|
838
|
+
"access_suspended",
|
|
839
|
+
"subscription_terminated",
|
|
840
|
+
"invoice_issued",
|
|
841
|
+
"invoice_payment_due",
|
|
842
|
+
"price_change_notice"
|
|
843
|
+
];
|
|
844
|
+
const EmailTemplateSchema = z.enum(EMAIL_TEMPLATES);
|
|
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
|
+
*/
|
|
911
|
+
const EMAIL_TEMPLATE_BY_EVENT = Object.freeze({
|
|
912
|
+
"subscription.activated": "subscription_started",
|
|
913
|
+
"subscription.renewed": "payment_receipt",
|
|
914
|
+
"subscription.payment_failed": "payment_failed",
|
|
915
|
+
"subscription.cancel_scheduled": "cancel_scheduled",
|
|
916
|
+
"subscription.canceled": "subscription_ended",
|
|
917
|
+
"subscription.expired": "subscription_ended",
|
|
918
|
+
"subscription.suspended": "access_suspended",
|
|
919
|
+
"subscription.terminated": "subscription_terminated",
|
|
920
|
+
"invoice.issued": "invoice_issued"
|
|
921
|
+
});
|
|
922
|
+
function emailTemplateOf(type) {
|
|
923
|
+
return EMAIL_TEMPLATE_BY_EVENT[type] ?? null;
|
|
924
|
+
}
|
|
925
|
+
const EMAIL_LOCALES = ["tr", "en"];
|
|
926
|
+
/** Upper bound of product-provided recipients per account. */
|
|
927
|
+
const MAX_NOTIFICATION_EMAILS = 10;
|
|
928
|
+
const NotificationEmailsSchema = z.array(z.email().max(254)).max(10).transform((emails) => uniqueEmails(emails));
|
|
929
|
+
/** Case-insensitive de-duplication; keeps the first spelling and the order. */
|
|
930
|
+
function uniqueEmails(emails) {
|
|
931
|
+
const seen = /* @__PURE__ */ new Set();
|
|
932
|
+
const out = [];
|
|
933
|
+
for (const email of emails) {
|
|
934
|
+
const key = email.trim().toLowerCase();
|
|
935
|
+
if (key === "" || seen.has(key)) continue;
|
|
936
|
+
seen.add(key);
|
|
937
|
+
out.push(email.trim());
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
940
|
+
}
|
|
941
|
+
/** Profile email first, then the account's notification emails. */
|
|
942
|
+
function emailRecipients(profileEmail, notificationEmails) {
|
|
943
|
+
return uniqueEmails([...profileEmail ? [profileEmail] : [], ...notificationEmails]);
|
|
944
|
+
}
|
|
945
|
+
function emailLocaleOf(accountLocale, lastCheckoutLocale, defaultLocale) {
|
|
946
|
+
const known = (value) => EMAIL_LOCALES.includes(value ?? "");
|
|
947
|
+
if (known(accountLocale)) return accountLocale;
|
|
948
|
+
if (known(lastCheckoutLocale)) return lastCheckoutLocale;
|
|
949
|
+
return defaultLocale;
|
|
950
|
+
}
|
|
951
|
+
const ExtraTextValueSchema = z.string().trim().min(1).max(1e3);
|
|
952
|
+
const EmailExtraTextSchema = z.object({
|
|
953
|
+
tr: ExtraTextValueSchema.optional(),
|
|
954
|
+
en: ExtraTextValueSchema.optional()
|
|
955
|
+
});
|
|
956
|
+
const EmailTemplateSettingsSchema = z.object({
|
|
957
|
+
/** false: steward does not send this template (the product sends its own). */
|
|
958
|
+
enabled: z.boolean().default(true),
|
|
959
|
+
/** Plain-text paragraph appended to the template body, per locale. */
|
|
960
|
+
extraText: EmailExtraTextSchema.optional()
|
|
961
|
+
});
|
|
962
|
+
const EmailTemplatesSchema = z.object(Object.fromEntries(EMAIL_TEMPLATES.map((t) => [t, EmailTemplateSettingsSchema.optional()])));
|
|
963
|
+
const EmailSenderSchema = z.object({
|
|
964
|
+
/** Display name; defaults to `branding.name`. */
|
|
965
|
+
name: z.string().trim().min(1).max(100).optional(),
|
|
966
|
+
/** Sender address; defaults to the deployment's `STEWARD_EMAIL_FROM`. Its domain needs SPF/DKIM. */
|
|
967
|
+
address: z.email().max(254).optional()
|
|
968
|
+
});
|
|
969
|
+
/** Placeholders a billing path may use. */
|
|
970
|
+
const BILLING_PATH_PLACEHOLDERS = ["{LOCALE}", "{ACCOUNT_REF}"];
|
|
971
|
+
const BillingPathSchema = z.string().max(500).regex(/^\/(?!\/)[^\s\\]*$/, "an absolute path under appUrl (starts with a single /)").refine((path) => (path.match(/\{[^}]*\}/g) ?? []).every((p) => BILLING_PATH_PLACEHOLDERS.includes(p)), "only {LOCALE} and {ACCOUNT_REF} placeholders");
|
|
972
|
+
const EmailSettingsSchema = z.object({
|
|
973
|
+
from: EmailSenderSchema.optional(),
|
|
974
|
+
/** Reply-To; defaults to `branding.supportEmail`. */
|
|
975
|
+
replyTo: z.email().max(254).optional(),
|
|
976
|
+
/** Product billing page under `appUrl`, e.g. `/{LOCALE}/app/orgs/{ACCOUNT_REF}/billing`; without it buttons open `appUrl`. */
|
|
977
|
+
billingPath: BillingPathSchema.optional(),
|
|
978
|
+
templates: EmailTemplatesSchema.default({})
|
|
979
|
+
});
|
|
980
|
+
const EmailSettingsPatchSchema = z.object({
|
|
981
|
+
from: EmailSenderSchema.strict().nullable().optional(),
|
|
982
|
+
replyTo: z.email().max(254).nullable().optional(),
|
|
983
|
+
billingPath: BillingPathSchema.nullable().optional(),
|
|
984
|
+
templates: z.object(Object.fromEntries(EMAIL_TEMPLATES.map((t) => [t, z.object({
|
|
985
|
+
enabled: z.boolean().optional(),
|
|
986
|
+
extraText: EmailExtraTextSchema.strict().optional()
|
|
987
|
+
}).strict().nullable().optional()]))).strict().optional()
|
|
988
|
+
}).strict();
|
|
989
|
+
/** Pure merge of a validated patch (see the patch rules above). */
|
|
990
|
+
function applyEmailSettingsPatch(current, patch) {
|
|
991
|
+
if (patch === null) return EmailSettingsSchema.parse({});
|
|
992
|
+
const next = {
|
|
993
|
+
...current,
|
|
994
|
+
templates: { ...current.templates }
|
|
995
|
+
};
|
|
996
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
997
|
+
if (value === void 0) continue;
|
|
998
|
+
if (key === "templates") {
|
|
999
|
+
const templates = next.templates;
|
|
1000
|
+
for (const [template, settings] of Object.entries(value)) {
|
|
1001
|
+
if (settings === void 0) continue;
|
|
1002
|
+
if (settings === null) delete templates[template];
|
|
1003
|
+
else templates[template] = settings;
|
|
1004
|
+
}
|
|
1005
|
+
} else if (value === null) delete next[key];
|
|
1006
|
+
else next[key] = value;
|
|
1007
|
+
}
|
|
1008
|
+
return EmailSettingsSchema.parse(next);
|
|
1009
|
+
}
|
|
1010
|
+
function emailTemplateSettings(settings, template) {
|
|
1011
|
+
return settings.templates[template] ?? { enabled: true };
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Button target: `appUrl` + `billingPath` with placeholders filled (the ref is URL-encoded).
|
|
1015
|
+
* Null without `appUrl`, or when the result would leave the `appUrl` origin.
|
|
1016
|
+
*/
|
|
1017
|
+
function billingPageUrl(appUrl, billingPath, locale, accountRef) {
|
|
1018
|
+
if (appUrl === void 0) return null;
|
|
1019
|
+
let base;
|
|
1020
|
+
try {
|
|
1021
|
+
base = new URL(appUrl);
|
|
1022
|
+
} catch {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
if (billingPath === void 0) return base.toString();
|
|
1026
|
+
const path = billingPath.replaceAll("{LOCALE}", locale).replaceAll("{ACCOUNT_REF}", encodeURIComponent(accountRef));
|
|
1027
|
+
try {
|
|
1028
|
+
const url = new URL(path, base.origin);
|
|
1029
|
+
return url.origin === base.origin ? url.toString() : null;
|
|
1030
|
+
} catch {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
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
|
+
/**
|
|
1046
|
+
* Whether the email still describes the account at send time. Receipts, the welcome email
|
|
1047
|
+
* (legal confirmation) and the final termination notice always go; the others only while
|
|
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).
|
|
1050
|
+
*/
|
|
1051
|
+
function isEmailRelevant(template, facts) {
|
|
1052
|
+
const sub = liveSubscriptionOf(facts.state, facts.subscriptionId);
|
|
1053
|
+
switch (template) {
|
|
1054
|
+
case "subscription_started":
|
|
1055
|
+
case "payment_receipt":
|
|
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";
|
|
1063
|
+
case "invoice_issued": return facts.invoiceStatus === "issued";
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
49
1067
|
//#region ../contract/src/accounts.ts
|
|
1068
|
+
const AccountLocaleSchema = z.enum(EMAIL_LOCALES);
|
|
50
1069
|
const AccountUpsertInputSchema = z.object({
|
|
51
1070
|
displayName: z.string().min(1).max(200),
|
|
52
|
-
metadata: MetadataSchema.default({})
|
|
1071
|
+
metadata: MetadataSchema.default({}),
|
|
1072
|
+
/**
|
|
1073
|
+
* Extra recipients of billing emails besides the billing profile email (e.g. the
|
|
1074
|
+
* organization owners). Omitted: unchanged (older clients keep working); `[]` clears.
|
|
1075
|
+
*/
|
|
1076
|
+
notificationEmails: NotificationEmailsSchema.optional(),
|
|
1077
|
+
/** Language of billing emails. Omitted: unchanged; `null`: latest checkout's, else the product default. */
|
|
1078
|
+
locale: AccountLocaleSchema.nullable().optional()
|
|
53
1079
|
});
|
|
54
1080
|
const BillingProfileKindSchema = z.enum(["individual", "company"]);
|
|
55
1081
|
const BillingProfileSchema = z.object({
|
|
@@ -92,6 +1118,10 @@ const SubscriptionSummarySchema = z.object({
|
|
|
92
1118
|
status: SubscriptionStatusSchema,
|
|
93
1119
|
planCode: CodeSchema,
|
|
94
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
|
+
*/
|
|
95
1125
|
amountMinor: AmountMinorSchema,
|
|
96
1126
|
currency: CurrencySchema,
|
|
97
1127
|
currentPeriodEnd: DateTimeSchema.nullable(),
|
|
@@ -111,11 +1141,16 @@ const AccountDunningStateSchema = z.object({
|
|
|
111
1141
|
/** Sıradaki dunning aksiyonunun zamanı; kalmadıysa null. */
|
|
112
1142
|
nextActionAt: DateTimeSchema.nullable()
|
|
113
1143
|
});
|
|
114
|
-
/**
|
|
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
|
+
*/
|
|
115
1148
|
const AccountScheduledChangeSchema = z.object({
|
|
116
1149
|
planCode: CodeSchema,
|
|
117
1150
|
interval: IntervalSchema,
|
|
118
|
-
effectiveAt: DateTimeSchema
|
|
1151
|
+
effectiveAt: DateTimeSchema,
|
|
1152
|
+
/** Amount locked from `effectiveAt` (billing core; absent from older steward). */
|
|
1153
|
+
amountMinor: AmountMinorSchema.optional()
|
|
119
1154
|
});
|
|
120
1155
|
const AccountSubscriptionStateSchema = SubscriptionSummarySchema.extend({
|
|
121
1156
|
cancelAtPeriodEnd: z.boolean(),
|
|
@@ -124,7 +1159,10 @@ const AccountSubscriptionStateSchema = SubscriptionSummarySchema.extend({
|
|
|
124
1159
|
currentPeriodStart: DateTimeSchema.nullable(),
|
|
125
1160
|
/** Abonelik dunning'de değilse null. */
|
|
126
1161
|
dunning: AccountDunningStateSchema.nullable(),
|
|
127
|
-
/**
|
|
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
|
+
*/
|
|
128
1166
|
scheduledChange: AccountScheduledChangeSchema.nullable()
|
|
129
1167
|
});
|
|
130
1168
|
/** Hesabın açık (tamamlanmamış, süresi dolmamış) checkout oturumu. */
|
|
@@ -137,6 +1175,38 @@ const AccountOpenCheckoutSchema = z.object({
|
|
|
137
1175
|
amountMinor: AmountMinorSchema,
|
|
138
1176
|
expiresAt: DateTimeSchema
|
|
139
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);
|
|
140
1210
|
/** Fatura profili özeti: yalnızca var mı ve türü (alanlar PII, burada yok). */
|
|
141
1211
|
const AccountProfileStateSchema = z.object({
|
|
142
1212
|
present: z.boolean(),
|
|
@@ -155,14 +1225,27 @@ const AccountStateSchema = EntitlementSnapshotSchema.extend({
|
|
|
155
1225
|
catalogVersion: z.string().nullable(),
|
|
156
1226
|
subscription: AccountSubscriptionStateSchema.nullable(),
|
|
157
1227
|
openCheckout: AccountOpenCheckoutSchema.nullable(),
|
|
158
|
-
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()
|
|
159
1238
|
});
|
|
160
1239
|
const AccountSchema = z.object({
|
|
161
1240
|
ref: ExternalRefSchema,
|
|
162
1241
|
displayName: z.string(),
|
|
163
1242
|
metadata: MetadataSchema,
|
|
164
1243
|
createdAt: DateTimeSchema,
|
|
165
|
-
snapshot: EntitlementSnapshotSchema
|
|
1244
|
+
snapshot: EntitlementSnapshotSchema,
|
|
1245
|
+
/** Extra billing email recipients (older steward: absent → `[]`). */
|
|
1246
|
+
notificationEmails: z.array(z.string()).default([]),
|
|
1247
|
+
/** Billing email language set by the product; null: not set. */
|
|
1248
|
+
locale: AccountLocaleSchema.nullable().default(null)
|
|
166
1249
|
});
|
|
167
1250
|
const AccountDeleteResponseSchema = z.object({
|
|
168
1251
|
ref: ExternalRefSchema,
|
|
@@ -181,6 +1264,11 @@ const ACCOUNT_INCLUDES = [
|
|
|
181
1264
|
const EventIdSchema = z.string().regex(/^evt_[0-9a-f-]{36}$/);
|
|
182
1265
|
const ApiVersionSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
183
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
|
+
};
|
|
184
1272
|
const EVENT_DETAIL_SCHEMAS = {
|
|
185
1273
|
"account.updated": z.object({
|
|
186
1274
|
/** Snapshot'ın hangi alanları değişti (planCode, accessState, entitlements, subscription). */
|
|
@@ -214,7 +1302,9 @@ changed: z.array(z.string()) }),
|
|
|
214
1302
|
paymentId: IdSchema.nullable(),
|
|
215
1303
|
/** Dunning'in kaçıncı başarısız denemesi (1'den başlar). */
|
|
216
1304
|
attempt: z.number().int().positive(),
|
|
217
|
-
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()
|
|
218
1308
|
}),
|
|
219
1309
|
"subscription.suspended": z.object({
|
|
220
1310
|
...sub,
|
|
@@ -271,6 +1361,51 @@ changed: z.array(z.string()) }),
|
|
|
271
1361
|
"account.deleted": z.object({
|
|
272
1362
|
deletedAt: DateTimeSchema,
|
|
273
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()
|
|
274
1409
|
})
|
|
275
1410
|
};
|
|
276
1411
|
/** Tüm event tipleri (yenileri dahil). */
|
|
@@ -296,7 +1431,18 @@ const LEGACY_EVENT_TYPES = Object.freeze([
|
|
|
296
1431
|
"invoice.created",
|
|
297
1432
|
"invoice.issued"
|
|
298
1433
|
]);
|
|
299
|
-
|
|
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
|
+
]);
|
|
300
1446
|
function accountSchemaOf(type) {
|
|
301
1447
|
return FULL_STATE_EVENT_TYPES.has(type) ? AccountStateSchema : EntitlementSnapshotSchema;
|
|
302
1448
|
}
|
|
@@ -347,4 +1493,4 @@ const PingSchema = z.object({
|
|
|
347
1493
|
endpointId: IdSchema
|
|
348
1494
|
});
|
|
349
1495
|
//#endregion
|
|
350
|
-
export {
|
|
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 };
|