@reventlessdev/reventless-spec 3.0.0-alpha.88 → 3.0.0-alpha.90
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/CHANGELOG.md +14 -0
- package/package.json +5 -3
- package/rescript.json +2 -1
- package/scripts/generate-currency.mjs +215 -0
- package/scripts/iso-4217-list-one.xml +1956 -0
- package/src/PackageVersion.res +7 -13
- package/src/generator/Config.res +4 -4
- package/src/generator/Discovery.res +12 -12
- package/src/generator/Generator_Node.res +12 -47
- package/src/generator/Pairing.res +12 -12
- package/src/generator/PlatformGenerator.res +12 -13
- package/src/generator/PlatformManifests.res +9 -9
- package/src/generator/PluginGenerator.res +10 -11
- package/src/semantic/Currency.res +598 -0
- package/src/semantic/Currency.res.mjs +743 -0
- package/src/semantic/DateRange.res +148 -0
- package/src/semantic/DateRange.res.mjs +74 -0
- package/src/semantic/Money.res +196 -0
- package/src/semantic/Money.res.mjs +138 -0
- package/src/semantic/Semantic.res +11 -0
- package/src/semantic/Semantic.res.mjs +3 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
A span of time: two ISO-8601 instants, a start and an end, as one value.
|
|
3
|
+
|
|
4
|
+
## Why the pair is the value, not two fields beside each other
|
|
5
|
+
|
|
6
|
+
Before this type a span was a *guess*. A view that wanted a calendar, a timeline
|
|
7
|
+
or a gantt bar asked which fields were date-like and took the first named
|
|
8
|
+
`start…` and the first named `end…`, independently. Three things go wrong and
|
|
9
|
+
all three are silent: two intervals in one row mispair across each other (a bar
|
|
10
|
+
drawn from one interval's start to the other's end); an interval named anything
|
|
11
|
+
else — `checkIn`/`checkOut`, `from`/`to` — is invisible; and a lone `started…`
|
|
12
|
+
point pairs with whatever `end…` is nearby. Making the two instants one value
|
|
13
|
+
removes the pairing question: a consumer either holds the span or it does not.
|
|
14
|
+
|
|
15
|
+
The parts keep their own `dateTime` marker, so a walker that only understands
|
|
16
|
+
date-times still sees them, and the whole carries `dateRange` besides. That is
|
|
17
|
+
the same layering `Money` uses — the amount keeps being a number, the composite
|
|
18
|
+
adds the meaning the number cannot carry.
|
|
19
|
+
|
|
20
|
+
## `[start, end)` — end exclusive
|
|
21
|
+
|
|
22
|
+
A range runs from `start` up to but not including `end`. `09:00–11:00` and
|
|
23
|
+
`11:00–13:00` are adjacent, not overlapping, and an all-day grid needs no
|
|
24
|
+
off-by-one-millisecond convention invented per consumer. This is a decision, not
|
|
25
|
+
a default: `overlaps` and `contains` are the only places it is written, and no
|
|
26
|
+
layout may re-decide it.
|
|
27
|
+
|
|
28
|
+
## Why the ordering rule is not enforced at decode
|
|
29
|
+
|
|
30
|
+
`start <= end` relates two fields, so — unlike `Money`'s wholeness, which is a
|
|
31
|
+
property of one field and rides on that field's schema — it is a *record-level*
|
|
32
|
+
invariant. sury 11.0.0-alpha.4 miscompiles a refinement wrapping a record schema
|
|
33
|
+
(it hoists the result object above the field reads, so parse and serialize throw
|
|
34
|
+
`Cannot access 'v0' before initialization`), and the pin has not moved. So the
|
|
35
|
+
rule lives in `validate`/`make` and **the schema does not enforce it at decode**.
|
|
36
|
+
|
|
37
|
+
This is the first semantic type in this library whose invariant the boundary does
|
|
38
|
+
not check: `Money` rejects a fractional minor unit on the way in; `DateRange`
|
|
39
|
+
will accept a range that ends before it starts if one is ever written. A reader
|
|
40
|
+
who assumes parity with `Money` assumes wrong. When sury fixes the record
|
|
41
|
+
refinement the rule moves into the schema and `validate` stays as its single
|
|
42
|
+
definition — the relationship `Money.validateAmount` has with `amountSchema`.
|
|
43
|
+
|
|
44
|
+
Parsing is `Date.fromString` on each instant. A range whose strings do not parse
|
|
45
|
+
is a decode-time problem the `DateTime` marker does not currently catch either,
|
|
46
|
+
so there is no second validation layer here — a reversed *parseable* range is
|
|
47
|
+
what `validate` catches, and an unparseable one is out of both their scope.
|
|
48
|
+
|
|
49
|
+
## How a field declares it
|
|
50
|
+
|
|
51
|
+
The field's declared type *is* `DateRange.t`, and sury-ppx resolves it to this
|
|
52
|
+
module's `schema`:
|
|
53
|
+
|
|
54
|
+
```rescript
|
|
55
|
+
@schema type state = {
|
|
56
|
+
orderId: string,
|
|
57
|
+
deliveryWindow: option<Reventless.DateRange.t>,
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
which serializes as `{"start": "2026-03-02T09:00:00Z", "end": "2026-03-02T11:00:00Z"}`.
|
|
62
|
+
|
|
63
|
+
**Introduced as a new optional field it is additive** — an absent optional
|
|
64
|
+
decodes to `None` for events written before it existed, so no upcaster and no
|
|
65
|
+
projection rebuild. It costs a log something only if it *collapses* an existing
|
|
66
|
+
`start*`/`end*` pair, which rewrites the wire shape the way `Money` rewrote
|
|
67
|
+
`price: float`. That collapse belongs to whoever builds the upcaster.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
@schema
|
|
71
|
+
type t = {
|
|
72
|
+
/** The instant the range opens, inclusive. */
|
|
73
|
+
start: @s.matches(DateTime.string) string,
|
|
74
|
+
/** The instant the range closes, **exclusive** — the range does not contain
|
|
75
|
+
it. `@as("end")` puts `end` on the wire (where the UI's own `GanttChart`
|
|
76
|
+
already spells it that way); `end_` is the source spelling because `end`
|
|
77
|
+
is awkward as a bare ReScript field. */
|
|
78
|
+
@as("end") end_: @s.matches(DateTime.string) string,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The sury schema for a date-range field, carrying the `dateRange` semantic.
|
|
82
|
+
|
|
83
|
+
Shadows the schema sury-ppx derived from the type above: the derived one is
|
|
84
|
+
the shape, and this adds the marker the shape cannot carry. The ordering rule
|
|
85
|
+
is deliberately *not* refined in here — see the module doc. */
|
|
86
|
+
let schema: S.t<t> = schema->Semantic.mark(~id=Semantic.Id.dateRange)
|
|
87
|
+
|
|
88
|
+
/** An instant as milliseconds since the epoch — `NaN` if it does not parse. The
|
|
89
|
+
one place a range's strings become numbers, so end-exclusivity and the
|
|
90
|
+
ordering rule are all expressed against a single parse. */
|
|
91
|
+
let millis = (instant: string): float => instant->Date.fromString->Date.getTime
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
Validate a range's ordering, saying why when it is reversed.
|
|
95
|
+
|
|
96
|
+
The single statement of the `start <= end` rule; `make` is derived from it, and
|
|
97
|
+
`schema` will be once sury's record refinement is fixed. An unparseable instant
|
|
98
|
+
is not caught here (see the module doc) — a reversed range means two instants
|
|
99
|
+
that both parse, the earlier one second.
|
|
100
|
+
*/
|
|
101
|
+
let validate = (range: t): result<t, string> =>
|
|
102
|
+
millis(range.start) > millis(range.end_)
|
|
103
|
+
? Error(
|
|
104
|
+
`a range ends before it starts: ${range.start} is after ${range.end_}. ` ++
|
|
105
|
+
`A range is [start, end) — the start is the earlier instant.`,
|
|
106
|
+
)
|
|
107
|
+
: Ok(range)
|
|
108
|
+
|
|
109
|
+
/** Build a validated range from its two instants. `end` is exclusive. */
|
|
110
|
+
let make = (~start: string, ~end_: string): result<t, string> => validate({start, end_})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
The range's length as a `Duration`, in whole seconds — the composite composing
|
|
114
|
+
with one of the branded scalars.
|
|
115
|
+
|
|
116
|
+
Total: a valid range has a non-negative length, and a zero-length range is
|
|
117
|
+
zero seconds. Truncated to whole seconds because that is what `Duration` is.
|
|
118
|
+
*/
|
|
119
|
+
let duration = (range: t): Duration.t =>
|
|
120
|
+
Duration.unsafe(Math.trunc((millis(range.end_) -. millis(range.start)) /. 1000.0)->Float.toInt)
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
Whether an instant falls within the range — at or after `start`, strictly before
|
|
124
|
+
`end`. End-exclusive, so the instant that opens the next adjacent range is *not*
|
|
125
|
+
contained by this one. One of the two places `[start, end)` is decided.
|
|
126
|
+
*/
|
|
127
|
+
let contains = (range: t, instant: string): bool => {
|
|
128
|
+
let t = millis(instant)
|
|
129
|
+
t >= millis(range.start) && t < millis(range.end_)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
Whether two ranges share any instant. End-exclusive: `09:00–11:00` and
|
|
134
|
+
`11:00–13:00` are adjacent and do *not* overlap. The other place `[start, end)`
|
|
135
|
+
is decided — a layout that re-decides it will disagree with this.
|
|
136
|
+
*/
|
|
137
|
+
let overlaps = (a: t, b: t): bool =>
|
|
138
|
+
millis(a.start) < millis(b.end_) && millis(b.start) < millis(a.end_)
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
The range as text: the two instants with an en dash between them —
|
|
142
|
+
`"2026-03-02T09:00:00Z – 2026-03-02T11:00:00Z"`.
|
|
143
|
+
|
|
144
|
+
Locale-independent, matching the rest of the framework's formatters: the same
|
|
145
|
+
value reads the same in every log line and every test. A calendar-style
|
|
146
|
+
rendering is the presentation layer's job.
|
|
147
|
+
*/
|
|
148
|
+
let format = (range: t): string => `${range.start} – ${range.end_}`
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as DateTime$Reventless from "../types/DateTime.res.mjs";
|
|
5
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
6
|
+
|
|
7
|
+
let schema = S.schema(s => ({
|
|
8
|
+
start: s.m(DateTime$Reventless.string),
|
|
9
|
+
end: s.m(DateTime$Reventless.string)
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.dateRange, undefined);
|
|
13
|
+
|
|
14
|
+
function millis(instant) {
|
|
15
|
+
return new Date(instant).getTime();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validate(range) {
|
|
19
|
+
if (new Date(range.start).getTime() > new Date(range.end).getTime()) {
|
|
20
|
+
return {
|
|
21
|
+
TAG: "Error",
|
|
22
|
+
_0: `a range ends before it starts: ` + range.start + ` is after ` + range.end + `. A range is [start, end) — the start is the earlier instant.`
|
|
23
|
+
};
|
|
24
|
+
} else {
|
|
25
|
+
return {
|
|
26
|
+
TAG: "Ok",
|
|
27
|
+
_0: range
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function make(start, end_) {
|
|
33
|
+
return validate({
|
|
34
|
+
start: start,
|
|
35
|
+
end: end_
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function duration(range) {
|
|
40
|
+
return Math.trunc((new Date(range.end).getTime() - new Date(range.start).getTime()) / 1000.0) | 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function contains(range, instant) {
|
|
44
|
+
let t = new Date(instant).getTime();
|
|
45
|
+
if (t >= new Date(range.start).getTime()) {
|
|
46
|
+
return t < new Date(range.end).getTime();
|
|
47
|
+
} else {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function overlaps(a, b) {
|
|
53
|
+
if (new Date(a.start).getTime() < new Date(b.end).getTime()) {
|
|
54
|
+
return new Date(b.start).getTime() < new Date(a.end).getTime();
|
|
55
|
+
} else {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function format(range) {
|
|
61
|
+
return range.start + ` – ` + range.end;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
schema$1 as schema,
|
|
66
|
+
millis,
|
|
67
|
+
validate,
|
|
68
|
+
make,
|
|
69
|
+
duration,
|
|
70
|
+
contains,
|
|
71
|
+
overlaps,
|
|
72
|
+
format,
|
|
73
|
+
}
|
|
74
|
+
/* schema Not a pure module */
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
An amount of money: a whole number of a currency's minor units, and the currency
|
|
3
|
+
those units belong to.
|
|
4
|
+
|
|
5
|
+
## Why the currency travels with the number
|
|
6
|
+
|
|
7
|
+
A minor unit is currency-dependent — ISO 4217 gives EUR two decimal places, **JPY
|
|
8
|
+
zero** and **TND three** — so a bare `1000` is €10.00 or ¥1000 or 1.000 TND, and
|
|
9
|
+
there is no way to tell which. It cannot be rendered, compared, summed or
|
|
10
|
+
sanity-checked without knowing. The currency is part of the number's meaning, not
|
|
11
|
+
metadata beside it.
|
|
12
|
+
|
|
13
|
+
The alternative considered and rejected was a branded `amount` scalar with the
|
|
14
|
+
currency held once on the aggregate. Its appeal is real: mixing currencies
|
|
15
|
+
becomes unrepresentable rather than merely checkable. But it only works while
|
|
16
|
+
every amount an aggregate touches shares one currency, and the moment one does
|
|
17
|
+
not, the information needed to notice has already been deleted. `add` checks
|
|
18
|
+
instead — see below.
|
|
19
|
+
|
|
20
|
+
## Why whole minor units and not a decimal major amount
|
|
21
|
+
|
|
22
|
+
`0.1 +. 0.2` is not `0.3`, and money is summed. Minor units keep every amount an
|
|
23
|
+
exact integer, so addition is exact and equality means what it says.
|
|
24
|
+
|
|
25
|
+
## Why `float` for a whole number
|
|
26
|
+
|
|
27
|
+
Because ReScript's `int` is int32, and sury enforces that — an `int` amount caps
|
|
28
|
+
at 2,147,483,647 minor units, which is €21,474,836.47. A framework type that
|
|
29
|
+
cannot express a €22M total is not a money type. `float` is exact for every
|
|
30
|
+
integer below 2^53 (about €90 trillion in cents), and the wholeness that `int`
|
|
31
|
+
would have given for free is recovered by checking it in `schema`.
|
|
32
|
+
|
|
33
|
+
This is the same correction `Bytes` already made for the same reason, and the
|
|
34
|
+
wire form is identical either way: both are JSON numbers.
|
|
35
|
+
|
|
36
|
+
## How a field declares it
|
|
37
|
+
|
|
38
|
+
Unlike the branded scalars, this is not an `@s.matches` refinement — the field's
|
|
39
|
+
declared type *is* `Money.t`, and sury-ppx resolves it to this module's `schema`:
|
|
40
|
+
|
|
41
|
+
```rescript
|
|
42
|
+
@schema type state = {
|
|
43
|
+
productId: string,
|
|
44
|
+
price: Reventless.Money.t,
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
which serializes as `{"amount": 1000, "currency": "EUR"}`.
|
|
49
|
+
|
|
50
|
+
**That is a structural change to the field.** Retyping an existing `price: float`
|
|
51
|
+
rewrites the wire shape, so stored events no longer decode and projections must
|
|
52
|
+
be rebuilt. Retyping a field in a log that has to survive needs an upcaster
|
|
53
|
+
first; a log that can be discarded can take it today.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
Validate a minor-unit amount, saying why when it is not one.
|
|
58
|
+
|
|
59
|
+
The single definition of what an amount may hold; `amountSchema` is derived from
|
|
60
|
+
it rather than hand-rolling a second check, the rule `StorageRef` established.
|
|
61
|
+
*/
|
|
62
|
+
let validateAmount = (amount: float): result<float, string> =>
|
|
63
|
+
if !Float.isFinite(amount) {
|
|
64
|
+
Error(`an amount must be a finite number of minor units, got ${Float.toString(amount)}`)
|
|
65
|
+
} else if amount !== Math.trunc(amount) {
|
|
66
|
+
Error(
|
|
67
|
+
`an amount is a whole number of a currency's minor units, got ` ++
|
|
68
|
+
`${Float.toString(amount)}. There is no such thing as a fraction of the ` ++
|
|
69
|
+
`smallest unit — a major amount converts with Money.ofMajor.`,
|
|
70
|
+
)
|
|
71
|
+
} else {
|
|
72
|
+
Ok(amount)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The amount's own schema. The check sits on the field rather than on the pair
|
|
76
|
+
because wholeness is a property of the amount — and because sury 11-alpha
|
|
77
|
+
miscompiles a refinement wrapping a *record* schema (it hoists the result
|
|
78
|
+
object above the field reads, so both parse and serialize throw
|
|
79
|
+
`Cannot access 'v0' before initialization`). Refining the field is both the
|
|
80
|
+
honest placement and the one that works. */
|
|
81
|
+
let amountSchema: S.t<float> =
|
|
82
|
+
S.float->S.refine(s => amount =>
|
|
83
|
+
switch validateAmount(amount) {
|
|
84
|
+
| Ok(_) => ()
|
|
85
|
+
| Error(why) => s.fail(why)
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@schema
|
|
90
|
+
type t = {
|
|
91
|
+
/** Whole minor units of `currency` — 1000 is €10.00, ¥1000 or 1.000 TND
|
|
92
|
+
depending on which. Negative amounts are allowed: a refund is money. */
|
|
93
|
+
amount: @s.matches(amountSchema) float,
|
|
94
|
+
currency: Currency.t,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The sury schema for a money field, carrying the `money` semantic.
|
|
98
|
+
|
|
99
|
+
Shadows the schema sury-ppx derived from the type above: the derived one is
|
|
100
|
+
the shape, and this adds the marker the shape cannot carry. */
|
|
101
|
+
let schema: S.t<t> = schema->Semantic.mark(~id=Semantic.Id.money)
|
|
102
|
+
|
|
103
|
+
/** An amount already counted in minor units. */
|
|
104
|
+
let make = (~amount: float, ~currency: Currency.t): t => {amount, currency}
|
|
105
|
+
|
|
106
|
+
/** Nothing, in a currency. A zero still has a currency — "no money" and "no
|
|
107
|
+
euros" are different claims, and only the second one adds to a total. */
|
|
108
|
+
let zero = (~currency: Currency.t): t => {amount: 0.0, currency}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
Convert a major-unit decimal (`10.5`) into minor units (`1050`), using the
|
|
112
|
+
currency's own exponent.
|
|
113
|
+
|
|
114
|
+
This is the one place a decimal is allowed to become money, and it is here rather
|
|
115
|
+
than at each call site precisely so that `*. 100.0` is written once and is
|
|
116
|
+
correct for JPY and TND — where it would be `*. 1.0` and `*. 1000.0`.
|
|
117
|
+
|
|
118
|
+
Rounds half away from zero (`10.005` EUR → `1001`), which is what a reader
|
|
119
|
+
expects of a price. That is a *boundary conversion* and not an arithmetic
|
|
120
|
+
policy: the half-even question that FX and tax rounding turn on is a separate
|
|
121
|
+
decision, and nothing here forecloses it.
|
|
122
|
+
*/
|
|
123
|
+
let ofMajor = (~amount: float, ~currency: Currency.t): t => {
|
|
124
|
+
let scale = Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(currency)))
|
|
125
|
+
{amount: Math.round(amount *. scale), currency}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The amount as a major-unit decimal — for charts, averages and anything that
|
|
129
|
+
has to be a number rather than money. Lossy by nature: the result is a float
|
|
130
|
+
again, so it is an output, not something to compute a balance in. */
|
|
131
|
+
let toMajor = (m: t): float =>
|
|
132
|
+
m.amount /. Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(m.currency)))
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
The amount as text: the decimal point placed by the currency's exponent, digits
|
|
136
|
+
grouped in threes, and the ISO code after it — `"1,234.50 EUR"`, `"1,000 JPY"`,
|
|
137
|
+
`"1.000 TND"`.
|
|
138
|
+
|
|
139
|
+
Deliberately locale-independent, matching the rest of the framework's
|
|
140
|
+
formatters: the same value reads the same in every log line and every test. A
|
|
141
|
+
locale-aware, symbol-bearing rendering is the presentation layer's job, and it
|
|
142
|
+
has the currency code to do it with.
|
|
143
|
+
*/
|
|
144
|
+
let format = (m: t): string => {
|
|
145
|
+
let exponent = Currency.exponent(m.currency)
|
|
146
|
+
let negative = m.amount < 0.0
|
|
147
|
+
let digits = Float.toString(negative ? -.m.amount : m.amount)
|
|
148
|
+
// Pad so there is always at least one digit left of the point: 5 minor units
|
|
149
|
+
// of EUR is "0.05", not ".05".
|
|
150
|
+
let padded = digits->String.padStart(exponent + 1, "0")
|
|
151
|
+
let split = String.length(padded) - exponent
|
|
152
|
+
let whole = padded->String.slice(~start=0, ~end=split)
|
|
153
|
+
let fraction = padded->String.slice(~start=split, ~end=String.length(padded))
|
|
154
|
+
let rec group = (s: string): string => {
|
|
155
|
+
let length = String.length(s)
|
|
156
|
+
length <= 3
|
|
157
|
+
? s
|
|
158
|
+
: group(s->String.slice(~start=0, ~end=length - 3)) ++
|
|
159
|
+
"," ++
|
|
160
|
+
s->String.slice(~start=length - 3, ~end=length)
|
|
161
|
+
}
|
|
162
|
+
(negative ? "-" : "") ++
|
|
163
|
+
group(whole) ++
|
|
164
|
+
(exponent == 0 ? "" : "." ++ fraction) ++
|
|
165
|
+
" " ++
|
|
166
|
+
Currency.toString(m.currency)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
Add two amounts, refusing to add across currencies.
|
|
171
|
+
|
|
172
|
+
This is §15.1's second rider made executable. The genuine appeal of the
|
|
173
|
+
branded-scalar shape was that mixing currencies could not be written at all; the
|
|
174
|
+
answer is to *check* it rather than to delete the information that makes checking
|
|
175
|
+
possible. An aggregate that must hold one currency rejects a line item in
|
|
176
|
+
another — a decider's concern, and one it can now actually express.
|
|
177
|
+
*/
|
|
178
|
+
let add = (a: t, b: t): result<t, string> =>
|
|
179
|
+
a.currency == b.currency
|
|
180
|
+
? Ok({amount: a.amount +. b.amount, currency: a.currency})
|
|
181
|
+
: Error(
|
|
182
|
+
`cannot add ${format(b)} to ${format(a)}: they are different currencies. ` ++
|
|
183
|
+
`Converting between them needs a rate, which is not something an amount carries.`,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
/** Add a run of amounts, refusing at the first currency that does not match the
|
|
187
|
+
first amount's. `None` for an empty run — the sum of no amounts has no
|
|
188
|
+
currency to be in. */
|
|
189
|
+
let sum = (amounts: array<t>): option<result<t, string>> =>
|
|
190
|
+
switch amounts {
|
|
191
|
+
| [] => None
|
|
192
|
+
| _ => Some(amounts->Array.reduce(Ok(zero(~currency=(amounts->Array.getUnsafe(0)).currency)), (
|
|
193
|
+
acc,
|
|
194
|
+
m,
|
|
195
|
+
) => acc->Result.flatMap(total => add(total, m))))
|
|
196
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
6
|
+
import * as Currency$Reventless from "./Currency.res.mjs";
|
|
7
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
8
|
+
|
|
9
|
+
function validateAmount(amount) {
|
|
10
|
+
if (isFinite(amount)) {
|
|
11
|
+
if (amount !== Math.trunc(amount)) {
|
|
12
|
+
return {
|
|
13
|
+
TAG: "Error",
|
|
14
|
+
_0: `an amount is a whole number of a currency's minor units, got ` + (amount.toString() + `. There is no such thing as a fraction of the `) + `smallest unit — a major amount converts with Money.ofMajor.`
|
|
15
|
+
};
|
|
16
|
+
} else {
|
|
17
|
+
return {
|
|
18
|
+
TAG: "Ok",
|
|
19
|
+
_0: amount
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
return {
|
|
24
|
+
TAG: "Error",
|
|
25
|
+
_0: `an amount must be a finite number of minor units, got ` + amount.toString()
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let amountSchema = S.refine(S.float, s => (amount => {
|
|
31
|
+
let why = validateAmount(amount);
|
|
32
|
+
if (why.TAG === "Ok") {
|
|
33
|
+
return;
|
|
34
|
+
} else {
|
|
35
|
+
return s.fail(why._0, undefined);
|
|
36
|
+
}
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
let schema = S.schema(s => ({
|
|
40
|
+
amount: s.m(amountSchema),
|
|
41
|
+
currency: s.m(Currency$Reventless.schema)
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.money, undefined);
|
|
45
|
+
|
|
46
|
+
function make(amount, currency) {
|
|
47
|
+
return {
|
|
48
|
+
amount: amount,
|
|
49
|
+
currency: currency
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function zero(currency) {
|
|
54
|
+
return {
|
|
55
|
+
amount: 0.0,
|
|
56
|
+
currency: currency
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ofMajor(amount, currency) {
|
|
61
|
+
let scale = Math.pow(10.0, Currency$Reventless.exponent(currency));
|
|
62
|
+
return {
|
|
63
|
+
amount: Math.round(amount * scale),
|
|
64
|
+
currency: currency
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toMajor(m) {
|
|
69
|
+
return m.amount / Math.pow(10.0, Currency$Reventless.exponent(m.currency));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function format(m) {
|
|
73
|
+
let exponent = Currency$Reventless.exponent(m.currency);
|
|
74
|
+
let negative = m.amount < 0.0;
|
|
75
|
+
let digits = (
|
|
76
|
+
negative ? - m.amount : m.amount
|
|
77
|
+
).toString();
|
|
78
|
+
let padded = digits.padStart(exponent + 1 | 0, "0");
|
|
79
|
+
let split = padded.length - exponent | 0;
|
|
80
|
+
let whole = padded.slice(0, split);
|
|
81
|
+
let fraction = padded.slice(split, padded.length);
|
|
82
|
+
let group = s => {
|
|
83
|
+
let length = s.length;
|
|
84
|
+
if (length <= 3) {
|
|
85
|
+
return s;
|
|
86
|
+
} else {
|
|
87
|
+
return group(s.slice(0, length - 3 | 0)) + "," + s.slice(length - 3 | 0, length);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return (
|
|
91
|
+
negative ? "-" : ""
|
|
92
|
+
) + group(whole) + (
|
|
93
|
+
exponent === 0 ? "" : "." + fraction
|
|
94
|
+
) + " " + Currency$Reventless.toString(m.currency);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function add(a, b) {
|
|
98
|
+
if (a.currency === b.currency) {
|
|
99
|
+
return {
|
|
100
|
+
TAG: "Ok",
|
|
101
|
+
_0: {
|
|
102
|
+
amount: a.amount + b.amount,
|
|
103
|
+
currency: a.currency
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
} else {
|
|
107
|
+
return {
|
|
108
|
+
TAG: "Error",
|
|
109
|
+
_0: `cannot add ` + format(b) + ` to ` + format(a) + `: they are different currencies. Converting between them needs a rate, which is not something an amount carries.`
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sum(amounts) {
|
|
115
|
+
if (amounts.length !== 0) {
|
|
116
|
+
return Stdlib_Array.reduce(amounts, {
|
|
117
|
+
TAG: "Ok",
|
|
118
|
+
_0: {
|
|
119
|
+
amount: 0.0,
|
|
120
|
+
currency: amounts[0].currency
|
|
121
|
+
}
|
|
122
|
+
}, (acc, m) => Stdlib_Result.flatMap(acc, total => add(total, m)));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export {
|
|
127
|
+
validateAmount,
|
|
128
|
+
amountSchema,
|
|
129
|
+
schema$1 as schema,
|
|
130
|
+
make,
|
|
131
|
+
zero,
|
|
132
|
+
ofMajor,
|
|
133
|
+
toMajor,
|
|
134
|
+
format,
|
|
135
|
+
add,
|
|
136
|
+
sum,
|
|
137
|
+
}
|
|
138
|
+
/* amountSchema Not a pure module */
|
|
@@ -58,6 +58,17 @@ module Id = {
|
|
|
58
58
|
let bytes = "bytes"
|
|
59
59
|
let duration = "duration"
|
|
60
60
|
let color = "color"
|
|
61
|
+
|
|
62
|
+
// The first composite that is not infrastructure. Unlike the seven above it
|
|
63
|
+
// this one changes a field's *shape* — a number becomes an object — so it is
|
|
64
|
+
// a wire-breaking declaration rather than a refinement of one.
|
|
65
|
+
let money = "money"
|
|
66
|
+
|
|
67
|
+
// The second composite. A pair of ISO-8601 instants as one value, replacing a
|
|
68
|
+
// span the UI used to guess from a `start*`/`end*` name pair. Like `money` it
|
|
69
|
+
// is an object on the wire; unlike it, adopting it as a *new* optional field
|
|
70
|
+
// is additive — an absent optional decodes to `None`.
|
|
71
|
+
let dateRange = "dateRange"
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
|