@evinvest/sites-contract 0.1.2 → 0.1.3

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 CHANGED
@@ -11,14 +11,15 @@ src/generated/contract.ts panel_core's shapes, vocabularies and limits (`nix ru
11
11
  src/generated/wire.ts the protos as protojson: the envelope, every type's properties
12
12
  src/*.ts the one TS implementation of what both sides run, held to
13
13
  contracts/vectors with panel_core:
14
- pricing checkPricingModel, priceOf
15
- booking checkBookingConfig, checkBookingUrl, bookingRequested, isLeadRef
14
+ price priceOf: what a page runs pricing checkPricingModel
15
+ booking bookingRequested, isLeadRef booking-config checkBookingConfig, checkBookingUrl
16
16
  place checkPlaceLive: a place's live settings, field by field
17
17
  phone normalizePhone, phoneProblem
18
18
  ingest siteEvent, ingestBody, uuidV7, isOpaqueId: the only way a site builds an event
19
19
  experiments parseLiveExperiments, declarationProblem
20
20
  ```
21
21
 
22
+ A page bundles what it runs and not the panel's checks: they sit in modules of their own.
22
23
  Checks answer `{ path, code }` problems, never words: a site logs them, the panel's editor says
23
24
  each code in its own copy.
24
25
 
@@ -0,0 +1,22 @@
1
+ import type { BookingCode } from "./generated/codes.js";
2
+ import type { BookingConfig, PageProvider } from "./generated/types.js";
3
+ /** One thing wrong with a place's `booking`: where below it (`""`, `default`, `providers.cal_com.url`) and why. */
4
+ export interface BookingProblem {
5
+ path: string;
6
+ code: BookingCode;
7
+ }
8
+ /**
9
+ * Why `url` may not be `provider`'s booking page, or `null` (`contracts/vectors/booking/README.md`,
10
+ * normative): at most 2048 printable ASCII; the literal `https://`; no `#` and no `\`; an
11
+ * authority without `@`, `:` or `[`; a dotted DNS name whose last label is not a number; then
12
+ * the provider's own rule.
13
+ */
14
+ export declare function checkBookingUrl(provider: PageProvider, url: string): BookingCode | null;
15
+ /**
16
+ * A place's `booking` checked whole: every problem, and the config when there is none. `manual`
17
+ * is always available, so never a key of `providers`; the default is `manual` or one of them.
18
+ */
19
+ export declare function checkBookingConfig(v: unknown): {
20
+ problems: BookingProblem[];
21
+ config: BookingConfig | null;
22
+ };
@@ -0,0 +1,101 @@
1
+ import { isBookingProvider, isPageProvider } from "./booking.js";
2
+ import { BOOKING_LIMITS as LIMIT } from "./generated/booking.js";
3
+ import { at, isObject } from "./text.js";
4
+ const CAL_SEGMENT = /* @__PURE__ */ new RegExp(LIMIT.calComSegment);
5
+ const LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
6
+ const APPOINTMENTS = "/calendar/appointments/";
7
+ /**
8
+ * Why `url` may not be `provider`'s booking page, or `null` (`contracts/vectors/booking/README.md`,
9
+ * normative): at most 2048 printable ASCII; the literal `https://`; no `#` and no `\`; an
10
+ * authority without `@`, `:` or `[`; a dotted DNS name whose last label is not a number; then
11
+ * the provider's own rule.
12
+ */
13
+ export function checkBookingUrl(provider, url) {
14
+ if (url.length === 0 || url.length > LIMIT.maxUrl || !/^[\x21-\x7e]+$/.test(url))
15
+ return "format";
16
+ if (!url.startsWith("https://"))
17
+ return "scheme";
18
+ if (url.includes("#"))
19
+ return "fragment";
20
+ if (url.includes("\\"))
21
+ return "backslash";
22
+ const rest = url.slice("https://".length);
23
+ const split = rest.search(/[/?]/);
24
+ const host = split < 0 ? rest : rest.slice(0, split);
25
+ if (host.includes("@"))
26
+ return "userinfo";
27
+ if (host.startsWith("["))
28
+ return "ip";
29
+ if (host.includes(":"))
30
+ return "port";
31
+ const labels = host.split(".");
32
+ if (host.length > 253 || labels.length < 2 || !labels.every(l => LABEL.test(l)))
33
+ return "host";
34
+ const last = (labels.at(-1) ?? "").toLowerCase();
35
+ // A WHATWG parser reads a host whose last label is a number as an IPv4 address.
36
+ if (/^\d+$/.test(last) || last.startsWith("0x"))
37
+ return "ip";
38
+ const name = host.toLowerCase();
39
+ const path = (split < 0 ? "" : rest.slice(split)).split("?")[0] ?? "";
40
+ switch (provider) {
41
+ case "link":
42
+ return null;
43
+ case "google_calendar":
44
+ return (name === "calendar.app.google" && path.length > 1) || (name === "calendar.google.com" && path.length > APPOINTMENTS.length && path.startsWith(APPOINTMENTS)) ? null : "google";
45
+ case "cal_com": {
46
+ if (!LIMIT.calComHosts.some(h => h === name))
47
+ return "cal_host";
48
+ const segments = path.replace(/^\//, "").split("/");
49
+ return path.startsWith("/") && segments.length === 2 && segments.every(s => CAL_SEGMENT.test(s)) ? null : "cal_path";
50
+ }
51
+ }
52
+ }
53
+ /**
54
+ * A place's `booking` checked whole: every problem, and the config when there is none. `manual`
55
+ * is always available, so never a key of `providers`; the default is `manual` or one of them.
56
+ */
57
+ export function checkBookingConfig(v) {
58
+ const problems = [];
59
+ const fail = (path, code) => problems.push({ path, code });
60
+ if (!isObject(v))
61
+ return { problems: [{ path: "", code: "shape" }], config: null };
62
+ if (Object.keys(v).some(k => k !== "default" && k !== "providers"))
63
+ fail("", "unknown_key");
64
+ const def = isBookingProvider(v.default) ? v.default : null;
65
+ if (!Object.hasOwn(v, "default"))
66
+ fail("default", "required");
67
+ else if (def === null)
68
+ fail("default", "default_unknown");
69
+ const providers = {};
70
+ if (!Object.hasOwn(v, "providers"))
71
+ fail("providers", "required");
72
+ else if (!isObject(v.providers))
73
+ fail("providers", "shape");
74
+ else
75
+ for (const [name, conf] of Object.entries(v.providers)) {
76
+ const path = at("providers", name);
77
+ if (!isBookingProvider(name))
78
+ fail(path, "provider_unknown");
79
+ else if (!isObject(conf))
80
+ fail(path, "provider_shape");
81
+ else if (Object.keys(conf).some(k => k !== "url"))
82
+ fail(path, "provider_extra");
83
+ else if (!isPageProvider(name))
84
+ fail(path, "manual");
85
+ else if (!Object.hasOwn(conf, "url"))
86
+ fail(at(path, "url"), "required");
87
+ else if (typeof conf.url !== "string")
88
+ fail(at(path, "url"), "url_type");
89
+ else {
90
+ const bad = checkBookingUrl(name, conf.url);
91
+ if (bad)
92
+ fail(at(path, "url"), bad);
93
+ else
94
+ providers[name] = { url: conf.url };
95
+ }
96
+ }
97
+ // A default whose own entry is refused is blamed there, not twice.
98
+ if (isPageProvider(def) && !Object.hasOwn(providers, def) && !problems.some(p => p.path.startsWith(at("providers", def))))
99
+ fail("default", "default_unset");
100
+ return problems.length > 0 || def === null ? { problems, config: null } : { problems, config: { default: def, providers } };
101
+ }
package/dist/booking.d.ts CHANGED
@@ -1,31 +1,10 @@
1
1
  import { type DayPart } from "./generated/booking.js";
2
- import type { BookingCode } from "./generated/codes.js";
3
- import type { BookingConfig, BookingProvider, PageProvider } from "./generated/types.js";
2
+ import type { BookingProvider, PageProvider } from "./generated/types.js";
4
3
  import type { BookingRequestedV1 } from "./generated/wire.js";
5
- /** One thing wrong with a place's `booking`: where below it (`""`, `default`, `providers.cal_com.url`) and why. */
6
- export interface BookingProblem {
7
- path: string;
8
- code: BookingCode;
9
- }
10
4
  export declare const isBookingProvider: (v: unknown) => v is BookingProvider;
11
5
  export declare const isPageProvider: (v: unknown) => v is PageProvider;
12
6
  /** `lead-<row>-<8 lowercase hex>`, the row 1 or more: the panel's lead id for a site's lead. */
13
7
  export declare const isLeadRef: (v: unknown) => v is string;
14
- /**
15
- * Why `url` may not be `provider`'s booking page, or `null` (`contracts/vectors/booking/README.md`,
16
- * normative): at most 2048 printable ASCII; the literal `https://`; no `#` and no `\`; an
17
- * authority without `@`, `:` or `[`; a dotted DNS name whose last label is not a number; then
18
- * the provider's own rule.
19
- */
20
- export declare function checkBookingUrl(provider: PageProvider, url: string): BookingCode | null;
21
- /**
22
- * A place's `booking` checked whole: every problem, and the config when there is none. `manual`
23
- * is always available, so never a key of `providers`; the default is `manual` or one of them.
24
- */
25
- export declare function checkBookingConfig(v: unknown): {
26
- problems: BookingProblem[];
27
- config: BookingConfig | null;
28
- };
29
8
  /** `booking.requested@1`'s properties, their vocabularies as the panel checks them. */
30
9
  export type BookingRequested = BookingRequestedV1 & {
31
10
  provider: BookingProvider;
package/dist/booking.js CHANGED
@@ -1,108 +1,10 @@
1
1
  import { BOOKING_LIMITS as LIMIT, BOOKING_PROVIDERS, DAY_PARTS, PAGE_PROVIDERS } from "./generated/booking.js";
2
- import { at, isDate, isObject } from "./text.js";
2
+ import { isDate, isObject } from "./text.js";
3
3
  export const isBookingProvider = (v) => BOOKING_PROVIDERS.some(p => p === v);
4
4
  export const isPageProvider = (v) => PAGE_PROVIDERS.some(p => p === v);
5
5
  const LEAD_REF = /* @__PURE__ */ new RegExp(LIMIT.leadRef);
6
- const CAL_SEGMENT = /* @__PURE__ */ new RegExp(LIMIT.calComSegment);
7
- const LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
8
- const APPOINTMENTS = "/calendar/appointments/";
9
6
  /** `lead-<row>-<8 lowercase hex>`, the row 1 or more: the panel's lead id for a site's lead. */
10
7
  export const isLeadRef = (v) => typeof v === "string" && LEAD_REF.test(v);
11
- /**
12
- * Why `url` may not be `provider`'s booking page, or `null` (`contracts/vectors/booking/README.md`,
13
- * normative): at most 2048 printable ASCII; the literal `https://`; no `#` and no `\`; an
14
- * authority without `@`, `:` or `[`; a dotted DNS name whose last label is not a number; then
15
- * the provider's own rule.
16
- */
17
- export function checkBookingUrl(provider, url) {
18
- if (url.length === 0 || url.length > LIMIT.maxUrl || !/^[\x21-\x7e]+$/.test(url))
19
- return "format";
20
- if (!url.startsWith("https://"))
21
- return "scheme";
22
- if (url.includes("#"))
23
- return "fragment";
24
- if (url.includes("\\"))
25
- return "backslash";
26
- const rest = url.slice("https://".length);
27
- const split = rest.search(/[/?]/);
28
- const host = split < 0 ? rest : rest.slice(0, split);
29
- if (host.includes("@"))
30
- return "userinfo";
31
- if (host.startsWith("["))
32
- return "ip";
33
- if (host.includes(":"))
34
- return "port";
35
- const labels = host.split(".");
36
- if (host.length > 253 || labels.length < 2 || !labels.every(l => LABEL.test(l)))
37
- return "host";
38
- const last = (labels.at(-1) ?? "").toLowerCase();
39
- // A WHATWG parser reads a host whose last label is a number as an IPv4 address.
40
- if (/^\d+$/.test(last) || last.startsWith("0x"))
41
- return "ip";
42
- const name = host.toLowerCase();
43
- const path = (split < 0 ? "" : rest.slice(split)).split("?")[0] ?? "";
44
- switch (provider) {
45
- case "link":
46
- return null;
47
- case "google_calendar":
48
- return (name === "calendar.app.google" && path.length > 1) || (name === "calendar.google.com" && path.length > APPOINTMENTS.length && path.startsWith(APPOINTMENTS)) ? null : "google";
49
- case "cal_com": {
50
- if (!LIMIT.calComHosts.some(h => h === name))
51
- return "cal_host";
52
- const segments = path.replace(/^\//, "").split("/");
53
- return path.startsWith("/") && segments.length === 2 && segments.every(s => CAL_SEGMENT.test(s)) ? null : "cal_path";
54
- }
55
- }
56
- }
57
- /**
58
- * A place's `booking` checked whole: every problem, and the config when there is none. `manual`
59
- * is always available, so never a key of `providers`; the default is `manual` or one of them.
60
- */
61
- export function checkBookingConfig(v) {
62
- const problems = [];
63
- const fail = (path, code) => problems.push({ path, code });
64
- if (!isObject(v))
65
- return { problems: [{ path: "", code: "shape" }], config: null };
66
- if (Object.keys(v).some(k => k !== "default" && k !== "providers"))
67
- fail("", "unknown_key");
68
- const def = isBookingProvider(v.default) ? v.default : null;
69
- if (!Object.hasOwn(v, "default"))
70
- fail("default", "required");
71
- else if (def === null)
72
- fail("default", "default_unknown");
73
- const providers = {};
74
- if (!Object.hasOwn(v, "providers"))
75
- fail("providers", "required");
76
- else if (!isObject(v.providers))
77
- fail("providers", "shape");
78
- else
79
- for (const [name, conf] of Object.entries(v.providers)) {
80
- const path = at("providers", name);
81
- if (!isBookingProvider(name))
82
- fail(path, "provider_unknown");
83
- else if (!isObject(conf))
84
- fail(path, "provider_shape");
85
- else if (Object.keys(conf).some(k => k !== "url"))
86
- fail(path, "provider_extra");
87
- else if (!isPageProvider(name))
88
- fail(path, "manual");
89
- else if (!Object.hasOwn(conf, "url"))
90
- fail(at(path, "url"), "required");
91
- else if (typeof conf.url !== "string")
92
- fail(at(path, "url"), "url_type");
93
- else {
94
- const bad = checkBookingUrl(name, conf.url);
95
- if (bad)
96
- fail(at(path, "url"), bad);
97
- else
98
- providers[name] = { url: conf.url };
99
- }
100
- }
101
- // A default whose own entry is refused is blamed there, not twice.
102
- if (isPageProvider(def) && !Object.hasOwn(providers, def) && !problems.some(p => p.path.startsWith(at("providers", def))))
103
- fail("default", "default_unset");
104
- return problems.length > 0 || def === null ? { problems, config: null } : { problems, config: { default: def, providers } };
105
- }
106
8
  const isDayPart = (v) => DAY_PARTS.some(p => p === v);
107
9
  /**
108
10
  * `booking.requested@1`'s properties, or `null` when the panel would refuse them: a lead ref, a
package/dist/index.d.ts CHANGED
@@ -6,9 +6,11 @@ export * from "./generated/place.js";
6
6
  export * from "./generated/pricing.js";
7
7
  export type * from "./generated/types.js";
8
8
  export * from "./generated/wire.js";
9
- export { checkBookingConfig, checkBookingUrl, bookingRequested, isBookingProvider, isLeadRef, isPageProvider, preferredDateInWindow, type BookingProblem, type BookingRequested } from "./booking.js";
9
+ export { bookingRequested, isBookingProvider, isLeadRef, isPageProvider, preferredDateInWindow, type BookingRequested } from "./booking.js";
10
+ export { checkBookingConfig, checkBookingUrl, type BookingProblem } from "./booking-config.js";
10
11
  export { declarationProblem, parseLiveExperiments, type DeclarationProblem } from "./experiments.js";
11
12
  export { ingestBody, isOpaqueId, siteEvent, uuidV7, type LeadCreated, type SiteEvent, type SiteEventHead, type SiteProperties } from "./ingest.js";
12
13
  export { normalizePhone, phoneProblem } from "./phone.js";
13
14
  export { checkPlaceLive, isInstant, type PlaceProblem } from "./place.js";
14
- export { checkPricingModel, mulBp, priceOf, roundTo, type Price, type PriceLine, type PricingInputs, type PricingLabels, type PricingProblem } from "./pricing.js";
15
+ export { mulBp, priceOf, roundTo, type Price, type PriceLine, type PricingInputs, type PricingLabels } from "./price.js";
16
+ export { checkPricingModel, type PricingProblem } from "./pricing.js";
package/dist/index.js CHANGED
@@ -5,9 +5,11 @@ export * from "./generated/lead.js";
5
5
  export * from "./generated/place.js";
6
6
  export * from "./generated/pricing.js";
7
7
  export * from "./generated/wire.js";
8
- export { checkBookingConfig, checkBookingUrl, bookingRequested, isBookingProvider, isLeadRef, isPageProvider, preferredDateInWindow } from "./booking.js";
8
+ export { bookingRequested, isBookingProvider, isLeadRef, isPageProvider, preferredDateInWindow } from "./booking.js";
9
+ export { checkBookingConfig, checkBookingUrl } from "./booking-config.js";
9
10
  export { declarationProblem, parseLiveExperiments } from "./experiments.js";
10
11
  export { ingestBody, isOpaqueId, siteEvent, uuidV7 } from "./ingest.js";
11
12
  export { normalizePhone, phoneProblem } from "./phone.js";
12
13
  export { checkPlaceLive, isInstant } from "./place.js";
13
- export { checkPricingModel, mulBp, priceOf, roundTo } from "./pricing.js";
14
+ export { mulBp, priceOf, roundTo } from "./price.js";
15
+ export { checkPricingModel } from "./pricing.js";
package/dist/place.js CHANGED
@@ -1,4 +1,4 @@
1
- import { checkBookingConfig } from "./booking.js";
1
+ import { checkBookingConfig } from "./booking-config.js";
2
2
  import { DAYS, MESSENGERS, PLACE_LIMITS as LIMIT } from "./generated/place.js";
3
3
  import { at, charCount, hasControl, hasWhitespace, isDate, isObject, trim } from "./text.js";
4
4
  const E164 = /* @__PURE__ */ new RegExp(LIMIT.e164);
@@ -0,0 +1,57 @@
1
+ import type { PricingInputKind } from "./generated/pricing.js";
2
+ import type { PricingInput, PricingModel } from "./generated/types.js";
3
+ /** The answers to an estimate's inputs: input id → option id. */
4
+ export type PricingInputs = Readonly<Record<string, string>>;
5
+ /** The labels of an input or an option, by locale. */
6
+ export type PricingLabels = PricingInput["labels"];
7
+ /** The effect of each option of an input, in the unit its kind says. */
8
+ export declare function effects(input: PricingInput): {
9
+ id: string;
10
+ effect: number;
11
+ }[];
12
+ /**
13
+ * `cents × bp / 10 000`, rounded half up to the cent. Integers throughout: the product is exact
14
+ * (the model's limits keep it under 2^53), and the division is done on the remainder.
15
+ */
16
+ export declare function mulBp(cents: number, bp: number): number;
17
+ /** Half up to a multiple of `step` (≥ 1). */
18
+ export declare function roundTo(cents: number, step: number): number;
19
+ /**
20
+ * One line of how a price was reached; `cents` is what the line changed, so the lines add up to
21
+ * the total. `base` is the need's base (or the fixed price); `rounding` and `minimum` appear only
22
+ * when they changed something.
23
+ */
24
+ export type PriceLine = {
25
+ kind: "base";
26
+ cents: number;
27
+ } | {
28
+ kind: PricingInputKind;
29
+ input: string;
30
+ option: string;
31
+ cents: number;
32
+ } | {
33
+ kind: "rounding";
34
+ cents: number;
35
+ } | {
36
+ kind: "minimum";
37
+ cents: number;
38
+ };
39
+ export interface Price {
40
+ cents: number;
41
+ breakdown: readonly PriceLine[];
42
+ }
43
+ /**
44
+ * What a need costs for these answers (`contracts/vectors/pricing/README.md`, normative):
45
+ *
46
+ * 1. the need's base, plus every `add` input, in the need's input order;
47
+ * 2. times every `multiply` input, in order, each product rounded half up to the cent at once;
48
+ * 3. every `discount` input, in order, as a multiplication by `10 000 − discountBp`, rounded the
49
+ * same way;
50
+ * 4. the total rounded half up to a multiple of `roundToCents`;
51
+ * 5. raised to `minimumCents`.
52
+ *
53
+ * A `fixed` need is its price, as is. `null`: the need is not priced, or an input is unanswered
54
+ * or answered with an option it does not have — never a guess. Answers to inputs the need does
55
+ * not ask are ignored.
56
+ */
57
+ export declare function priceOf(model: PricingModel, need: string, inputs: PricingInputs): Price | null;
package/dist/price.js ADDED
@@ -0,0 +1,78 @@
1
+ /** The effect of each option of an input, in the unit its kind says. */
2
+ export function effects(input) {
3
+ switch (input.kind) {
4
+ case "add":
5
+ return input.options.map(o => ({ id: o.id, effect: o.addCents }));
6
+ case "multiply":
7
+ return input.options.map(o => ({ id: o.id, effect: o.multiplyBp }));
8
+ case "discount":
9
+ return input.options.map(o => ({ id: o.id, effect: o.discountBp }));
10
+ }
11
+ }
12
+ const BP = 10_000;
13
+ /**
14
+ * `cents × bp / 10 000`, rounded half up to the cent. Integers throughout: the product is exact
15
+ * (the model's limits keep it under 2^53), and the division is done on the remainder.
16
+ */
17
+ export function mulBp(cents, bp) {
18
+ const n = cents * bp + BP / 2;
19
+ return (n - (n % BP)) / BP;
20
+ }
21
+ /** Half up to a multiple of `step` (≥ 1). */
22
+ export function roundTo(cents, step) {
23
+ const rest = cents % step;
24
+ return rest * 2 >= step ? cents - rest + step : cents - rest;
25
+ }
26
+ /**
27
+ * What a need costs for these answers (`contracts/vectors/pricing/README.md`, normative):
28
+ *
29
+ * 1. the need's base, plus every `add` input, in the need's input order;
30
+ * 2. times every `multiply` input, in order, each product rounded half up to the cent at once;
31
+ * 3. every `discount` input, in order, as a multiplication by `10 000 − discountBp`, rounded the
32
+ * same way;
33
+ * 4. the total rounded half up to a multiple of `roundToCents`;
34
+ * 5. raised to `minimumCents`.
35
+ *
36
+ * A `fixed` need is its price, as is. `null`: the need is not priced, or an input is unanswered
37
+ * or answered with an option it does not have — never a guess. Answers to inputs the need does
38
+ * not ask are ignored.
39
+ */
40
+ export function priceOf(model, need, inputs) {
41
+ const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
42
+ if (!pricing)
43
+ return null;
44
+ if (pricing.kind === "fixed")
45
+ return { cents: pricing.cents, breakdown: [{ kind: "base", cents: pricing.cents }] };
46
+ const answers = [];
47
+ for (const id of pricing.inputs) {
48
+ const input = model.inputs.find(i => i.id === id);
49
+ const answer = Object.hasOwn(inputs, id) ? inputs[id] : undefined;
50
+ const option = input && effects(input).find(o => o.id === answer);
51
+ if (!input || !option)
52
+ return null;
53
+ answers.push({ input, option: option.id, effect: option.effect });
54
+ }
55
+ const lines = [{ kind: "base", cents: pricing.baseCents }];
56
+ let total = pricing.baseCents;
57
+ const step = (kind, apply) => {
58
+ for (const a of answers) {
59
+ if (a.input.kind !== kind)
60
+ continue;
61
+ const next = apply(total, a.effect);
62
+ lines.push({ kind, input: a.input.id, option: a.option, cents: next - total });
63
+ total = next;
64
+ }
65
+ };
66
+ step("add", (t, cents) => t + cents);
67
+ step("multiply", (t, bp) => mulBp(t, bp));
68
+ step("discount", (t, bp) => mulBp(t, BP - bp));
69
+ const rounded = roundTo(total, model.roundToCents);
70
+ if (rounded !== total)
71
+ lines.push({ kind: "rounding", cents: rounded - total });
72
+ total = rounded;
73
+ if (total < model.minimumCents) {
74
+ lines.push({ kind: "minimum", cents: model.minimumCents - total });
75
+ total = model.minimumCents;
76
+ }
77
+ return { cents: total, breakdown: lines };
78
+ }
package/dist/pricing.d.ts CHANGED
@@ -1,6 +1,5 @@
1
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";
2
+ import type { PricingModel } from "./generated/types.js";
4
3
  /**
5
4
  * One thing wrong with a model: its path from the model's root (`inputs[0].options[2].addCents`,
6
5
  * `""` for the model itself), the code the panel names it by too, and what the code's words
@@ -11,10 +10,6 @@ export interface PricingProblem {
11
10
  code: PricingCode;
12
11
  vars: Readonly<Record<string, string | number>>;
13
12
  }
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
13
  /**
19
14
  * Every reason `value` is not a pricing model, and the model rebuilt from what was checked when
20
15
  * there is none: taken whole or not at all, unknown fields refused rather than ignored. With
@@ -24,49 +19,3 @@ export declare function checkPricingModel(value: unknown, locales?: readonly str
24
19
  problems: PricingProblem[];
25
20
  model: PricingModel | null;
26
21
  };
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;
package/dist/pricing.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { INPUT_KINDS, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS as LIMIT } from "./generated/pricing.js";
2
+ import { effects, mulBp } from "./price.js";
2
3
  import { at, isDate, isObject, trim } from "./text.js";
3
4
  const SLUG = /* @__PURE__ */ new RegExp(LIMIT.slug);
4
5
  const LOCALE = /* @__PURE__ */ new RegExp(LIMIT.locale);
@@ -116,17 +117,6 @@ function need(c, path, v, inputs) {
116
117
  c.fail(at(at(path, "inputs"), i), "noInput", { id });
117
118
  return { kind: "estimate", baseCents, inputs: ids };
118
119
  }
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
120
  /**
131
121
  * The dearest answer to every input — each effect only raises the price with its value, and
132
122
  * rounding half up is monotonic — walked step by step: if no step of it passes the cap, no
@@ -204,70 +194,3 @@ export function checkPricingModel(value, locales = []) {
204
194
  return { problems: c.problems, model: null };
205
195
  return { problems: [], model: { format: PRICING_FORMAT, currency: PRICING_CURRENCY, validFrom: String(value.validFrom), roundToCents, minimumCents, inputs, needs } };
206
196
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/sites-contract",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
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
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {