@sincpro/client-framework 0.2.0 → 0.4.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/AGENTS.md +51 -23
- package/MANIFESTO.md +44 -26
- package/README.md +304 -29
- package/dist/adapters/opentelemetry.d.ts +1 -1
- package/dist/adapters/opentelemetry.js +1 -1
- package/dist/adapters/sentry.d.ts +2 -2
- package/dist/adapters/sentry.js +2 -2
- package/dist/client/memory.js +14 -6
- package/dist/common/decimal.d.ts +49 -0
- package/dist/common/decimal.js +146 -0
- package/dist/{client → common}/ids.d.ts +7 -0
- package/dist/{client → common}/ids.js +12 -0
- package/dist/common/money.d.ts +74 -0
- package/dist/common/money.js +168 -0
- package/dist/common/serialization.d.ts +49 -0
- package/dist/common/serialization.js +155 -0
- package/dist/common/time.d.ts +40 -0
- package/dist/common/time.js +93 -0
- package/dist/context/codec.d.ts +24 -0
- package/dist/context/codec.js +28 -0
- package/dist/context/context.d.ts +45 -0
- package/dist/context/context.js +70 -0
- package/dist/context/store.d.ts +51 -0
- package/dist/context/store.js +59 -0
- package/dist/index.d.ts +17 -5
- package/dist/index.js +17 -5
- package/dist/observability/ports.d.ts +2 -2
- package/dist/services/bounded-context.d.ts +79 -0
- package/dist/services/bounded-context.js +272 -0
- package/dist/services/execution.d.ts +8 -8
- package/dist/services/execution.js +8 -6
- package/dist/services/feature.d.ts +70 -48
- package/dist/services/feature.js +57 -191
- package/dist/services/process.d.ts +60 -0
- package/dist/services/process.js +57 -0
- package/dist/testing/index.d.ts +3 -3
- package/package.json +2 -2
- package/dist/services/runtime.d.ts +0 -48
- package/dist/services/runtime.js +0 -30
package/dist/adapters/sentry.js
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
|
|
6
6
|
* const reporter = sentryReporter(client);
|
|
7
|
-
*
|
|
7
|
+
* configureProcess({ reporter });
|
|
8
8
|
*
|
|
9
9
|
* Context: it imports nothing — it takes the client the platform made (with its DSN, release
|
|
10
10
|
* and data rules), so it costs no dependency and works on every platform. What is reported is
|
|
11
|
-
* already decided by the
|
|
11
|
+
* already decided by the process (`outcomeOf`: only `failed`); this only says where it happened.
|
|
12
12
|
* The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
|
|
13
13
|
* values are not the place for that.
|
|
14
14
|
*/
|
package/dist/client/memory.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { TransportError, } from "./transport.js";
|
|
2
|
+
import { readValues, valuesOf } from "../common/serialization.js";
|
|
2
3
|
import { answer, answerGroups } from "@sincpro/criteria/engine";
|
|
4
|
+
/**
|
|
5
|
+
* A deep copy written and read by the wire's rules (`valuesOf`, `readValues`): what a real server
|
|
6
|
+
* would have received and answered — a `Money` as `{ amount, currency }`, a `Decimal` as its text,
|
|
7
|
+
* a `Date` as an instant, bytes as bytes — the same on every platform. `structuredClone` kept a
|
|
8
|
+
* `Date` a `Date` in a browser and a JSON round trip made it text on a phone; neither is a wire.
|
|
9
|
+
*/
|
|
10
|
+
const copyOf = (value) => value === undefined ? value : readValues(valuesOf(value));
|
|
3
11
|
function wait(ms, signal) {
|
|
4
12
|
if (ms <= 0)
|
|
5
13
|
return Promise.resolve();
|
|
@@ -46,7 +54,7 @@ export function memoryTransport(tables, options = {}) {
|
|
|
46
54
|
? answerGroups(table.rows, criteria, table.meta)
|
|
47
55
|
: answer(table.rows, criteria, table.meta);
|
|
48
56
|
// A copy, as over a wire: a screen holding a row must not see the server change it.
|
|
49
|
-
return { ...page, rows:
|
|
57
|
+
return { ...page, rows: copyOf(page.rows) };
|
|
50
58
|
},
|
|
51
59
|
async one(route, signal) {
|
|
52
60
|
await check(route, signal);
|
|
@@ -66,7 +74,7 @@ export function memoryTransport(tables, options = {}) {
|
|
|
66
74
|
detail: `${table.meta.aggregate} ${id} does not exist.`,
|
|
67
75
|
});
|
|
68
76
|
return {
|
|
69
|
-
record:
|
|
77
|
+
record: copyOf(record),
|
|
70
78
|
meta: table.meta,
|
|
71
79
|
dropped: [],
|
|
72
80
|
draft: options.draft?.(route) ?? null,
|
|
@@ -78,7 +86,7 @@ export function memoryTransport(tables, options = {}) {
|
|
|
78
86
|
// sincpro_framework's `@idempotency` does with a repeated `Idempotency-Key`.
|
|
79
87
|
const key = sending?.idempotencyKey;
|
|
80
88
|
if (key !== undefined && answered.has(key))
|
|
81
|
-
return
|
|
89
|
+
return copyOf(answered.get(key));
|
|
82
90
|
if (options.handle === undefined)
|
|
83
91
|
throw new TransportError(route, 404, {
|
|
84
92
|
kind: "not_found",
|
|
@@ -90,9 +98,9 @@ export function memoryTransport(tables, options = {}) {
|
|
|
90
98
|
detail: `Nothing answers ${method} ${route}.`,
|
|
91
99
|
});
|
|
92
100
|
// A deep copy: what the caller sent is not what the server keeps.
|
|
93
|
-
const answer = await options.handle(method, route,
|
|
101
|
+
const answer = await options.handle(method, route, copyOf(body));
|
|
94
102
|
if (key !== undefined)
|
|
95
|
-
answered.set(key,
|
|
103
|
+
answered.set(key, copyOf(answer));
|
|
96
104
|
return answer;
|
|
97
105
|
},
|
|
98
106
|
...(options.read === undefined
|
|
@@ -101,7 +109,7 @@ export function memoryTransport(tables, options = {}) {
|
|
|
101
109
|
async read(route, signal) {
|
|
102
110
|
await check(route, signal);
|
|
103
111
|
// A copy, as every answer: over a wire nobody holds the server's object.
|
|
104
|
-
return
|
|
112
|
+
return copyOf(await options.read(route));
|
|
105
113
|
},
|
|
106
114
|
}),
|
|
107
115
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An exact decimal — what sincpro_framework's `Decimal` is on the wire: text, every digit kept.
|
|
3
|
+
*
|
|
4
|
+
* new Decimal("10.50").plus("0.25") → 10.75
|
|
5
|
+
* new Decimal("2.500").times("19.90") → 49.75000
|
|
6
|
+
* new Decimal("10").dividedBy("3", 2) → 3.33 half up, as the backend rounds
|
|
7
|
+
* JSON.stringify({ qty: new Decimal("2.500") }) → {"qty":"2.500"}
|
|
8
|
+
*
|
|
9
|
+
* Context: the backend sends a decimal as text (`"10.50"`) so no client reads it as a float —
|
|
10
|
+
* `0.1 + 0.2` is `0.30000000000000004` in every JavaScript engine, and an invoice cannot be. A
|
|
11
|
+
* client that adds, multiplies or rounds an amount (a cart, a line, a discount) does it here,
|
|
12
|
+
* on `BigInt`, which browsers, Node and Hermes have; it depends on nothing. It never rounds by
|
|
13
|
+
* accident: `plus`, `minus` and `times` are exact, and only `rounded` and `dividedBy` round —
|
|
14
|
+
* half up, away from zero, the backend's `ROUND_HALF_UP`. Formatting it for a person is the
|
|
15
|
+
* platform's (`Intl.NumberFormat`), never this.
|
|
16
|
+
*/
|
|
17
|
+
export type DecimalLike = Decimal | string | number | bigint;
|
|
18
|
+
export declare class Decimal {
|
|
19
|
+
/** The digits, as an integer: `10.50` is `1050n` at scale 2. */
|
|
20
|
+
readonly units: bigint;
|
|
21
|
+
/** How many of the digits are after the point. */
|
|
22
|
+
readonly scale: number;
|
|
23
|
+
/**
|
|
24
|
+
* A decimal from its text (`"10.50"`, `"-0.5"`, `"1e3"`), a number — taken by its digits,
|
|
25
|
+
* `0.1` is `"0.1"` — or a whole `bigint`. Anything else is refused, naming it.
|
|
26
|
+
*/
|
|
27
|
+
constructor(value: DecimalLike);
|
|
28
|
+
static of(value: DecimalLike): Decimal;
|
|
29
|
+
private static at;
|
|
30
|
+
/** Both at the larger scale, so their units compare and add. */
|
|
31
|
+
private static aligned;
|
|
32
|
+
plus(other: DecimalLike): Decimal;
|
|
33
|
+
minus(other: DecimalLike): Decimal;
|
|
34
|
+
times(factor: DecimalLike): Decimal;
|
|
35
|
+
/** This over `divisor`, with `decimals` digits after the point, rounded half up. */
|
|
36
|
+
dividedBy(divisor: DecimalLike, decimals: number): Decimal;
|
|
37
|
+
/** With `decimals` digits after the point: rounded half up when it had more, padded when fewer. */
|
|
38
|
+
rounded(decimals: number): Decimal;
|
|
39
|
+
negated(): Decimal;
|
|
40
|
+
/** `-1`, `0` or `1`: `10.5` and `10.50` are the same amount. */
|
|
41
|
+
compare(other: DecimalLike): -1 | 0 | 1;
|
|
42
|
+
equals(other: DecimalLike): boolean;
|
|
43
|
+
get isZero(): boolean;
|
|
44
|
+
get isNegative(): boolean;
|
|
45
|
+
get isPositive(): boolean;
|
|
46
|
+
/** The text the wire carries: every digit, the scale kept (`"10.50"`, never `"10.5"`). */
|
|
47
|
+
toString(): string;
|
|
48
|
+
toJSON(): string;
|
|
49
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An exact decimal — what sincpro_framework's `Decimal` is on the wire: text, every digit kept.
|
|
3
|
+
*
|
|
4
|
+
* new Decimal("10.50").plus("0.25") → 10.75
|
|
5
|
+
* new Decimal("2.500").times("19.90") → 49.75000
|
|
6
|
+
* new Decimal("10").dividedBy("3", 2) → 3.33 half up, as the backend rounds
|
|
7
|
+
* JSON.stringify({ qty: new Decimal("2.500") }) → {"qty":"2.500"}
|
|
8
|
+
*
|
|
9
|
+
* Context: the backend sends a decimal as text (`"10.50"`) so no client reads it as a float —
|
|
10
|
+
* `0.1 + 0.2` is `0.30000000000000004` in every JavaScript engine, and an invoice cannot be. A
|
|
11
|
+
* client that adds, multiplies or rounds an amount (a cart, a line, a discount) does it here,
|
|
12
|
+
* on `BigInt`, which browsers, Node and Hermes have; it depends on nothing. It never rounds by
|
|
13
|
+
* accident: `plus`, `minus` and `times` are exact, and only `rounded` and `dividedBy` round —
|
|
14
|
+
* half up, away from zero, the backend's `ROUND_HALF_UP`. Formatting it for a person is the
|
|
15
|
+
* platform's (`Intl.NumberFormat`), never this.
|
|
16
|
+
*/
|
|
17
|
+
const NUMBER = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d{1,4}))?$/;
|
|
18
|
+
const TEN = 10n;
|
|
19
|
+
function pow10(exponent) {
|
|
20
|
+
return TEN ** BigInt(exponent);
|
|
21
|
+
}
|
|
22
|
+
/** `units / divisor` rounded half up, away from zero. */
|
|
23
|
+
function halfUp(units, divisor) {
|
|
24
|
+
const quotient = units / divisor;
|
|
25
|
+
const rest = units % divisor;
|
|
26
|
+
const twice = (rest < 0n ? -rest : rest) * 2n;
|
|
27
|
+
const whole = divisor < 0n ? -divisor : divisor;
|
|
28
|
+
if (twice < whole)
|
|
29
|
+
return quotient;
|
|
30
|
+
return units < 0n !== divisor < 0n ? quotient - 1n : quotient + 1n;
|
|
31
|
+
}
|
|
32
|
+
export class Decimal {
|
|
33
|
+
/** The digits, as an integer: `10.50` is `1050n` at scale 2. */
|
|
34
|
+
units;
|
|
35
|
+
/** How many of the digits are after the point. */
|
|
36
|
+
scale;
|
|
37
|
+
/**
|
|
38
|
+
* A decimal from its text (`"10.50"`, `"-0.5"`, `"1e3"`), a number — taken by its digits,
|
|
39
|
+
* `0.1` is `"0.1"` — or a whole `bigint`. Anything else is refused, naming it.
|
|
40
|
+
*/
|
|
41
|
+
constructor(value) {
|
|
42
|
+
if (value instanceof Decimal) {
|
|
43
|
+
this.units = value.units;
|
|
44
|
+
this.scale = value.scale;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === "bigint") {
|
|
48
|
+
this.units = value;
|
|
49
|
+
this.scale = 0;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
53
|
+
throw new TypeError(`${value} is not a decimal`);
|
|
54
|
+
const text = typeof value === "number" ? String(value) : String(value).trim();
|
|
55
|
+
const match = NUMBER.exec(text);
|
|
56
|
+
const [, sign = "", whole = "", fraction = "", exponent = "0"] = match ?? [];
|
|
57
|
+
if (match === null || whole + fraction === "")
|
|
58
|
+
throw new TypeError(`"${text}" is not a decimal`);
|
|
59
|
+
let digits = whole + fraction;
|
|
60
|
+
let scale = fraction.length - Number(exponent);
|
|
61
|
+
if (scale < 0) {
|
|
62
|
+
digits += "0".repeat(-scale);
|
|
63
|
+
scale = 0;
|
|
64
|
+
}
|
|
65
|
+
this.units = BigInt(digits) * (sign === "-" ? -1n : 1n);
|
|
66
|
+
this.scale = scale;
|
|
67
|
+
}
|
|
68
|
+
static of(value) {
|
|
69
|
+
return value instanceof Decimal ? value : new Decimal(value);
|
|
70
|
+
}
|
|
71
|
+
static at(units, scale) {
|
|
72
|
+
const made = Object.create(Decimal.prototype);
|
|
73
|
+
made.units = units;
|
|
74
|
+
made.scale = scale;
|
|
75
|
+
return made;
|
|
76
|
+
}
|
|
77
|
+
/** Both at the larger scale, so their units compare and add. */
|
|
78
|
+
static aligned(one, other) {
|
|
79
|
+
const scale = Math.max(one.scale, other.scale);
|
|
80
|
+
return [
|
|
81
|
+
one.units * pow10(scale - one.scale),
|
|
82
|
+
other.units * pow10(scale - other.scale),
|
|
83
|
+
scale,
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
plus(other) {
|
|
87
|
+
const [one, two, scale] = Decimal.aligned(this, Decimal.of(other));
|
|
88
|
+
return Decimal.at(one + two, scale);
|
|
89
|
+
}
|
|
90
|
+
minus(other) {
|
|
91
|
+
const [one, two, scale] = Decimal.aligned(this, Decimal.of(other));
|
|
92
|
+
return Decimal.at(one - two, scale);
|
|
93
|
+
}
|
|
94
|
+
times(factor) {
|
|
95
|
+
const by = Decimal.of(factor);
|
|
96
|
+
return Decimal.at(this.units * by.units, this.scale + by.scale);
|
|
97
|
+
}
|
|
98
|
+
/** This over `divisor`, with `decimals` digits after the point, rounded half up. */
|
|
99
|
+
dividedBy(divisor, decimals) {
|
|
100
|
+
const by = Decimal.of(divisor);
|
|
101
|
+
if (by.units === 0n)
|
|
102
|
+
throw new RangeError("a decimal divided by zero");
|
|
103
|
+
const numerator = this.units * pow10(by.scale + decimals);
|
|
104
|
+
const denominator = by.units * pow10(this.scale);
|
|
105
|
+
return Decimal.at(halfUp(numerator, denominator), decimals);
|
|
106
|
+
}
|
|
107
|
+
/** With `decimals` digits after the point: rounded half up when it had more, padded when fewer. */
|
|
108
|
+
rounded(decimals) {
|
|
109
|
+
if (decimals >= this.scale)
|
|
110
|
+
return Decimal.at(this.units * pow10(decimals - this.scale), decimals);
|
|
111
|
+
return Decimal.at(halfUp(this.units, pow10(this.scale - decimals)), decimals);
|
|
112
|
+
}
|
|
113
|
+
negated() {
|
|
114
|
+
return Decimal.at(-this.units, this.scale);
|
|
115
|
+
}
|
|
116
|
+
/** `-1`, `0` or `1`: `10.5` and `10.50` are the same amount. */
|
|
117
|
+
compare(other) {
|
|
118
|
+
const [one, two] = Decimal.aligned(this, Decimal.of(other));
|
|
119
|
+
return one < two ? -1 : one > two ? 1 : 0;
|
|
120
|
+
}
|
|
121
|
+
equals(other) {
|
|
122
|
+
return this.compare(other) === 0;
|
|
123
|
+
}
|
|
124
|
+
get isZero() {
|
|
125
|
+
return this.units === 0n;
|
|
126
|
+
}
|
|
127
|
+
get isNegative() {
|
|
128
|
+
return this.units < 0n;
|
|
129
|
+
}
|
|
130
|
+
get isPositive() {
|
|
131
|
+
return this.units > 0n;
|
|
132
|
+
}
|
|
133
|
+
/** The text the wire carries: every digit, the scale kept (`"10.50"`, never `"10.5"`). */
|
|
134
|
+
toString() {
|
|
135
|
+
const negative = this.units < 0n;
|
|
136
|
+
const digits = (negative ? -this.units : this.units)
|
|
137
|
+
.toString()
|
|
138
|
+
.padStart(this.scale + 1, "0");
|
|
139
|
+
const whole = digits.slice(0, digits.length - this.scale);
|
|
140
|
+
const fraction = digits.slice(digits.length - this.scale);
|
|
141
|
+
return `${negative ? "-" : ""}${whole}${this.scale > 0 ? `.${fraction}` : ""}`;
|
|
142
|
+
}
|
|
143
|
+
toJSON() {
|
|
144
|
+
return this.toString();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -15,3 +15,10 @@
|
|
|
15
15
|
export declare function newEntityId(now?: number): string;
|
|
16
16
|
/** A key that is a UUID v7 as sincpro_framework writes it: 32 hex, version 7, variant `10`. */
|
|
17
17
|
export declare function isEntityId(text: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* When a UUID v7 was minted, read off its leading 48 bits — sincpro_framework's `moment_of`;
|
|
20
|
+
* `null` for a key that is not one (a project's own format, a UUID v4).
|
|
21
|
+
*
|
|
22
|
+
* momentOf(newEntityId()) → "2026-11-06T22:41:07.512Z", the moment it was minted
|
|
23
|
+
*/
|
|
24
|
+
export declare function momentOf(identity: string): string | null;
|
|
@@ -50,3 +50,15 @@ export function newEntityId(now = Date.now()) {
|
|
|
50
50
|
export function isEntityId(text) {
|
|
51
51
|
return /^[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}$/.test(text);
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* When a UUID v7 was minted, read off its leading 48 bits — sincpro_framework's `moment_of`;
|
|
55
|
+
* `null` for a key that is not one (a project's own format, a UUID v4).
|
|
56
|
+
*
|
|
57
|
+
* momentOf(newEntityId()) → "2026-11-06T22:41:07.512Z", the moment it was minted
|
|
58
|
+
*/
|
|
59
|
+
export function momentOf(identity) {
|
|
60
|
+
const hex = identity.replaceAll("-", "").toLowerCase();
|
|
61
|
+
if (!isEntityId(hex))
|
|
62
|
+
return null;
|
|
63
|
+
return new Date(Number.parseInt(hex.slice(0, 12), 16)).toISOString();
|
|
64
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Decimal, type DecimalLike } from "./decimal.js";
|
|
2
|
+
/**
|
|
3
|
+
* An amount and its currency, as sincpro_framework's `Money` travels: `{"amount": "10.50",
|
|
4
|
+
* "currency": "BOB"}`.
|
|
5
|
+
*
|
|
6
|
+
* const line = new Money("19.90", "BOB").times("3"); → 59.70 BOB
|
|
7
|
+
* const total = Money.total([line, delivery], "BOB");
|
|
8
|
+
* total.rounded().toJSON() → { amount: "69.70", currency: "BOB" }
|
|
9
|
+
* Money.fromJSON(answer.total) ← what the server sent
|
|
10
|
+
*
|
|
11
|
+
* Context: an amount never travels without its currency, never as a float and never rounds by
|
|
12
|
+
* accident — the backend's three rules, kept here so a cart, a POS line or a portal's quote adds
|
|
13
|
+
* the way the invoice will. Adding two currencies is refused; `total` sums one, `totals` each.
|
|
14
|
+
* The arithmetic is `Decimal`'s, exact; `rounded()` rounds to the currency's decimals (ISO 4217:
|
|
15
|
+
* `BOB` 2, `JPY` 0, `KWD` 3), and `isZero` / `isNegative` read it at those decimals, as an
|
|
16
|
+
* accountant does.
|
|
17
|
+
*
|
|
18
|
+
* Not restrictive: any code is read (a crypto, a loyalty point), and its decimals are 2 unless
|
|
19
|
+
* `Money.decimals` says otherwise — an application adds its own (`Money.decimals["USDT"] = 6`).
|
|
20
|
+
* Showing it to a person is the platform's (`Intl.NumberFormat` with `style: "currency"`).
|
|
21
|
+
*/
|
|
22
|
+
export declare class Money {
|
|
23
|
+
/** Decimals by currency where they are not 2: ISO 4217's, and what an application adds. */
|
|
24
|
+
static readonly decimals: Record<string, number>;
|
|
25
|
+
/** The amount as the wire carries it: every digit, the scale kept (`"10.50"`). */
|
|
26
|
+
readonly amount: string;
|
|
27
|
+
/** The currency's code, upper case (`"BOB"`). */
|
|
28
|
+
readonly currency: string;
|
|
29
|
+
constructor(amount: DecimalLike, currency: string);
|
|
30
|
+
/**
|
|
31
|
+
* What the server sent — `{ amount, currency }`, the amount as text or as a JSON number taken
|
|
32
|
+
* by its digits. Anything else is refused, naming what is wrong.
|
|
33
|
+
*/
|
|
34
|
+
static fromJSON(value: unknown): Money;
|
|
35
|
+
/** Whether `value` has the shape of a money on the wire: `{ amount, currency }`. */
|
|
36
|
+
static isMoney(value: unknown): value is {
|
|
37
|
+
amount: string | number;
|
|
38
|
+
currency: string;
|
|
39
|
+
};
|
|
40
|
+
/** The decimals `currency` is counted in: 2 unless `Money.decimals` says otherwise. */
|
|
41
|
+
static decimalsOf(currency: string): number;
|
|
42
|
+
/** The sum of `items`, all in `currency` — zero when there are none. Another currency is refused. */
|
|
43
|
+
static total(items: Iterable<Money>, currency: string): Money;
|
|
44
|
+
/** The sums by currency, each currency's own. */
|
|
45
|
+
static totals(items: Iterable<Money>): Readonly<Record<string, Money>>;
|
|
46
|
+
/** The amount as an exact decimal. */
|
|
47
|
+
get value(): Decimal;
|
|
48
|
+
/** The decimals this currency is counted in. */
|
|
49
|
+
get decimals(): number;
|
|
50
|
+
private same;
|
|
51
|
+
plus(other: Money): Money;
|
|
52
|
+
minus(other: Money): Money;
|
|
53
|
+
/** Times a quantity, a rate or a percentage as a fraction: exact, never rounded. */
|
|
54
|
+
times(factor: DecimalLike): Money;
|
|
55
|
+
/** Over `divisor`, rounded half up to the currency's decimals (or `decimals`). */
|
|
56
|
+
dividedBy(divisor: DecimalLike, decimals?: number): Money;
|
|
57
|
+
/** Rounded half up to the currency's decimals (or `decimals`): what an invoice shows. */
|
|
58
|
+
rounded(decimals?: number): Money;
|
|
59
|
+
negated(): Money;
|
|
60
|
+
/** `-1`, `0` or `1`, in the same currency. */
|
|
61
|
+
compare(other: Money): -1 | 0 | 1;
|
|
62
|
+
/** The same currency and the same amount: `10.5 BOB` equals `10.50 BOB`. */
|
|
63
|
+
equals(other: Money): boolean;
|
|
64
|
+
/** Zero at the currency's decimals: `0.004 BOB` is. */
|
|
65
|
+
get isZero(): boolean;
|
|
66
|
+
get isNegative(): boolean;
|
|
67
|
+
get isPositive(): boolean;
|
|
68
|
+
toJSON(): {
|
|
69
|
+
amount: string;
|
|
70
|
+
currency: string;
|
|
71
|
+
};
|
|
72
|
+
/** `"10.50 BOB"` — for a log or a test; a person reads it through `Intl.NumberFormat`. */
|
|
73
|
+
toString(): string;
|
|
74
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Decimal } from "./decimal.js";
|
|
2
|
+
/**
|
|
3
|
+
* An amount and its currency, as sincpro_framework's `Money` travels: `{"amount": "10.50",
|
|
4
|
+
* "currency": "BOB"}`.
|
|
5
|
+
*
|
|
6
|
+
* const line = new Money("19.90", "BOB").times("3"); → 59.70 BOB
|
|
7
|
+
* const total = Money.total([line, delivery], "BOB");
|
|
8
|
+
* total.rounded().toJSON() → { amount: "69.70", currency: "BOB" }
|
|
9
|
+
* Money.fromJSON(answer.total) ← what the server sent
|
|
10
|
+
*
|
|
11
|
+
* Context: an amount never travels without its currency, never as a float and never rounds by
|
|
12
|
+
* accident — the backend's three rules, kept here so a cart, a POS line or a portal's quote adds
|
|
13
|
+
* the way the invoice will. Adding two currencies is refused; `total` sums one, `totals` each.
|
|
14
|
+
* The arithmetic is `Decimal`'s, exact; `rounded()` rounds to the currency's decimals (ISO 4217:
|
|
15
|
+
* `BOB` 2, `JPY` 0, `KWD` 3), and `isZero` / `isNegative` read it at those decimals, as an
|
|
16
|
+
* accountant does.
|
|
17
|
+
*
|
|
18
|
+
* Not restrictive: any code is read (a crypto, a loyalty point), and its decimals are 2 unless
|
|
19
|
+
* `Money.decimals` says otherwise — an application adds its own (`Money.decimals["USDT"] = 6`).
|
|
20
|
+
* Showing it to a person is the platform's (`Intl.NumberFormat` with `style: "currency"`).
|
|
21
|
+
*/
|
|
22
|
+
export class Money {
|
|
23
|
+
/** Decimals by currency where they are not 2: ISO 4217's, and what an application adds. */
|
|
24
|
+
static decimals = {
|
|
25
|
+
BHD: 3,
|
|
26
|
+
BIF: 0,
|
|
27
|
+
BTC: 8,
|
|
28
|
+
CLF: 4,
|
|
29
|
+
CLP: 0,
|
|
30
|
+
DJF: 0,
|
|
31
|
+
GNF: 0,
|
|
32
|
+
IQD: 3,
|
|
33
|
+
ISK: 0,
|
|
34
|
+
JOD: 3,
|
|
35
|
+
JPY: 0,
|
|
36
|
+
KMF: 0,
|
|
37
|
+
KRW: 0,
|
|
38
|
+
KWD: 3,
|
|
39
|
+
LYD: 3,
|
|
40
|
+
OMR: 3,
|
|
41
|
+
PYG: 0,
|
|
42
|
+
RWF: 0,
|
|
43
|
+
TND: 3,
|
|
44
|
+
UGX: 0,
|
|
45
|
+
UYI: 0,
|
|
46
|
+
VND: 0,
|
|
47
|
+
VUV: 0,
|
|
48
|
+
XAF: 0,
|
|
49
|
+
XOF: 0,
|
|
50
|
+
XPF: 0,
|
|
51
|
+
};
|
|
52
|
+
/** The amount as the wire carries it: every digit, the scale kept (`"10.50"`). */
|
|
53
|
+
amount;
|
|
54
|
+
/** The currency's code, upper case (`"BOB"`). */
|
|
55
|
+
currency;
|
|
56
|
+
constructor(amount, currency) {
|
|
57
|
+
const code = currency.trim().toUpperCase();
|
|
58
|
+
if (!/^[A-Z][A-Z0-9]{1,9}$/.test(code))
|
|
59
|
+
throw new TypeError(`"${currency}" is not a currency code`);
|
|
60
|
+
this.amount = Decimal.of(amount).toString();
|
|
61
|
+
this.currency = code;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What the server sent — `{ amount, currency }`, the amount as text or as a JSON number taken
|
|
65
|
+
* by its digits. Anything else is refused, naming what is wrong.
|
|
66
|
+
*/
|
|
67
|
+
static fromJSON(value) {
|
|
68
|
+
if (value instanceof Money)
|
|
69
|
+
return value;
|
|
70
|
+
if (value === null || typeof value !== "object")
|
|
71
|
+
throw new TypeError(`${JSON.stringify(value)} is not a money: { amount, currency }`);
|
|
72
|
+
const { amount, currency } = value;
|
|
73
|
+
if (typeof currency !== "string")
|
|
74
|
+
throw new TypeError(`a money without its currency: ${JSON.stringify(value)}`);
|
|
75
|
+
if (typeof amount !== "string" && typeof amount !== "number")
|
|
76
|
+
throw new TypeError(`a money without its amount: ${JSON.stringify(value)}`);
|
|
77
|
+
return new Money(amount, currency);
|
|
78
|
+
}
|
|
79
|
+
/** Whether `value` has the shape of a money on the wire: `{ amount, currency }`. */
|
|
80
|
+
static isMoney(value) {
|
|
81
|
+
if (value instanceof Money)
|
|
82
|
+
return true;
|
|
83
|
+
if (value === null || typeof value !== "object")
|
|
84
|
+
return false;
|
|
85
|
+
const { amount, currency } = value;
|
|
86
|
+
return (typeof currency === "string" &&
|
|
87
|
+
(typeof amount === "string" || typeof amount === "number"));
|
|
88
|
+
}
|
|
89
|
+
/** The decimals `currency` is counted in: 2 unless `Money.decimals` says otherwise. */
|
|
90
|
+
static decimalsOf(currency) {
|
|
91
|
+
return Money.decimals[currency.toUpperCase()] ?? 2;
|
|
92
|
+
}
|
|
93
|
+
/** The sum of `items`, all in `currency` — zero when there are none. Another currency is refused. */
|
|
94
|
+
static total(items, currency) {
|
|
95
|
+
let sum = new Money("0", currency);
|
|
96
|
+
for (const item of items)
|
|
97
|
+
sum = sum.plus(item);
|
|
98
|
+
return sum;
|
|
99
|
+
}
|
|
100
|
+
/** The sums by currency, each currency's own. */
|
|
101
|
+
static totals(items) {
|
|
102
|
+
const sums = {};
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
const held = sums[item.currency];
|
|
105
|
+
sums[item.currency] = held === undefined ? item : held.plus(item);
|
|
106
|
+
}
|
|
107
|
+
return sums;
|
|
108
|
+
}
|
|
109
|
+
/** The amount as an exact decimal. */
|
|
110
|
+
get value() {
|
|
111
|
+
return new Decimal(this.amount);
|
|
112
|
+
}
|
|
113
|
+
/** The decimals this currency is counted in. */
|
|
114
|
+
get decimals() {
|
|
115
|
+
return Money.decimalsOf(this.currency);
|
|
116
|
+
}
|
|
117
|
+
same(other) {
|
|
118
|
+
if (other.currency !== this.currency)
|
|
119
|
+
throw new TypeError(`${other.currency} cannot be added to ${this.currency} — sum each currency apart (Money.totals)`);
|
|
120
|
+
return other;
|
|
121
|
+
}
|
|
122
|
+
plus(other) {
|
|
123
|
+
return new Money(this.value.plus(this.same(other).amount), this.currency);
|
|
124
|
+
}
|
|
125
|
+
minus(other) {
|
|
126
|
+
return new Money(this.value.minus(this.same(other).amount), this.currency);
|
|
127
|
+
}
|
|
128
|
+
/** Times a quantity, a rate or a percentage as a fraction: exact, never rounded. */
|
|
129
|
+
times(factor) {
|
|
130
|
+
return new Money(this.value.times(factor), this.currency);
|
|
131
|
+
}
|
|
132
|
+
/** Over `divisor`, rounded half up to the currency's decimals (or `decimals`). */
|
|
133
|
+
dividedBy(divisor, decimals = this.decimals) {
|
|
134
|
+
return new Money(this.value.dividedBy(divisor, decimals), this.currency);
|
|
135
|
+
}
|
|
136
|
+
/** Rounded half up to the currency's decimals (or `decimals`): what an invoice shows. */
|
|
137
|
+
rounded(decimals = this.decimals) {
|
|
138
|
+
return new Money(this.value.rounded(decimals), this.currency);
|
|
139
|
+
}
|
|
140
|
+
negated() {
|
|
141
|
+
return new Money(this.value.negated(), this.currency);
|
|
142
|
+
}
|
|
143
|
+
/** `-1`, `0` or `1`, in the same currency. */
|
|
144
|
+
compare(other) {
|
|
145
|
+
return this.value.compare(this.same(other).amount);
|
|
146
|
+
}
|
|
147
|
+
/** The same currency and the same amount: `10.5 BOB` equals `10.50 BOB`. */
|
|
148
|
+
equals(other) {
|
|
149
|
+
return other.currency === this.currency && this.value.equals(other.amount);
|
|
150
|
+
}
|
|
151
|
+
/** Zero at the currency's decimals: `0.004 BOB` is. */
|
|
152
|
+
get isZero() {
|
|
153
|
+
return this.rounded().value.isZero;
|
|
154
|
+
}
|
|
155
|
+
get isNegative() {
|
|
156
|
+
return this.rounded().value.isNegative;
|
|
157
|
+
}
|
|
158
|
+
get isPositive() {
|
|
159
|
+
return this.rounded().value.isPositive;
|
|
160
|
+
}
|
|
161
|
+
toJSON() {
|
|
162
|
+
return { amount: this.amount, currency: this.currency };
|
|
163
|
+
}
|
|
164
|
+
/** `"10.50 BOB"` — for a log or a test; a person reads it through `Intl.NumberFormat`. */
|
|
165
|
+
toString() {
|
|
166
|
+
return `${this.amount} ${this.currency}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Any value as JSON values and back — what every message a client sends is written with, as
|
|
3
|
+
* sincpro_framework's `common/serialization` (`values_of`, `read_values`, `rebuilt`, `pack`,
|
|
4
|
+
* `unpack`).
|
|
5
|
+
*
|
|
6
|
+
* valuesOf({ total: new Money("12.50", "BOB"), qty: new Decimal("2.500") })
|
|
7
|
+
* → { total: { amount: "12.50", currency: "BOB" }, qty: "2.500" }
|
|
8
|
+
* valuesOf(new Uint8Array([104, 105])) → { $bytes: "aGk=" }
|
|
9
|
+
* readValues('{"photo":{"$bytes":"aGk="}}') → { photo: Uint8Array [104, 105] }
|
|
10
|
+
* unpack(text, Money) → a Money, or a TypeError naming what is wrong
|
|
11
|
+
*
|
|
12
|
+
* Context: the wire has one set of rules, the backend's, and every platform writes and reads it
|
|
13
|
+
* the same — that is why it lives here and not in each client:
|
|
14
|
+
*
|
|
15
|
+
* - a decimal is text (`"10.50"`): a `Decimal`, a `Money`'s amount, a `bigint` — never a float;
|
|
16
|
+
* - a day is text (`"2026-10-02"`) and stays text: a `Date` is always an instant, written as one;
|
|
17
|
+
* - an instant is ISO text (`Date` → `toISOString()`);
|
|
18
|
+
* - bytes are `{"$bytes": "<base64>"}`, so binary is never mistaken for text;
|
|
19
|
+
* - a value that says how it travels (`toJSON`) is written that way: `Money`, `Decimal`, `Date`;
|
|
20
|
+
* - a map is an object with text keys; a set or a list, a list.
|
|
21
|
+
*
|
|
22
|
+
* Not restrictive, like the backend: reading keeps every key it finds — what a reader does not
|
|
23
|
+
* know it ignores, never refuses — and a typed read is asked for, never imposed (`rebuilt(values,
|
|
24
|
+
* Money)`). What JSON cannot write (a function, `NaN`, a cycle) is refused at once, naming where,
|
|
25
|
+
* rather than sent as `null`.
|
|
26
|
+
*/
|
|
27
|
+
/** A value JSON writes as it is. */
|
|
28
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
29
|
+
[key: string]: JsonValue;
|
|
30
|
+
};
|
|
31
|
+
/** The tag bytes travel under. */
|
|
32
|
+
export declare const BYTES = "$bytes";
|
|
33
|
+
/** What `rebuilt` reads values as: a class with `fromJSON` (`Money`), or a function. */
|
|
34
|
+
export type Rebuild<T> = {
|
|
35
|
+
fromJSON(values: unknown): T;
|
|
36
|
+
} | ((values: unknown) => T);
|
|
37
|
+
/**
|
|
38
|
+
* `value` as JSON values, by the wire's rules. `undefined` is left out of an object and written
|
|
39
|
+
* `null` in a list, as JSON does.
|
|
40
|
+
*/
|
|
41
|
+
export declare function valuesOf(value: unknown): JsonValue;
|
|
42
|
+
/** What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. */
|
|
43
|
+
export declare function readValues(text: string | unknown): unknown;
|
|
44
|
+
/** `values` read as `of` — `Money`, a DTO's own reader — or as they are without one. */
|
|
45
|
+
export declare function rebuilt<T = unknown>(values: unknown, of?: Rebuild<T>): T;
|
|
46
|
+
/** `value` as JSON text — see `valuesOf`. */
|
|
47
|
+
export declare function pack(value: unknown): string;
|
|
48
|
+
/** What `pack` wrote, read as `of` — see `rebuilt`. */
|
|
49
|
+
export declare function unpack<T = unknown>(text: string, of?: Rebuild<T>): T;
|