@reventlessdev/reventless-spec 3.0.0-alpha.88 → 3.0.0-alpha.89

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.
@@ -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,11 @@ 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"
61
66
  }
62
67
 
63
68
  let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
@@ -12,7 +12,8 @@ let Id = {
12
12
  percent: "percent",
13
13
  bytes: "bytes",
14
14
  duration: "duration",
15
- color: "color"
15
+ color: "color",
16
+ money: "money"
16
17
  };
17
18
 
18
19
  let semanticId = S.Metadata.Id.make("reventless", "semantic");