@evinvest/sites-contract 0.0.0-stage → 0.1.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 +25 -2
- package/dist/booking.d.ts +42 -0
- package/dist/booking.js +137 -0
- package/dist/experiments.d.ts +18 -0
- package/dist/experiments.js +56 -0
- package/dist/generated/contract.d.ts +303 -0
- package/dist/generated/contract.js +59 -0
- package/dist/generated/wire.d.ts +502 -0
- package/dist/generated/wire.js +3 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/ingest.d.ts +51 -0
- package/dist/ingest.js +45 -0
- package/dist/phone.d.ts +22 -0
- package/dist/phone.js +76 -0
- package/dist/place.d.ts +22 -0
- package/dist/place.js +280 -0
- package/dist/pricing.d.ts +70 -0
- package/dist/pricing.js +273 -0
- package/dist/text.d.ts +11 -0
- package/dist/text.js +19 -0
- package/package.json +41 -4
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { INPUT_KINDS, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS as LIMIT, } from "./generated/contract.js";
|
|
2
|
+
import { at, isDate, isObject, trim } from "./text.js";
|
|
3
|
+
const SLUG = new RegExp(LIMIT.slug);
|
|
4
|
+
const LOCALE = new RegExp(LIMIT.locale);
|
|
5
|
+
const EFFECT_FIELD = { add: "addCents", multiply: "multiplyBp", discount: "discountBp" };
|
|
6
|
+
const EFFECT_MAX = { add: LIMIT.maxPriceCents, multiply: LIMIT.maxMultiplyBp, discount: LIMIT.maxDiscountBp };
|
|
7
|
+
class Check {
|
|
8
|
+
problems = [];
|
|
9
|
+
fail(path, code, vars = {}) {
|
|
10
|
+
this.problems.push({ path, code, vars });
|
|
11
|
+
}
|
|
12
|
+
keys(path, v, required) {
|
|
13
|
+
for (const key of required)
|
|
14
|
+
if (!Object.hasOwn(v, key))
|
|
15
|
+
this.fail(at(path, key), "missing");
|
|
16
|
+
for (const key of Object.keys(v))
|
|
17
|
+
if (!required.includes(key))
|
|
18
|
+
this.fail(at(path, key), "unknownField");
|
|
19
|
+
}
|
|
20
|
+
int(path, v, min, max) {
|
|
21
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < min || v > max) {
|
|
22
|
+
this.fail(path, "int", { min, max });
|
|
23
|
+
return min;
|
|
24
|
+
}
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
slug(path, v) {
|
|
28
|
+
if (typeof v !== "string" || !SLUG.test(v)) {
|
|
29
|
+
this.fail(path, "slug");
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
return v;
|
|
33
|
+
}
|
|
34
|
+
labels(path, v) {
|
|
35
|
+
if (!isObject(v) || Object.keys(v).length === 0) {
|
|
36
|
+
this.fail(path, "labels");
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [locale, text] of Object.entries(v)) {
|
|
41
|
+
if (!LOCALE.test(locale))
|
|
42
|
+
this.fail(at(path, locale), "locale");
|
|
43
|
+
else if (typeof text !== "string" || trim(text) === "" || text.length > LIMIT.maxLabel)
|
|
44
|
+
this.fail(at(path, locale), "label", { max: LIMIT.maxLabel });
|
|
45
|
+
else
|
|
46
|
+
out[locale] = text;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
list(path, v, min, max) {
|
|
51
|
+
if (!Array.isArray(v) || v.length < min || v.length > max) {
|
|
52
|
+
this.fail(path, "list", { min, max });
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return v;
|
|
56
|
+
}
|
|
57
|
+
unique(path, ids) {
|
|
58
|
+
const dup = ids.find((id, i) => id !== "" && ids.indexOf(id) !== i);
|
|
59
|
+
if (dup !== undefined)
|
|
60
|
+
this.fail(path, "duplicate", { id: dup });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const isKind = (v) => INPUT_KINDS.some(k => k === v);
|
|
64
|
+
function input(c, path, v) {
|
|
65
|
+
if (!isObject(v)) {
|
|
66
|
+
c.fail(path, "object");
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
c.keys(path, v, ["id", "kind", "labels", "options"]);
|
|
70
|
+
const id = c.slug(at(path, "id"), v.id);
|
|
71
|
+
const labels = c.labels(at(path, "labels"), v.labels);
|
|
72
|
+
const kind = v.kind;
|
|
73
|
+
if (!isKind(kind)) {
|
|
74
|
+
c.fail(at(path, "kind"), "kind");
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const effect = EFFECT_FIELD[kind];
|
|
78
|
+
const options = c.list(at(path, "options"), v.options, 1, LIMIT.maxOptions).map((o, i) => {
|
|
79
|
+
const there = at(at(path, "options"), i);
|
|
80
|
+
if (!isObject(o)) {
|
|
81
|
+
c.fail(there, "object");
|
|
82
|
+
return { id: "", labels: {}, value: 0 };
|
|
83
|
+
}
|
|
84
|
+
c.keys(there, o, ["id", "labels", effect]);
|
|
85
|
+
return { id: c.slug(at(there, "id"), o.id), labels: c.labels(at(there, "labels"), o.labels), value: c.int(at(there, effect), o[effect], 0, EFFECT_MAX[kind]) };
|
|
86
|
+
});
|
|
87
|
+
c.unique(at(path, "options"), options.map(o => o.id));
|
|
88
|
+
switch (kind) {
|
|
89
|
+
case "add":
|
|
90
|
+
return { kind, id, labels, options: options.map(o => ({ id: o.id, labels: o.labels, addCents: o.value })) };
|
|
91
|
+
case "multiply":
|
|
92
|
+
return { kind, id, labels, options: options.map(o => ({ id: o.id, labels: o.labels, multiplyBp: o.value })) };
|
|
93
|
+
case "discount":
|
|
94
|
+
return { kind, id, labels, options: options.map(o => ({ id: o.id, labels: o.labels, discountBp: o.value })) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function need(c, path, v, inputs) {
|
|
98
|
+
if (!isObject(v)) {
|
|
99
|
+
c.fail(path, "object");
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (v.kind === "fixed") {
|
|
103
|
+
c.keys(path, v, ["kind", "cents"]);
|
|
104
|
+
return { kind: "fixed", cents: c.int(at(path, "cents"), v.cents, 0, LIMIT.maxPriceCents) };
|
|
105
|
+
}
|
|
106
|
+
if (v.kind !== "estimate") {
|
|
107
|
+
c.fail(at(path, "kind"), "needKind");
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
c.keys(path, v, ["kind", "baseCents", "inputs"]);
|
|
111
|
+
const baseCents = c.int(at(path, "baseCents"), v.baseCents, 0, LIMIT.maxPriceCents);
|
|
112
|
+
const ids = c.list(at(path, "inputs"), v.inputs, 0, LIMIT.maxNeedInputs).map((id, i) => c.slug(at(at(path, "inputs"), i), id));
|
|
113
|
+
c.unique(at(path, "inputs"), ids);
|
|
114
|
+
for (const [i, id] of ids.entries())
|
|
115
|
+
if (id !== "" && !inputs.some(x => x.id === id))
|
|
116
|
+
c.fail(at(at(path, "inputs"), i), "noInput", { id });
|
|
117
|
+
return { kind: "estimate", baseCents, inputs: ids };
|
|
118
|
+
}
|
|
119
|
+
/** The effect of each option of an input, in the unit its kind says. */
|
|
120
|
+
function effects(input) {
|
|
121
|
+
switch (input.kind) {
|
|
122
|
+
case "add":
|
|
123
|
+
return input.options.map(o => ({ id: o.id, effect: o.addCents }));
|
|
124
|
+
case "multiply":
|
|
125
|
+
return input.options.map(o => ({ id: o.id, effect: o.multiplyBp }));
|
|
126
|
+
case "discount":
|
|
127
|
+
return input.options.map(o => ({ id: o.id, effect: o.discountBp }));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The dearest answer to every input — each effect only raises the price with its value, and
|
|
132
|
+
* rounding half up is monotonic — walked step by step: if no step of it passes the cap, no
|
|
133
|
+
* combination does.
|
|
134
|
+
*/
|
|
135
|
+
function dearest(c, path, pricing, inputs) {
|
|
136
|
+
const asked = pricing.inputs.map(id => inputs.find(i => i.id === id)).filter(i => i !== undefined);
|
|
137
|
+
const max = (i) => (i.kind === "discount" ? 0 : Math.max(...effects(i).map(o => o.effect)));
|
|
138
|
+
let total = pricing.baseCents;
|
|
139
|
+
let over = total > LIMIT.maxPriceCents;
|
|
140
|
+
for (const i of asked.filter(i => i.kind === "add"))
|
|
141
|
+
over ||= (total += max(i)) > LIMIT.maxPriceCents;
|
|
142
|
+
for (const i of asked.filter(i => i.kind === "multiply"))
|
|
143
|
+
over ||= (total = mulBp(total, max(i))) > LIMIT.maxPriceCents;
|
|
144
|
+
if (over)
|
|
145
|
+
c.fail(path, "dearest", { max: LIMIT.maxPriceCents });
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Every reason `value` is not a pricing model, and the model rebuilt from what was checked when
|
|
149
|
+
* there is none: taken whole or not at all, unknown fields refused rather than ignored. With
|
|
150
|
+
* `locales`, a model a site can show: every input and option labelled in each of them.
|
|
151
|
+
*/
|
|
152
|
+
export function checkPricingModel(value, locales = []) {
|
|
153
|
+
const c = new Check();
|
|
154
|
+
if (!isObject(value))
|
|
155
|
+
return { problems: [{ path: "", code: "object", vars: {} }], model: null };
|
|
156
|
+
c.keys("", value, ["format", "currency", "validFrom", "roundToCents", "minimumCents", "inputs", "needs"]);
|
|
157
|
+
if (value.format !== PRICING_FORMAT)
|
|
158
|
+
c.fail("format", "format", { format: PRICING_FORMAT });
|
|
159
|
+
if (value.currency !== PRICING_CURRENCY)
|
|
160
|
+
c.fail("currency", "currency", { currency: PRICING_CURRENCY });
|
|
161
|
+
if (!isDate(value.validFrom))
|
|
162
|
+
c.fail("validFrom", "date");
|
|
163
|
+
const roundToCents = c.int("roundToCents", value.roundToCents, 1, LIMIT.maxPriceCents);
|
|
164
|
+
const minimumCents = c.int("minimumCents", value.minimumCents, 0, LIMIT.maxPriceCents);
|
|
165
|
+
const inputs = c
|
|
166
|
+
.list("inputs", value.inputs, 0, LIMIT.maxInputs)
|
|
167
|
+
.map((v, i) => input(c, at("inputs", i), v))
|
|
168
|
+
.filter(i => i !== null);
|
|
169
|
+
c.unique("inputs", inputs.map(i => i.id));
|
|
170
|
+
const needs = {};
|
|
171
|
+
if (!isObject(value.needs))
|
|
172
|
+
c.fail("needs", "object");
|
|
173
|
+
else {
|
|
174
|
+
const entries = Object.entries(value.needs);
|
|
175
|
+
if (entries.length > LIMIT.maxNeeds)
|
|
176
|
+
c.fail("needs", "list", { min: 0, max: LIMIT.maxNeeds });
|
|
177
|
+
for (const [slug, v] of entries) {
|
|
178
|
+
const path = at("needs", slug);
|
|
179
|
+
if (!SLUG.test(slug))
|
|
180
|
+
c.fail(path, "slug");
|
|
181
|
+
const pricing = need(c, path, v, inputs);
|
|
182
|
+
if (pricing)
|
|
183
|
+
needs[slug] = pricing;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (c.problems.length > 0)
|
|
187
|
+
return { problems: c.problems, model: null };
|
|
188
|
+
for (const [slug, pricing] of Object.entries(needs))
|
|
189
|
+
if (pricing.kind === "estimate")
|
|
190
|
+
dearest(c, at("needs", slug), pricing, inputs);
|
|
191
|
+
if (c.problems.length > 0)
|
|
192
|
+
return { problems: c.problems, model: null };
|
|
193
|
+
const labelled = (path, labels) => {
|
|
194
|
+
for (const locale of locales)
|
|
195
|
+
if (!Object.hasOwn(labels, locale))
|
|
196
|
+
c.fail(at(at(path, "labels"), locale), "labelMissing", { locale });
|
|
197
|
+
};
|
|
198
|
+
for (const [i, inp] of inputs.entries()) {
|
|
199
|
+
labelled(at("inputs", i), inp.labels);
|
|
200
|
+
for (const [j, o] of inp.options.entries())
|
|
201
|
+
labelled(at(at(at("inputs", i), "options"), j), o.labels);
|
|
202
|
+
}
|
|
203
|
+
if (c.problems.length > 0)
|
|
204
|
+
return { problems: c.problems, model: null };
|
|
205
|
+
return { problems: [], model: { format: PRICING_FORMAT, currency: PRICING_CURRENCY, validFrom: String(value.validFrom), roundToCents, minimumCents, inputs, needs } };
|
|
206
|
+
}
|
|
207
|
+
const BP = 10_000;
|
|
208
|
+
/**
|
|
209
|
+
* `cents × bp / 10 000`, rounded half up to the cent. Integers throughout: the product is exact
|
|
210
|
+
* (the model's limits keep it under 2^53), and the division is done on the remainder.
|
|
211
|
+
*/
|
|
212
|
+
export function mulBp(cents, bp) {
|
|
213
|
+
const n = cents * bp + BP / 2;
|
|
214
|
+
return (n - (n % BP)) / BP;
|
|
215
|
+
}
|
|
216
|
+
/** Half up to a multiple of `step` (≥ 1). */
|
|
217
|
+
export function roundTo(cents, step) {
|
|
218
|
+
const rest = cents % step;
|
|
219
|
+
return rest * 2 >= step ? cents - rest + step : cents - rest;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* What a need costs for these answers (`contracts/vectors/pricing/README.md`, normative):
|
|
223
|
+
*
|
|
224
|
+
* 1. the need's base, plus every `add` input, in the need's input order;
|
|
225
|
+
* 2. times every `multiply` input, in order, each product rounded half up to the cent at once;
|
|
226
|
+
* 3. every `discount` input, in order, as a multiplication by `10 000 − discountBp`, rounded the
|
|
227
|
+
* same way;
|
|
228
|
+
* 4. the total rounded half up to a multiple of `roundToCents`;
|
|
229
|
+
* 5. raised to `minimumCents`.
|
|
230
|
+
*
|
|
231
|
+
* A `fixed` need is its price, as is. `null`: the need is not priced, or an input is unanswered
|
|
232
|
+
* or answered with an option it does not have — never a guess. Answers to inputs the need does
|
|
233
|
+
* not ask are ignored.
|
|
234
|
+
*/
|
|
235
|
+
export function priceOf(model, need, inputs) {
|
|
236
|
+
const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
|
|
237
|
+
if (!pricing)
|
|
238
|
+
return null;
|
|
239
|
+
if (pricing.kind === "fixed")
|
|
240
|
+
return { cents: pricing.cents, breakdown: [{ kind: "base", cents: pricing.cents }] };
|
|
241
|
+
const answers = [];
|
|
242
|
+
for (const id of pricing.inputs) {
|
|
243
|
+
const input = model.inputs.find(i => i.id === id);
|
|
244
|
+
const answer = Object.hasOwn(inputs, id) ? inputs[id] : undefined;
|
|
245
|
+
const option = input && effects(input).find(o => o.id === answer);
|
|
246
|
+
if (!input || !option)
|
|
247
|
+
return null;
|
|
248
|
+
answers.push({ input, option: option.id, effect: option.effect });
|
|
249
|
+
}
|
|
250
|
+
const lines = [{ kind: "base", cents: pricing.baseCents }];
|
|
251
|
+
let total = pricing.baseCents;
|
|
252
|
+
const step = (kind, apply) => {
|
|
253
|
+
for (const a of answers) {
|
|
254
|
+
if (a.input.kind !== kind)
|
|
255
|
+
continue;
|
|
256
|
+
const next = apply(total, a.effect);
|
|
257
|
+
lines.push({ kind, input: a.input.id, option: a.option, cents: next - total });
|
|
258
|
+
total = next;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
step("add", (t, cents) => t + cents);
|
|
262
|
+
step("multiply", (t, bp) => mulBp(t, bp));
|
|
263
|
+
step("discount", (t, bp) => mulBp(t, BP - bp));
|
|
264
|
+
const rounded = roundTo(total, model.roundToCents);
|
|
265
|
+
if (rounded !== total)
|
|
266
|
+
lines.push({ kind: "rounding", cents: rounded - total });
|
|
267
|
+
total = rounded;
|
|
268
|
+
if (total < model.minimumCents) {
|
|
269
|
+
lines.push({ kind: "minimum", cents: model.minimumCents - total });
|
|
270
|
+
total = model.minimumCents;
|
|
271
|
+
}
|
|
272
|
+
return { cents: total, breakdown: lines };
|
|
273
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Rust's `str::trim`: Unicode White_Space only — not the BOM JavaScript's `trim` also takes. */
|
|
2
|
+
export declare const trim: (s: string) => string;
|
|
3
|
+
/** In Unicode scalar values, as Rust's `chars().count()`. */
|
|
4
|
+
export declare const charCount: (s: string) => number;
|
|
5
|
+
export declare const hasControl: (s: string) => boolean;
|
|
6
|
+
export declare const hasWhitespace: (s: string) => boolean;
|
|
7
|
+
export type Json = Record<string, unknown>;
|
|
8
|
+
export declare const isObject: (v: unknown) => v is Json;
|
|
9
|
+
export declare const at: (path: string, key: string | number) => string;
|
|
10
|
+
/** `YYYY-MM-DD` of ASCII digits, and a day the (proleptic Gregorian) calendar has. */
|
|
11
|
+
export declare function isDate(v: unknown): v is string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Text as the panel's Rust reads it, so both sides agree on what is blank, long or one line.
|
|
2
|
+
const EDGES = /^\p{White_Space}+|\p{White_Space}+$/gu;
|
|
3
|
+
/** Rust's `str::trim`: Unicode White_Space only — not the BOM JavaScript's `trim` also takes. */
|
|
4
|
+
export const trim = (s) => s.replace(EDGES, "");
|
|
5
|
+
/** In Unicode scalar values, as Rust's `chars().count()`. */
|
|
6
|
+
export const charCount = (s) => [...s].length;
|
|
7
|
+
export const hasControl = (s) => /\p{Cc}/u.test(s);
|
|
8
|
+
export const hasWhitespace = (s) => /\p{White_Space}/u.test(s);
|
|
9
|
+
export const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
10
|
+
export const at = (path, key) => (typeof key === "number" ? `${path}[${key}]` : path === "" ? key : `${path}.${key}`);
|
|
11
|
+
/** `YYYY-MM-DD` of ASCII digits, and a day the (proleptic Gregorian) calendar has. */
|
|
12
|
+
export function isDate(v) {
|
|
13
|
+
if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v))
|
|
14
|
+
return false;
|
|
15
|
+
const [year = 0, month = 0, day = 0] = v.split("-").map(Number);
|
|
16
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
17
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
18
|
+
return days !== undefined && day >= 1 && day <= days;
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evinvest/sites-contract",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Service-Arb sites ↔ panel contract in TS: the shapes and vocabularies generated from the panel's Rust and protos, and the one TS implementation of what both sides run — pricing, booking and place checks, phone numbers, the ingest events a site sends.",
|
|
5
|
+
"license": "BlueOak-1.0.0",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/EV-Service-Arb/sites_panel.git",
|
|
12
|
+
"directory": "ts/sites-contract"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.13"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"prepare": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.10.0",
|
|
40
|
+
"typescript": "^5.9.3",
|
|
41
|
+
"vitest": "^3.2.4"
|
|
42
|
+
}
|
|
43
|
+
}
|