@reventlessdev/reventless-spec 3.0.0-alpha.123 → 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.
@@ -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 **TND three** — so a bare `1000` is €10.00 or ¥1000 or 1.000 TND, 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. 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. */
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 (`10.005` EUR → `1001`), which is what a reader
121
- expects of a price. That is a *boundary conversion* and not an arithmetic
122
- policy: the half-even question that FX and tax rounding turn on is a separate
123
- decision, and nothing here forecloses it.
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 scale = Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(currency)))
127
- {amount: Math.round(amount *. scale), currency}
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 — for charts, averages and anything that
131
- has to be a number rather than money. Lossy by nature: the result is a float
132
- again, so it is an output, not something to compute a balance in. */
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 scale = Math.pow(10.0, Currency$Reventless.exponent(currency));
62
+ let scaled = amount * Math.pow(10.0, Currency$Reventless.exponent(currency));
59
63
  return {
60
- amount: Math.round(amount * scale),
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
- )