@reventlessdev/reventless-spec 3.0.0-alpha.122 → 3.0.0-alpha.124
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 +38 -0
- package/package.json +1 -2
- package/schema/platform-api.graphql +24 -0
- package/src/components/Plugin.res +149 -422
- package/src/components/Plugin.res.mjs +42 -2
- package/src/components/ReadModel.res +9 -0
- package/src/components/ReadModel.res.mjs +6 -0
- package/src/semantic/Currency.res +519 -502
- package/src/semantic/Currency.res.mjs +7 -655
- package/src/semantic/Money.res +123 -21
- package/src/semantic/Money.res.mjs +49 -2
- package/scripts/generate-currency.mjs +0 -215
- package/scripts/iso-4217-list-one.xml +0 -1956
package/src/semantic/Money.res
CHANGED
|
@@ -5,7 +5,7 @@ those units belong to.
|
|
|
5
5
|
## Why the currency travels with the number
|
|
6
6
|
|
|
7
7
|
A minor unit is currency-dependent — ISO 4217 gives EUR two decimal places, **JPY
|
|
8
|
-
zero** and
|
|
8
|
+
zero** and TND three — so a bare `1000` is €10.00 or ¥1000 or 1.000 TND, and
|
|
9
9
|
there is no way to tell which. It cannot be rendered, compared, summed or
|
|
10
10
|
sanity-checked without knowing. The currency is part of the number's meaning, not
|
|
11
11
|
metadata beside it.
|
|
@@ -22,6 +22,20 @@ instead — see below.
|
|
|
22
22
|
`0.1 +. 0.2` is not `0.3`, and money is summed. Minor units keep every amount an
|
|
23
23
|
exact integer, so addition is exact and equality means what it says.
|
|
24
24
|
|
|
25
|
+
This was tried the other way — an amount holding the decimal a person types —
|
|
26
|
+
and reverted. What it bought was a readable log; what it cost was the guarantee.
|
|
27
|
+
Every sum outside `add` became a float sum, including the ones a read side does
|
|
28
|
+
over a page of rows, and the failure is a figure that is a cent out and looks
|
|
29
|
+
entirely plausible. Binary floating point is not what money is counted in, and
|
|
30
|
+
holding the exact value in a `float` only works while nothing does arithmetic on
|
|
31
|
+
it — which is not a property a framework type can promise on behalf of everyone
|
|
32
|
+
downstream.
|
|
33
|
+
|
|
34
|
+
**The complaint that prompted the attempt was real and is answered elsewhere.** A
|
|
35
|
+
form generated from this schema must not ask anyone for `1050`; it asks for
|
|
36
|
+
`10.50` and converts at its own boundary, with `ofMajor`/`toMajor` below. That is
|
|
37
|
+
a presentation concern, and this type had been letting it leak.
|
|
38
|
+
|
|
25
39
|
## Why `float` for a whole number
|
|
26
40
|
|
|
27
41
|
Because ReScript's `int` is int32, and sury enforces that — an `int` amount caps
|
|
@@ -72,12 +86,15 @@ let validateAmount = (amount: float): result<float, string> =>
|
|
|
72
86
|
Ok(amount)
|
|
73
87
|
}
|
|
74
88
|
|
|
75
|
-
/** The amount's own schema.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
/** The amount's own schema.
|
|
90
|
+
|
|
91
|
+
The check sits on the field rather than on the pair because wholeness is a
|
|
92
|
+
property of the amount: it does not need the currency, and a rule placed
|
|
93
|
+
where it needs nothing else is a rule that cannot be read wrong. (A
|
|
94
|
+
record-level refinement is available — sury 11.0.0-rc.2 compiles one
|
|
95
|
+
correctly, where 11-alpha hoisted the result object above the field reads and
|
|
96
|
+
threw `Cannot access 'v0' before initialization` — it is simply not what this
|
|
97
|
+
check wants.) */
|
|
81
98
|
let amountSchema: S.t<float> =
|
|
82
99
|
S.float->S.refine(
|
|
83
100
|
amount =>
|
|
@@ -109,34 +126,41 @@ let make = (~amount: float, ~currency: Currency.t): t => {amount, currency}
|
|
|
109
126
|
euros" are different claims, and only the second one adds to a total. */
|
|
110
127
|
let zero = (~currency: Currency.t): t => {amount: 0.0, currency}
|
|
111
128
|
|
|
129
|
+
/** Ten to the power of the currency's decimal count — the factor between a major
|
|
130
|
+
amount and the minor units it is. Named because the two conversions below
|
|
131
|
+
both need it and neither should spell out `Math.pow` again. */
|
|
132
|
+
let scale = (currency: Currency.t): float =>
|
|
133
|
+
Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(currency)))
|
|
134
|
+
|
|
112
135
|
/**
|
|
113
136
|
Convert a major-unit decimal (`10.5`) into minor units (`1050`), using the
|
|
114
137
|
currency's own exponent.
|
|
115
138
|
|
|
116
139
|
This is the one place a decimal is allowed to become money, and it is here rather
|
|
117
140
|
than at each call site precisely so that `*. 100.0` is written once and is
|
|
118
|
-
correct for JPY and TND — where it would be `*. 1.0` and `*. 1000.0`.
|
|
141
|
+
correct for JPY and TND — where it would be `*. 1.0` and `*. 1000.0`. It is what
|
|
142
|
+
a form, a supplier feed or any other boundary that speaks in decimals converts
|
|
143
|
+
through.
|
|
119
144
|
|
|
120
|
-
Rounds half away from zero
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
145
|
+
Rounds half away from zero, in both directions: `Math.round` alone rounds half
|
|
146
|
+
toward positive infinity, so `-2.5` would come back `-2` and a refund would round
|
|
147
|
+
the other way from the charge it reverses. That is a *boundary conversion* and
|
|
148
|
+
not an arithmetic policy: the half-even question that FX and tax rounding turn on
|
|
149
|
+
is a separate decision, and nothing here forecloses it.
|
|
124
150
|
*/
|
|
125
151
|
let ofMajor = (~amount: float, ~currency: Currency.t): t => {
|
|
126
|
-
let
|
|
127
|
-
{amount: Math.round(
|
|
152
|
+
let scaled = amount *. scale(currency)
|
|
153
|
+
{amount: scaled < 0.0 ? -.Math.round(-.scaled) : Math.round(scaled), currency}
|
|
128
154
|
}
|
|
129
155
|
|
|
130
|
-
/** The amount as a major-unit decimal —
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
let toMajor = (m: t): float =>
|
|
134
|
-
m.amount /. Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(m.currency)))
|
|
156
|
+
/** The amount as a major-unit decimal — what a form shows, and what a chart axis
|
|
157
|
+
or an average needs. Lossy by nature: the result is a float again, so it is an
|
|
158
|
+
output, not something to compute a balance in. */
|
|
159
|
+
let toMajor = (m: t): float => m.amount /. scale(m.currency)
|
|
135
160
|
|
|
136
161
|
/**
|
|
137
162
|
The amount as text: the decimal point placed by the currency's exponent, digits
|
|
138
|
-
grouped in threes, and the ISO code after it — `"1,234.50 EUR"`, `"1,000 JPY"
|
|
139
|
-
`"1.000 TND"`.
|
|
163
|
+
grouped in threes, and the ISO code after it — `"1,234.50 EUR"`, `"1,000 JPY"`.
|
|
140
164
|
|
|
141
165
|
Deliberately locale-independent, matching the rest of the framework's
|
|
142
166
|
formatters: the same value reads the same in every log line and every test. A
|
|
@@ -176,6 +200,9 @@ branded-scalar shape was that mixing currencies could not be written at all; the
|
|
|
176
200
|
answer is to *check* it rather than to delete the information that makes checking
|
|
177
201
|
possible. An aggregate that must hold one currency rejects a line item in
|
|
178
202
|
another — a decider's concern, and one it can now actually express.
|
|
203
|
+
|
|
204
|
+
The addition itself is integer addition, because both amounts are whole minor
|
|
205
|
+
units. That is the whole reason they are.
|
|
179
206
|
*/
|
|
180
207
|
let add = (a: t, b: t): result<t, string> =>
|
|
181
208
|
a.currency == b.currency
|
|
@@ -185,6 +212,81 @@ let add = (a: t, b: t): result<t, string> =>
|
|
|
185
212
|
`Converting between them needs a rate, which is not something an amount carries.`,
|
|
186
213
|
)
|
|
187
214
|
|
|
215
|
+
/** The largest whole number a `float` holds exactly, 2^53 - 1. Bound here
|
|
216
|
+
because `Float.Constants` does not carry it, and named because it is the
|
|
217
|
+
range every guarantee in this module is stated over. */
|
|
218
|
+
@val @scope("Number") external maxSafeInteger: float = "MAX_SAFE_INTEGER"
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
Multiply an amount by a count — a line item's unit price by its quantity.
|
|
222
|
+
|
|
223
|
+
`~by` is an `int` because the operand is a *count* and not a rate: three of
|
|
224
|
+
something, not 15% of something. Whole minor units times a whole count is
|
|
225
|
+
whole-number arithmetic, so the result is an exact amount and no rounding
|
|
226
|
+
question arises at all. That is the property this function exists to keep, and it
|
|
227
|
+
is why a call site should not write `amount *. Int.toFloat(quantity)` itself.
|
|
228
|
+
|
|
229
|
+
A rate is the other operation, and deliberately not this one. 15% of €10.50 is
|
|
230
|
+
€8.925, which is not an amount of euros in the first place — so applying a rate
|
|
231
|
+
is a rounding decision the domain has to state, through `toMajor`, the
|
|
232
|
+
arithmetic, and `ofMajor` back, with the rounding visible where someone chose it.
|
|
233
|
+
|
|
234
|
+
Refuses a product past the range where a `float` is an exact integer. Handing
|
|
235
|
+
back an inexact amount is the failure the wholeness check exists to prevent, and
|
|
236
|
+
it would not do to introduce it here.
|
|
237
|
+
*/
|
|
238
|
+
let times = (m: t, ~by: int): result<t, string> => {
|
|
239
|
+
let product = m.amount *. Int.toFloat(by)
|
|
240
|
+
Math.abs(product) <= maxSafeInteger
|
|
241
|
+
? Ok({amount: product, currency: m.currency})
|
|
242
|
+
: Error(
|
|
243
|
+
`cannot multiply ${format(m)} by ${Int.toString(by)}: the result is past the ` ++
|
|
244
|
+
`largest amount that stays exact, ${Float.toString(maxSafeInteger)} minor units.`,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
Split an amount into `into` parts that add back up to it.
|
|
250
|
+
|
|
251
|
+
The last minor unit has to go somewhere. Dividing and rounding each part on its
|
|
252
|
+
own loses or invents one — €10.00 into three is €3.33 three times, which is
|
|
253
|
+
€9.99 — so the remainder is handed out instead: the first parts get one minor
|
|
254
|
+
unit more than the rest. €10.00 into three is €3.34, €3.33, €3.33, and those add
|
|
255
|
+
up to €10.00.
|
|
256
|
+
|
|
257
|
+
*Which* parts get the extra unit is arbitrary, but it has to be decided rather
|
|
258
|
+
than left to a rounding mode, and "the earliest" is the decision here. A caller
|
|
259
|
+
that needs it somewhere else — the largest share, the payer's — reorders what it
|
|
260
|
+
gets back. A caller that needs uneven shares wants a split by ratio, which is a
|
|
261
|
+
different function and not this one.
|
|
262
|
+
|
|
263
|
+
A negative amount splits away from zero the same way: -€10.00 into three is
|
|
264
|
+
-€3.34, -€3.33, -€3.33. A refund divides like a charge.
|
|
265
|
+
*/
|
|
266
|
+
let allocate = (m: t, ~into: int): result<array<t>, string> =>
|
|
267
|
+
if into <= 0 {
|
|
268
|
+
Error(
|
|
269
|
+
`cannot split ${format(m)} into ${Int.toString(into)} parts: ` ++
|
|
270
|
+
`a split is into at least one part.`,
|
|
271
|
+
)
|
|
272
|
+
} else {
|
|
273
|
+
let parts = Int.toFloat(into)
|
|
274
|
+
// Truncated toward zero, so the remainder carries the sign of the amount and
|
|
275
|
+
// every part stays on the same side of zero as the whole it came from.
|
|
276
|
+
let share = Math.trunc(m.amount /. parts)
|
|
277
|
+
// Taken from the share actually used rather than from the ideal one, which
|
|
278
|
+
// is what makes the parts add back up by construction.
|
|
279
|
+
let remainder = m.amount -. share *. parts
|
|
280
|
+
let step = remainder < 0.0 ? -1.0 : 1.0
|
|
281
|
+
let extra = Math.abs(remainder)
|
|
282
|
+
Ok(
|
|
283
|
+
Array.fromInitializer(~length=into, i => {
|
|
284
|
+
amount: share +. (Int.toFloat(i) < extra ? step : 0.0),
|
|
285
|
+
currency: m.currency,
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
188
290
|
/** Add a run of amounts, refusing at the first currency that does not match the
|
|
189
291
|
first amount's. `None` for an empty run — the sum of no amounts has no
|
|
190
292
|
currency to be in. */
|
|
@@ -54,10 +54,14 @@ function zero(currency) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function scale(currency) {
|
|
58
|
+
return Math.pow(10.0, Currency$Reventless.exponent(currency));
|
|
59
|
+
}
|
|
60
|
+
|
|
57
61
|
function ofMajor(amount, currency) {
|
|
58
|
-
let
|
|
62
|
+
let scaled = amount * Math.pow(10.0, Currency$Reventless.exponent(currency));
|
|
59
63
|
return {
|
|
60
|
-
amount: Math.round(
|
|
64
|
+
amount: scaled < 0.0 ? - Math.round(- scaled) : Math.round(scaled),
|
|
61
65
|
currency: currency
|
|
62
66
|
};
|
|
63
67
|
}
|
|
@@ -108,6 +112,46 @@ function add(a, b) {
|
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
function times(m, by) {
|
|
116
|
+
let product = m.amount * by;
|
|
117
|
+
if (Math.abs(product) <= Number.MAX_SAFE_INTEGER) {
|
|
118
|
+
return {
|
|
119
|
+
TAG: "Ok",
|
|
120
|
+
_0: {
|
|
121
|
+
amount: product,
|
|
122
|
+
currency: m.currency
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
} else {
|
|
126
|
+
return {
|
|
127
|
+
TAG: "Error",
|
|
128
|
+
_0: `cannot multiply ` + format(m) + ` by ` + by.toString() + `: the result is past the ` + (`largest amount that stays exact, ` + Number.MAX_SAFE_INTEGER.toString() + ` minor units.`)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function allocate(m, into) {
|
|
134
|
+
if (into <= 0) {
|
|
135
|
+
return {
|
|
136
|
+
TAG: "Error",
|
|
137
|
+
_0: `cannot split ` + format(m) + ` into ` + into.toString() + ` parts: a split is into at least one part.`
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
let share = Math.trunc(m.amount / into);
|
|
141
|
+
let remainder = m.amount - share * into;
|
|
142
|
+
let step = remainder < 0.0 ? -1.0 : 1.0;
|
|
143
|
+
let extra = Math.abs(remainder);
|
|
144
|
+
return {
|
|
145
|
+
TAG: "Ok",
|
|
146
|
+
_0: Stdlib_Array.fromInitializer(into, i => ({
|
|
147
|
+
amount: share + (
|
|
148
|
+
i < extra ? step : 0.0
|
|
149
|
+
),
|
|
150
|
+
currency: m.currency
|
|
151
|
+
}))
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
111
155
|
function sum(amounts) {
|
|
112
156
|
if (amounts.length !== 0) {
|
|
113
157
|
return Stdlib_Array.reduce(amounts, {
|
|
@@ -126,10 +170,13 @@ export {
|
|
|
126
170
|
schema$1 as schema,
|
|
127
171
|
make,
|
|
128
172
|
zero,
|
|
173
|
+
scale,
|
|
129
174
|
ofMajor,
|
|
130
175
|
toMajor,
|
|
131
176
|
format,
|
|
132
177
|
add,
|
|
178
|
+
times,
|
|
179
|
+
allocate,
|
|
133
180
|
sum,
|
|
134
181
|
}
|
|
135
182
|
/* amountSchema Not a pure module */
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Generates `src/semantic/Currency.res` from the ISO 4217 table beside this
|
|
3
|
-
// script (`iso-4217-list-one.xml`, the standard's own "current currency & funds"
|
|
4
|
-
// publication).
|
|
5
|
-
//
|
|
6
|
-
// Why generated rather than hand-written: the codes and the minor-unit
|
|
7
|
-
// exponents have to agree, and the exponent is what makes `Money.format`
|
|
8
|
-
// derivable instead of a hardcoded `/100`. Taking both from one source makes
|
|
9
|
-
// them agree by construction — nobody has to remember that JPY has no decimals
|
|
10
|
-
// and TND has three.
|
|
11
|
-
//
|
|
12
|
-
// Updating to a newer ISO publication:
|
|
13
|
-
//
|
|
14
|
-
// curl -sL https://www.six-group.com/dam/download/financial-information/\
|
|
15
|
-
// data-center/iso-currrency/lists/list-one.xml \
|
|
16
|
-
// -o reventless/spec/scripts/iso-4217-list-one.xml
|
|
17
|
-
// pnpm --filter @reventlessdev/reventless-spec run generate:currency
|
|
18
|
-
//
|
|
19
|
-
// The output is committed: it is read in review, and a currency appearing or
|
|
20
|
-
// disappearing is exactly the kind of change that has to show up in a diff.
|
|
21
|
-
|
|
22
|
-
import {readFileSync, writeFileSync} from 'node:fs'
|
|
23
|
-
import {dirname, join} from 'node:path'
|
|
24
|
-
import {fileURLToPath} from 'node:url'
|
|
25
|
-
|
|
26
|
-
const here = dirname(fileURLToPath(import.meta.url))
|
|
27
|
-
const source = join(here, 'iso-4217-list-one.xml')
|
|
28
|
-
const target = join(here, '..', 'src', 'semantic', 'Currency.res')
|
|
29
|
-
|
|
30
|
-
const xml = readFileSync(source, 'utf8')
|
|
31
|
-
|
|
32
|
-
const published = xml.match(/<ISO_4217[^>]*Pblshd="([^"]+)"/)?.[1]
|
|
33
|
-
if (!published) throw new Error(`no Pblshd date in ${source} — is this the ISO 4217 list?`)
|
|
34
|
-
|
|
35
|
-
const field = (entry, tag) => entry.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1]?.trim()
|
|
36
|
-
|
|
37
|
-
// One entry per country, so a currency used in several countries repeats. Keyed
|
|
38
|
-
// by code; a repeat that disagrees about the exponent is a corrupt table, not
|
|
39
|
-
// something to pick a winner from.
|
|
40
|
-
const byCode = new Map()
|
|
41
|
-
const skipped = []
|
|
42
|
-
|
|
43
|
-
for (const [, entry] of xml.matchAll(/<CcyNtry>([\s\S]*?)<\/CcyNtry>/g)) {
|
|
44
|
-
const code = field(entry, 'Ccy')
|
|
45
|
-
// Territories with no currency of their own (Antarctica) carry no <Ccy>.
|
|
46
|
-
if (!code) continue
|
|
47
|
-
const minorUnits = field(entry, 'CcyMnrUnts')
|
|
48
|
-
const name = field(entry, 'CcyNm')
|
|
49
|
-
|
|
50
|
-
// `N.A.` means the entry has no minor unit at all: the precious metals (XAU,
|
|
51
|
-
// XAG, XPD, XPT), the bond market units (XBA–XBD), XDR, XUA, XSU, the testing
|
|
52
|
-
// code XTS and the "no currency" sentinel XXX. Admitting them would make
|
|
53
|
-
// `exponent` partial, which is the one property this type exists to have — so
|
|
54
|
-
// the standard's own table draws the line rather than a curated opinion. A
|
|
55
|
-
// field holding a weight of gold is not holding money.
|
|
56
|
-
if (!/^\d+$/.test(minorUnits ?? '')) {
|
|
57
|
-
if (!skipped.some(s => s.code === code)) skipped.push({code, name, minorUnits})
|
|
58
|
-
continue
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const exponent = Number(minorUnits)
|
|
62
|
-
const seen = byCode.get(code)
|
|
63
|
-
if (seen && seen.exponent !== exponent) {
|
|
64
|
-
throw new Error(
|
|
65
|
-
`${code} has two exponents in ${source}: ${seen.exponent} and ${exponent}`,
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
if (!seen) byCode.set(code, {code, name, exponent})
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const currencies = [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code))
|
|
72
|
-
if (currencies.length < 100) {
|
|
73
|
-
throw new Error(`only ${currencies.length} currencies parsed — the table did not parse`)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// The generated file quotes each currency's ISO name beside its constructor, so
|
|
77
|
-
// a reviewer reading a three-letter code does not have to look it up.
|
|
78
|
-
const constructors = currencies
|
|
79
|
-
.map(c => ` | /** ${c.name} */ ${c.code}`)
|
|
80
|
-
.join('\n')
|
|
81
|
-
|
|
82
|
-
const exponentArms = currencies
|
|
83
|
-
.map(c => ` | ${c.code} => ${c.exponent}`)
|
|
84
|
-
.join('\n')
|
|
85
|
-
|
|
86
|
-
const toStringArms = currencies.map(c => ` | ${c.code} => "${c.code}"`).join('\n')
|
|
87
|
-
|
|
88
|
-
// Wrapped rather than one 1,000-character line, so a code added or removed by a
|
|
89
|
-
// future ISO publication shows up as a one-line diff.
|
|
90
|
-
const all = currencies
|
|
91
|
-
.map(c => c.code)
|
|
92
|
-
.reduce((lines, code) => {
|
|
93
|
-
const last = lines[lines.length - 1]
|
|
94
|
-
if (last && `${last} ${code},`.length <= 96) lines[lines.length - 1] = `${last} ${code},`
|
|
95
|
-
else lines.push(` ${code},`)
|
|
96
|
-
return lines
|
|
97
|
-
}, [])
|
|
98
|
-
.join('\n')
|
|
99
|
-
|
|
100
|
-
const exponentCounts = [...new Set(currencies.map(c => c.exponent))]
|
|
101
|
-
.sort()
|
|
102
|
-
.map(e => `${currencies.filter(c => c.exponent === e).length}×${e}`)
|
|
103
|
-
.join(', ')
|
|
104
|
-
|
|
105
|
-
const skippedCodes = skipped.map(s => s.code).sort()
|
|
106
|
-
const group = codes => codes.filter(c => skippedCodes.includes(c)).join(', ')
|
|
107
|
-
const metals = group(['XAG', 'XAU', 'XPD', 'XPT'])
|
|
108
|
-
const bondUnits = group(['XBA', 'XBB', 'XBC', 'XBD'])
|
|
109
|
-
const rights = group(['XDR', 'XSU', 'XUA'])
|
|
110
|
-
|
|
111
|
-
const out = `// AUTO-GENERATED from ISO 4217 (published ${published}) — do not edit.
|
|
112
|
-
// Run \`pnpm --filter @reventlessdev/reventless-spec run generate:currency\`,
|
|
113
|
-
// or see \`scripts/generate-currency.mjs\` to update the source table first.
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
A currency, closed to the ${currencies.length} codes ISO 4217 defines a minor unit for.
|
|
117
|
-
|
|
118
|
-
## Why a type and not a three-letter string
|
|
119
|
-
|
|
120
|
-
A string field invites \`"eur"\` beside \`"EUR"\`, and two spellings of one
|
|
121
|
-
currency is a class of bug that reads as a data problem long after it became a
|
|
122
|
-
correctness problem — the values are present, and they simply never match. A
|
|
123
|
-
closed type makes the second spelling unwritable.
|
|
124
|
-
|
|
125
|
-
## Why generated, and why every code
|
|
126
|
-
|
|
127
|
-
The alternative was a curated handful (EUR, USD, GBP, JPY, …), which is small
|
|
128
|
-
and readable and wrong the first time an application needs a currency nobody
|
|
129
|
-
listed — a compile error in someone else's domain, fixable only by a framework
|
|
130
|
-
release. The thing being avoided by curating is ${currencies.length} constructors that are
|
|
131
|
-
machine-written and never read in full; the thing being risked is a release.
|
|
132
|
-
|
|
133
|
-
Generation also buys the property that makes this type worth having: \`exponent\`
|
|
134
|
-
comes from the *same* source as the codes, so it cannot drift from them
|
|
135
|
-
(${exponentCounts} decimal places across the set). That is what lets
|
|
136
|
-
\`Money.format\` derive its decimal placement instead of hardcoding \`/100\`, and
|
|
137
|
-
therefore what makes it correct for JPY and TND without anyone remembering that
|
|
138
|
-
those two are special.
|
|
139
|
-
|
|
140
|
-
## What is deliberately absent
|
|
141
|
-
|
|
142
|
-
The ${skipped.length} entries ISO lists with no minor unit: the precious metals
|
|
143
|
-
(${metals}), the bond market units (${bondUnits}), the accounting
|
|
144
|
-
units (${rights}), the testing code XTS, and the "no currency"
|
|
145
|
-
sentinel XXX. Each would make \`exponent\` partial, and a weight of gold is not
|
|
146
|
-
an amount of money. The standard's own table draws that line, so it is not a
|
|
147
|
-
curated opinion after all.
|
|
148
|
-
|
|
149
|
-
## The wire form
|
|
150
|
-
|
|
151
|
-
A payload-less variant, so the stored and transmitted form is the three-letter
|
|
152
|
-
code itself — \`{"amount": 1000, "currency": "EUR"}\`. Standard at the boundary,
|
|
153
|
-
a checked type in the domain.
|
|
154
|
-
*/
|
|
155
|
-
@schema
|
|
156
|
-
type t =
|
|
157
|
-
${constructors}
|
|
158
|
-
|
|
159
|
-
/** Every currency, in code order. \`fromString\` is derived from this, so a code
|
|
160
|
-
that parses and a code that exists are the same set by construction. */
|
|
161
|
-
let all: array<t> = [
|
|
162
|
-
${all}
|
|
163
|
-
]
|
|
164
|
-
|
|
165
|
-
/** The currency's ISO 4217 alphabetic code. */
|
|
166
|
-
let toString = (currency: t): string =>
|
|
167
|
-
switch currency {
|
|
168
|
-
${toStringArms}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
How many decimal places the currency's minor unit is: 2 for EUR, **0 for JPY**,
|
|
173
|
-
**3 for TND**, 4 for the Chilean Unidad de Fomento.
|
|
174
|
-
|
|
175
|
-
Total by construction — this is the whole reason the type is closed and the
|
|
176
|
-
table is generated. An amount is stored in integer minor units, so this is the
|
|
177
|
-
only thing that says where its decimal point goes.
|
|
178
|
-
*/
|
|
179
|
-
let exponent = (currency: t): int =>
|
|
180
|
-
switch currency {
|
|
181
|
-
${exponentArms}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
let byCode: dict<t> = {
|
|
185
|
-
let d = Dict.make()
|
|
186
|
-
all->Array.forEach(c => d->Dict.set(toString(c), c))
|
|
187
|
-
d
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
Parse an ISO 4217 alphabetic code, saying why when it is not one.
|
|
192
|
-
|
|
193
|
-
Case-sensitive on purpose: \`"eur"\` is rejected rather than repaired. This type
|
|
194
|
-
exists because a silent case mismatch is expensive to find, and quietly
|
|
195
|
-
accepting the wrong spelling at the boundary would put it back — a producer
|
|
196
|
-
sending lowercase codes should learn that at its first request, not at the first
|
|
197
|
-
report that two halves of a ledger disagree.
|
|
198
|
-
*/
|
|
199
|
-
let fromString = (raw: string): result<t, string> =>
|
|
200
|
-
switch byCode->Dict.get(raw) {
|
|
201
|
-
| Some(c) => Ok(c)
|
|
202
|
-
| None =>
|
|
203
|
-
Error(
|
|
204
|
-
\`expected an ISO 4217 currency code such as "EUR", got \${raw
|
|
205
|
-
->JSON.Encode.string
|
|
206
|
-
->JSON.stringify}. Codes are upper-case and exactly three letters.\`,
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
`
|
|
210
|
-
|
|
211
|
-
writeFileSync(target, out)
|
|
212
|
-
console.log(
|
|
213
|
-
`Currency.res: ${currencies.length} currencies (${exponentCounts} decimals), ` +
|
|
214
|
-
`ISO 4217 published ${published}, ${skipped.length} minor-unit-less entries skipped.`,
|
|
215
|
-
)
|