@evinvest/sites-contract 0.0.0-stage → 0.1.1
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 +44 -0
- package/dist/booking.js +137 -0
- package/dist/experiments.d.ts +18 -0
- package/dist/experiments.js +56 -0
- package/dist/generated/booking.d.ts +12 -0
- package/dist/generated/booking.js +11 -0
- package/dist/generated/codes.d.ts +6 -0
- package/dist/generated/codes.js +4 -0
- package/dist/generated/experiments.d.ts +7 -0
- package/dist/generated/experiments.js +8 -0
- package/dist/generated/lead.d.ts +13 -0
- package/dist/generated/lead.js +10 -0
- package/dist/generated/place.d.ts +15 -0
- package/dist/generated/place.js +15 -0
- package/dist/generated/pricing.d.ts +16 -0
- package/dist/generated/pricing.js +16 -0
- package/dist/generated/types.d.ts +234 -0
- package/dist/generated/types.js +2 -0
- package/dist/generated/wire.d.ts +502 -0
- package/dist/generated/wire.js +3 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -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 +23 -0
- package/dist/place.js +280 -0
- package/dist/pricing.d.ts +72 -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/phone.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The national number's length, after the country code, for the plans the
|
|
3
|
+
* vertical's visitors most often come from — bounds wide enough for every
|
|
4
|
+
* real number (Germany and Italy vary by area), short of a library. A plan
|
|
5
|
+
* not listed keeps E.164's 8 to 15 digits in all.
|
|
6
|
+
*/
|
|
7
|
+
const NATIONAL_LENGTH = {
|
|
8
|
+
"1": [10, 10],
|
|
9
|
+
"32": [8, 9],
|
|
10
|
+
"34": [9, 9],
|
|
11
|
+
"39": [6, 11],
|
|
12
|
+
"41": [9, 9],
|
|
13
|
+
"44": [9, 10],
|
|
14
|
+
"49": [7, 13],
|
|
15
|
+
};
|
|
16
|
+
function fitsPlan(e164) {
|
|
17
|
+
const digits = e164.slice(1);
|
|
18
|
+
for (const code of [digits.slice(0, 1), digits.slice(0, 2)]) {
|
|
19
|
+
const bounds = NATIONAL_LENGTH[code];
|
|
20
|
+
if (bounds)
|
|
21
|
+
return digits.length - code.length >= bounds[0] && digits.length - code.length <= bounds[1];
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A phone number as the funnel reads it: E.164 when the typed value is a
|
|
27
|
+
* number someone can be reached on, otherwise `null`.
|
|
28
|
+
*
|
|
29
|
+
* Only the French national plan is read closely (every brand of the vertical
|
|
30
|
+
* is in France): ten digits from `0[1-9]` — `06 12 34 56 78`, `+33 (0)6 …`,
|
|
31
|
+
* `0033 6 …`, a mobile typed without its trunk zero (`6 12 34 56 78`) — all
|
|
32
|
+
* become `+33612345678`. Any other international number is a plausible E.164,
|
|
33
|
+
* 8 to 15 digits after the `+`, compacted (`+44 7911 123456` →
|
|
34
|
+
* `+447911123456`) — and, for the plans a visitor here most often dials
|
|
35
|
+
* from (`NATIONAL_LENGTH`), as long as that plan's numbers are, so `+12345678`
|
|
36
|
+
* is not a number. Full-width digits read as digits; a run of one digit
|
|
37
|
+
* (`00 00 00 00 00`, `06 66 66 66 66`) is a refusal to give a number, not one.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizePhone(raw) {
|
|
40
|
+
// A Japanese or Chinese keyboard types full-width digits and `+`, and a
|
|
41
|
+
// pasted number may carry no-break spaces.
|
|
42
|
+
const folded = raw.replace(/[\uff10-\uff19\uff0b]/g, c => (c === "\uff0b" ? "+" : String(c.charCodeAt(0) - 0xff10))).replace(/\p{White_Space}/gu, " ");
|
|
43
|
+
let s = folded.replace(/\(0\)/g, "").replace(/[ .\-()/]/g, "");
|
|
44
|
+
if (s.startsWith("00"))
|
|
45
|
+
s = `+${s.slice(2)}`;
|
|
46
|
+
let e164 = null;
|
|
47
|
+
if (s.startsWith("+33")) {
|
|
48
|
+
const national = /^\+330?([1-9]\d{8})$/.exec(s);
|
|
49
|
+
e164 = national ? `+33${national[1]}` : null;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const trunk = /^0([1-9]\d{8})$/.exec(s);
|
|
53
|
+
if (trunk)
|
|
54
|
+
e164 = `+33${trunk[1]}`;
|
|
55
|
+
else if (/^[1-9]\d{8}$/.test(s))
|
|
56
|
+
e164 = `+33${s}`;
|
|
57
|
+
else if (/^\+[1-9]\d{7,14}$/.test(s) && fitsPlan(s))
|
|
58
|
+
e164 = s;
|
|
59
|
+
}
|
|
60
|
+
if (e164 === null)
|
|
61
|
+
return null;
|
|
62
|
+
// The subscriber's digits: past `+33` for a French number, past `+` otherwise.
|
|
63
|
+
const subscriber = e164.startsWith("+33") ? e164.slice(3) : e164.slice(1);
|
|
64
|
+
return /^(\d)\1+$/.test(subscriber) ? null : e164;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* What is wrong with a typed number, or `null`: `required` when nothing was
|
|
68
|
+
* typed, `invalid` when it is not a number we can call. The form blocks on it
|
|
69
|
+
* in the page's words and the server refuses on it — one rule, so a number
|
|
70
|
+
* the form lets through is never refused behind the visitor's back.
|
|
71
|
+
*/
|
|
72
|
+
export function phoneProblem(raw) {
|
|
73
|
+
if (raw.trim() === "")
|
|
74
|
+
return "required";
|
|
75
|
+
return normalizePhone(raw) === null ? "invalid" : null;
|
|
76
|
+
}
|
package/dist/place.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { BookingCode, PlaceCode } from "./generated/codes.js";
|
|
2
|
+
import type { PlaceLive } from "./generated/types.js";
|
|
3
|
+
/** One thing wrong with a place's settings: its path (`phone`, `hours[0].opens`, `booking.providers.cal_com.url`) and why. */
|
|
4
|
+
export interface PlaceProblem {
|
|
5
|
+
path: string;
|
|
6
|
+
code: PlaceCode | BookingCode;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* An RFC 3339 instant: `YYYY-MM-DDTHH:MM:SS`, an optional fraction of 1 to 9 digits, and `Z`
|
|
10
|
+
* or `±HH:MM` — on a day the calendar has, at a time the clock has (no leap second), at an
|
|
11
|
+
* offset of less than a day.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isInstant(s: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* A place's live settings checked field by field (`contracts/vectors/place.json`): the fields
|
|
16
|
+
* that pass, and every problem of those that do not. The panel refuses settings with any
|
|
17
|
+
* problem; a site keeps what passed and serves its baked config for the rest. A field set to
|
|
18
|
+
* `null` is left out, as if absent.
|
|
19
|
+
*/
|
|
20
|
+
export declare function checkPlaceLive(body: unknown): {
|
|
21
|
+
live: PlaceLive;
|
|
22
|
+
problems: PlaceProblem[];
|
|
23
|
+
};
|
package/dist/place.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { checkBookingConfig } from "./booking.js";
|
|
2
|
+
import { DAYS, MESSENGERS, PLACE_LIMITS as LIMIT } from "./generated/place.js";
|
|
3
|
+
import { at, charCount, hasControl, hasWhitespace, isDate, isObject, trim } from "./text.js";
|
|
4
|
+
const E164 = new RegExp(LIMIT.e164);
|
|
5
|
+
const TELEGRAM_BOT = new RegExp(LIMIT.telegramBot);
|
|
6
|
+
const TIME = new RegExp(LIMIT.time);
|
|
7
|
+
class Check {
|
|
8
|
+
problems = [];
|
|
9
|
+
fail(path, code) {
|
|
10
|
+
this.problems.push({ path, code });
|
|
11
|
+
}
|
|
12
|
+
/** `f`'s value when it added no problem. */
|
|
13
|
+
clean(f) {
|
|
14
|
+
const before = this.problems.length;
|
|
15
|
+
const out = f();
|
|
16
|
+
return this.problems.length === before ? out : undefined;
|
|
17
|
+
}
|
|
18
|
+
/** A line of text, trimmed: not blank, at most `max` characters, no control characters. */
|
|
19
|
+
text(path, v, max) {
|
|
20
|
+
if (typeof v !== "string") {
|
|
21
|
+
this.fail(path, "type");
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
const s = trim(v);
|
|
25
|
+
if (s === "")
|
|
26
|
+
this.fail(path, "blank");
|
|
27
|
+
else if (charCount(s) > max)
|
|
28
|
+
this.fail(path, "too_long");
|
|
29
|
+
else if (hasControl(s))
|
|
30
|
+
this.fail(path, "control");
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
/** A text whose only fault is a `code` of its own: trimmed, and held to `ok`. */
|
|
34
|
+
word(path, v, max, code, ok) {
|
|
35
|
+
const s = typeof v === "string" ? trim(v) : "";
|
|
36
|
+
if (s === "" || charCount(s) > max || hasControl(s) || !ok(s))
|
|
37
|
+
this.fail(path, code);
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
/** An object with exactly `keys`; every other key and every missing one named. */
|
|
41
|
+
object(path, v, keys) {
|
|
42
|
+
if (!isObject(v)) {
|
|
43
|
+
this.fail(path, "shape");
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const before = this.problems.length;
|
|
47
|
+
for (const k of Object.keys(v))
|
|
48
|
+
if (!keys.includes(k))
|
|
49
|
+
this.fail(at(path, k), "unknown_field");
|
|
50
|
+
for (const k of keys)
|
|
51
|
+
if (!Object.hasOwn(v, k))
|
|
52
|
+
this.fail(at(path, k), "required");
|
|
53
|
+
return this.problems.length === before ? v : null;
|
|
54
|
+
}
|
|
55
|
+
/** A list of 1 to `max` entries. */
|
|
56
|
+
list(path, v, max) {
|
|
57
|
+
if (!Array.isArray(v))
|
|
58
|
+
this.fail(path, "shape");
|
|
59
|
+
else if (v.length === 0)
|
|
60
|
+
this.fail(path, "empty");
|
|
61
|
+
else if (v.length > max)
|
|
62
|
+
this.fail(path, "too_many");
|
|
63
|
+
else
|
|
64
|
+
return v;
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The host of an absolute `https://` URL without userinfo or whitespace, lowercase. */
|
|
69
|
+
function httpsHost(s) {
|
|
70
|
+
if (!s.startsWith("https://"))
|
|
71
|
+
return null;
|
|
72
|
+
const authority = s.slice("https://".length).split(/[/?#]/, 1)[0] ?? "";
|
|
73
|
+
const ok = authority !== "" && !authority.startsWith(":") && !authority.startsWith(".") && !authority.includes("@") && !hasWhitespace(s);
|
|
74
|
+
return ok ? authority.toLowerCase() : null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* An RFC 3339 instant: `YYYY-MM-DDTHH:MM:SS`, an optional fraction of 1 to 9 digits, and `Z`
|
|
78
|
+
* or `±HH:MM` — on a day the calendar has, at a time the clock has (no leap second), at an
|
|
79
|
+
* offset of less than a day.
|
|
80
|
+
*/
|
|
81
|
+
export function isInstant(s) {
|
|
82
|
+
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(s);
|
|
83
|
+
if (!m || !isDate(m[1]))
|
|
84
|
+
return false;
|
|
85
|
+
const [hour, minute, second, offsetHours, offsetMinutes] = m.slice(2).map(n => (n === undefined ? 0 : Number(n)));
|
|
86
|
+
return hour <= 23 && minute <= 59 && second <= 59 && offsetHours <= 23 && offsetMinutes <= 59;
|
|
87
|
+
}
|
|
88
|
+
const isDay = (v) => DAYS.some(d => d === v);
|
|
89
|
+
const isMessenger = (v) => MESSENGERS.some(m => m === v);
|
|
90
|
+
function hours(c, path, v) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const [i, row] of c.list(path, v, LIMIT.maxHours).entries()) {
|
|
93
|
+
const there = at(path, i);
|
|
94
|
+
const m = c.object(there, row, ["days", "opens", "closes"]);
|
|
95
|
+
if (!m)
|
|
96
|
+
continue;
|
|
97
|
+
const days = c.clean(() => {
|
|
98
|
+
const seen = [];
|
|
99
|
+
for (const [j, d] of c.list(at(there, "days"), m.days, DAYS.length).entries()) {
|
|
100
|
+
if (!isDay(d))
|
|
101
|
+
c.fail(at(at(there, "days"), j), "day");
|
|
102
|
+
else if (seen.includes(d))
|
|
103
|
+
c.fail(at(at(there, "days"), j), "duplicate");
|
|
104
|
+
else
|
|
105
|
+
seen.push(d);
|
|
106
|
+
}
|
|
107
|
+
return DAYS.filter(d => seen.includes(d));
|
|
108
|
+
});
|
|
109
|
+
const time = (key) => c.clean(() => {
|
|
110
|
+
const t = m[key];
|
|
111
|
+
if (typeof t === "string" && TIME.test(t))
|
|
112
|
+
return t;
|
|
113
|
+
c.fail(at(there, key), "time");
|
|
114
|
+
return "";
|
|
115
|
+
});
|
|
116
|
+
const opens = time("opens");
|
|
117
|
+
const closes = time("closes");
|
|
118
|
+
if (days === undefined || opens === undefined || closes === undefined)
|
|
119
|
+
continue;
|
|
120
|
+
// HH:MM sorts as the time it names.
|
|
121
|
+
if (closes <= opens)
|
|
122
|
+
c.fail(at(there, "closes"), "closes_before_opens");
|
|
123
|
+
else
|
|
124
|
+
out.push({ days, opens, closes });
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function serviceArea(c, path, v) {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const [i, n] of c.list(path, v, LIMIT.maxArea).entries()) {
|
|
131
|
+
const name = c.clean(() => c.text(at(path, i), n, LIMIT.maxText));
|
|
132
|
+
if (name === undefined)
|
|
133
|
+
continue;
|
|
134
|
+
if (out.some(o => o.toLowerCase() === name.toLowerCase()))
|
|
135
|
+
c.fail(at(path, i), "duplicate");
|
|
136
|
+
else
|
|
137
|
+
out.push(name);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
function address(c, path, v) {
|
|
142
|
+
const m = c.object(path, v, ["street", "postalCode", "locality"]);
|
|
143
|
+
if (!m)
|
|
144
|
+
return null;
|
|
145
|
+
return {
|
|
146
|
+
street: c.text(at(path, "street"), m.street, LIMIT.maxText),
|
|
147
|
+
postalCode: c.word(at(path, "postalCode"), m.postalCode, 5, "postal_code", p => /^\d{5}$/.test(p)),
|
|
148
|
+
locality: c.text(at(path, "locality"), m.locality, LIMIT.maxText),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function geo(c, path, v) {
|
|
152
|
+
const m = c.object(path, v, ["lat", "lng"]);
|
|
153
|
+
if (!m)
|
|
154
|
+
return null;
|
|
155
|
+
const coord = (key, limit) => {
|
|
156
|
+
const x = m[key];
|
|
157
|
+
if (typeof x === "number" && Math.abs(x) <= limit)
|
|
158
|
+
return x;
|
|
159
|
+
c.fail(at(path, key), "coordinate");
|
|
160
|
+
return 0;
|
|
161
|
+
};
|
|
162
|
+
return { lat: coord("lat", 90), lng: coord("lng", 180) };
|
|
163
|
+
}
|
|
164
|
+
function landmark(c, path, v) {
|
|
165
|
+
if (!isObject(v)) {
|
|
166
|
+
c.fail(path, "shape");
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
const entries = Object.entries(v);
|
|
170
|
+
if (entries.length === 0)
|
|
171
|
+
c.fail(path, "empty");
|
|
172
|
+
else if (entries.length > LIMIT.maxLocales)
|
|
173
|
+
c.fail(path, "too_many");
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const [locale, text] of entries) {
|
|
176
|
+
if (!/^[a-z]{2}$/.test(locale))
|
|
177
|
+
c.fail(at(path, locale), "locale");
|
|
178
|
+
else
|
|
179
|
+
out[locale] = c.text(at(path, locale), text, LIMIT.maxText);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
function rating(c, path, v) {
|
|
184
|
+
const m = c.object(path, v, ["value", "count", "fetchedAt"]);
|
|
185
|
+
if (!m)
|
|
186
|
+
return null;
|
|
187
|
+
const { value, count, fetchedAt } = m;
|
|
188
|
+
const valueOk = typeof value === "number" && value >= 1 && value <= 5;
|
|
189
|
+
const countOk = typeof count === "number" && Number.isSafeInteger(count) && count >= 0;
|
|
190
|
+
const atOk = typeof fetchedAt === "string" && isInstant(fetchedAt);
|
|
191
|
+
if (!valueOk)
|
|
192
|
+
c.fail(at(path, "value"), "rating_value");
|
|
193
|
+
if (!countOk)
|
|
194
|
+
c.fail(at(path, "count"), "rating_count");
|
|
195
|
+
if (!atOk)
|
|
196
|
+
c.fail(at(path, "fetchedAt"), "instant");
|
|
197
|
+
return valueOk && countOk && atOk ? { value, count, fetchedAt } : null;
|
|
198
|
+
}
|
|
199
|
+
function messengers(c, path, v) {
|
|
200
|
+
if (!isObject(v)) {
|
|
201
|
+
c.fail(path, "shape");
|
|
202
|
+
return {};
|
|
203
|
+
}
|
|
204
|
+
const out = {};
|
|
205
|
+
for (const [k, on] of Object.entries(v)) {
|
|
206
|
+
if (!isMessenger(k))
|
|
207
|
+
c.fail(at(path, k), "messenger_unknown");
|
|
208
|
+
else if (typeof on !== "boolean")
|
|
209
|
+
c.fail(at(path, k), "boolean");
|
|
210
|
+
else
|
|
211
|
+
out[k] = on;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A place's live settings checked field by field (`contracts/vectors/place.json`): the fields
|
|
217
|
+
* that pass, and every problem of those that do not. The panel refuses settings with any
|
|
218
|
+
* problem; a site keeps what passed and serves its baked config for the rest. A field set to
|
|
219
|
+
* `null` is left out, as if absent.
|
|
220
|
+
*/
|
|
221
|
+
export function checkPlaceLive(body) {
|
|
222
|
+
const c = new Check();
|
|
223
|
+
const live = {};
|
|
224
|
+
if (!isObject(body))
|
|
225
|
+
return { live, problems: [{ path: "", code: "object" }] };
|
|
226
|
+
const set = (key, value) => {
|
|
227
|
+
if (value !== undefined && value !== null)
|
|
228
|
+
live[key] = value;
|
|
229
|
+
};
|
|
230
|
+
for (const [name, v] of Object.entries(body)) {
|
|
231
|
+
if (v === null)
|
|
232
|
+
continue;
|
|
233
|
+
switch (name) {
|
|
234
|
+
case "phone":
|
|
235
|
+
case "whatsapp":
|
|
236
|
+
set(name, c.clean(() => c.word(name, v, 16, "e164", s => E164.test(s))));
|
|
237
|
+
break;
|
|
238
|
+
case "telegram":
|
|
239
|
+
set(name, c.clean(() => c.word(name, v, 32, "telegram_bot", s => TELEGRAM_BOT.test(s))));
|
|
240
|
+
break;
|
|
241
|
+
case "messengers":
|
|
242
|
+
set(name, c.clean(() => messengers(c, name, v)));
|
|
243
|
+
break;
|
|
244
|
+
case "hours":
|
|
245
|
+
set(name, c.clean(() => hours(c, name, v)));
|
|
246
|
+
break;
|
|
247
|
+
case "serviceArea":
|
|
248
|
+
set(name, c.clean(() => serviceArea(c, name, v)));
|
|
249
|
+
break;
|
|
250
|
+
case "address":
|
|
251
|
+
set(name, c.clean(() => address(c, name, v)));
|
|
252
|
+
break;
|
|
253
|
+
case "geo":
|
|
254
|
+
set(name, c.clean(() => geo(c, name, v)));
|
|
255
|
+
break;
|
|
256
|
+
case "storefrontPhoto":
|
|
257
|
+
set(name, c.clean(() => c.word(name, v, LIMIT.maxUrl, "https_url", s => httpsHost(s) !== null)));
|
|
258
|
+
break;
|
|
259
|
+
case "landmark":
|
|
260
|
+
set(name, c.clean(() => landmark(c, name, v)));
|
|
261
|
+
break;
|
|
262
|
+
case "rating":
|
|
263
|
+
set(name, c.clean(() => rating(c, name, v)));
|
|
264
|
+
break;
|
|
265
|
+
case "booking": {
|
|
266
|
+
const { problems, config } = checkBookingConfig(v);
|
|
267
|
+
for (const p of problems)
|
|
268
|
+
c.fail(p.path === "" ? name : at(name, p.path), p.code);
|
|
269
|
+
set(name, config);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "reviewUrl":
|
|
273
|
+
set(name, c.clean(() => c.word(name, v, LIMIT.maxUrl, "review_url", s => LIMIT.reviewHosts.some(h => h === httpsHost(s)))));
|
|
274
|
+
break;
|
|
275
|
+
default:
|
|
276
|
+
c.fail(name, "unknown_field");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { live, problems: c.problems };
|
|
280
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { PricingCode } from "./generated/codes.js";
|
|
2
|
+
import { type PricingInputKind } from "./generated/pricing.js";
|
|
3
|
+
import type { PricingInput, PricingModel } from "./generated/types.js";
|
|
4
|
+
/**
|
|
5
|
+
* One thing wrong with a model: its path from the model's root (`inputs[0].options[2].addCents`,
|
|
6
|
+
* `""` for the model itself), the code the panel names it by too, and what the code's words
|
|
7
|
+
* need (`{ min, max }`, `{ id }`, `{ locale }`).
|
|
8
|
+
*/
|
|
9
|
+
export interface PricingProblem {
|
|
10
|
+
path: string;
|
|
11
|
+
code: PricingCode;
|
|
12
|
+
vars: Readonly<Record<string, string | number>>;
|
|
13
|
+
}
|
|
14
|
+
/** The answers to an estimate's inputs: input id → option id. */
|
|
15
|
+
export type PricingInputs = Readonly<Record<string, string>>;
|
|
16
|
+
/** The labels of an input or an option, by locale. */
|
|
17
|
+
export type PricingLabels = PricingInput["labels"];
|
|
18
|
+
/**
|
|
19
|
+
* Every reason `value` is not a pricing model, and the model rebuilt from what was checked when
|
|
20
|
+
* there is none: taken whole or not at all, unknown fields refused rather than ignored. With
|
|
21
|
+
* `locales`, a model a site can show: every input and option labelled in each of them.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkPricingModel(value: unknown, locales?: readonly string[]): {
|
|
24
|
+
problems: PricingProblem[];
|
|
25
|
+
model: PricingModel | null;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* `cents × bp / 10 000`, rounded half up to the cent. Integers throughout: the product is exact
|
|
29
|
+
* (the model's limits keep it under 2^53), and the division is done on the remainder.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mulBp(cents: number, bp: number): number;
|
|
32
|
+
/** Half up to a multiple of `step` (≥ 1). */
|
|
33
|
+
export declare function roundTo(cents: number, step: number): number;
|
|
34
|
+
/**
|
|
35
|
+
* One line of how a price was reached; `cents` is what the line changed, so the lines add up to
|
|
36
|
+
* the total. `base` is the need's base (or the fixed price); `rounding` and `minimum` appear only
|
|
37
|
+
* when they changed something.
|
|
38
|
+
*/
|
|
39
|
+
export type PriceLine = {
|
|
40
|
+
kind: "base";
|
|
41
|
+
cents: number;
|
|
42
|
+
} | {
|
|
43
|
+
kind: PricingInputKind;
|
|
44
|
+
input: string;
|
|
45
|
+
option: string;
|
|
46
|
+
cents: number;
|
|
47
|
+
} | {
|
|
48
|
+
kind: "rounding";
|
|
49
|
+
cents: number;
|
|
50
|
+
} | {
|
|
51
|
+
kind: "minimum";
|
|
52
|
+
cents: number;
|
|
53
|
+
};
|
|
54
|
+
export interface Price {
|
|
55
|
+
cents: number;
|
|
56
|
+
breakdown: readonly PriceLine[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What a need costs for these answers (`contracts/vectors/pricing/README.md`, normative):
|
|
60
|
+
*
|
|
61
|
+
* 1. the need's base, plus every `add` input, in the need's input order;
|
|
62
|
+
* 2. times every `multiply` input, in order, each product rounded half up to the cent at once;
|
|
63
|
+
* 3. every `discount` input, in order, as a multiplication by `10 000 − discountBp`, rounded the
|
|
64
|
+
* same way;
|
|
65
|
+
* 4. the total rounded half up to a multiple of `roundToCents`;
|
|
66
|
+
* 5. raised to `minimumCents`.
|
|
67
|
+
*
|
|
68
|
+
* A `fixed` need is its price, as is. `null`: the need is not priced, or an input is unanswered
|
|
69
|
+
* or answered with an option it does not have — never a guess. Answers to inputs the need does
|
|
70
|
+
* not ask are ignored.
|
|
71
|
+
*/
|
|
72
|
+
export declare function priceOf(model: PricingModel, need: string, inputs: PricingInputs): Price | null;
|