@amboras-dev/money 0.0.0-stage → 0.1.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.
Files changed (78) hide show
  1. package/README.md +393 -2
  2. package/dist/ai.cjs +185 -0
  3. package/dist/ai.d.cts +97 -0
  4. package/dist/ai.d.ts +97 -0
  5. package/dist/ai.js +175 -0
  6. package/dist/allocate-CdnhvAda.js +199 -0
  7. package/dist/allocate-D7tm_t_o.cjs +222 -0
  8. package/dist/amount-D7dj76nH.js +200 -0
  9. package/dist/amount-DHtvZSSm.cjs +241 -0
  10. package/dist/arithmetic-Bq-8mSal.cjs +233 -0
  11. package/dist/arithmetic-C-Q_aI49.js +132 -0
  12. package/dist/billing.cjs +184 -0
  13. package/dist/billing.d.cts +81 -0
  14. package/dist/billing.d.ts +81 -0
  15. package/dist/billing.js +175 -0
  16. package/dist/commerce.cjs +536 -0
  17. package/dist/commerce.d.cts +293 -0
  18. package/dist/commerce.d.ts +293 -0
  19. package/dist/commerce.js +516 -0
  20. package/dist/format-BdB7LEIp.js +90 -0
  21. package/dist/format-BnA1aLJk.cjs +113 -0
  22. package/dist/fx.cjs +407 -0
  23. package/dist/fx.d.cts +157 -0
  24. package/dist/fx.d.ts +157 -0
  25. package/dist/fx.js +374 -0
  26. package/dist/index.cjs +349 -0
  27. package/dist/index.d.cts +288 -0
  28. package/dist/index.d.ts +288 -0
  29. package/dist/index.js +276 -0
  30. package/dist/input.cjs +163 -0
  31. package/dist/input.d.cts +203 -0
  32. package/dist/input.d.ts +203 -0
  33. package/dist/input.js +153 -0
  34. package/dist/iso-minor--gQWI1f2.cjs +65 -0
  35. package/dist/iso-minor-CtaiIifs.js +48 -0
  36. package/dist/medusa-CmxCni-G.js +101 -0
  37. package/dist/medusa-DuQ3-KoD.d.cts +78 -0
  38. package/dist/medusa-OJ2TJOxt.cjs +154 -0
  39. package/dist/medusa-RwqxaDGq.d.ts +78 -0
  40. package/dist/money-CIgELekG.js +3571 -0
  41. package/dist/money-CbhsT0fB.d.cts +232 -0
  42. package/dist/money-CbhsT0fB.d.ts +232 -0
  43. package/dist/money-D5-vlv0h.cjs +3960 -0
  44. package/dist/number-text-BpvN6NAo.cjs +145 -0
  45. package/dist/number-text-C82oh6JB.js +134 -0
  46. package/dist/own-D7544D3k.js +4 -0
  47. package/dist/own-oP9DXFWv.cjs +9 -0
  48. package/dist/percent-CKVI_c-m.js +20 -0
  49. package/dist/percent-Dt8vNIAW.cjs +43 -0
  50. package/dist/rate-CmeTGNex.d.ts +63 -0
  51. package/dist/rate-DGPfB_jc.cjs +217 -0
  52. package/dist/rate-lLPViBuM.js +152 -0
  53. package/dist/rate-toyS3EMf.d.cts +63 -0
  54. package/dist/reporting-5xby-Zas.d.ts +31 -0
  55. package/dist/reporting-DEM97DhQ.d.cts +31 -0
  56. package/dist/retail-BsvpNv1F.js +69 -0
  57. package/dist/retail-BwcFJcVN.d.ts +31 -0
  58. package/dist/retail-DTk2XAMM.cjs +86 -0
  59. package/dist/retail-Mqd-sPMF.d.cts +31 -0
  60. package/dist/rounding-CVAPEfq_.js +50 -0
  61. package/dist/rounding-oZ832yTD.cjs +79 -0
  62. package/dist/sql.cjs +290 -0
  63. package/dist/sql.d.cts +147 -0
  64. package/dist/sql.d.ts +147 -0
  65. package/dist/sql.js +271 -0
  66. package/dist/testing.cjs +28518 -0
  67. package/dist/testing.d.cts +97 -0
  68. package/dist/testing.d.ts +97 -0
  69. package/dist/testing.js +28510 -0
  70. package/dist/wire-CI3Sm1iC.js +1675 -0
  71. package/dist/wire-Cd4Q1Jmm.d.ts +269 -0
  72. package/dist/wire-DQZ6fA3c.cjs +1872 -0
  73. package/dist/wire-hWuixv3w.d.cts +269 -0
  74. package/dist/wire.cjs +35 -0
  75. package/dist/wire.d.cts +2 -0
  76. package/dist/wire.d.ts +2 -0
  77. package/dist/wire.js +2 -0
  78. package/package.json +158 -3
package/README.md CHANGED
@@ -1,3 +1,394 @@
1
- # Temporary Holding Version
1
+ # `@amboras-dev/money`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Money core shared by the Amboras orchestrator (backend + admin), Medusa and plugins:
4
+ the storage rule, a generated currency registry, and exact conversion between major
5
+ units and stored integers. Zero dependencies; CommonJS + ESM + types; Node >= 20 and
6
+ browsers.
7
+
8
+ ```ts
9
+ import { fromMajor, money, toMajorDecimal, readStored, currencyCode, defaultCurrencyForCountry } from '@amboras-dev/money'
10
+
11
+ fromMajor(19.99, 'USD') // { amount: 1999, currency: 'USD' }
12
+ fromMajor(1200, 'jpy') // { amount: 120000, currency: 'JPY' } (x100, not x1)
13
+ fromMajor('1.234', 'KWD') // { amount: 1234, currency: 'KWD' } (x1000)
14
+ fromMajor(5.5, 'JPY') // throws MoneyError 'precision'
15
+ fromMajor(1500.4, 'JPY', { precision: 'round' }) // { amount: 150000, currency: 'JPY' }
16
+ toMajorDecimal(readStored({ value: '4300', precision: 20 }, 'usd')) // '43.00'
17
+ toMajorDecimal(money(4350, 'HUF'), { digits: 'display' }) // '44'
18
+ currencyCode('bgn') // throws: BGN was replaced by EUR
19
+ defaultCurrencyForCountry('BG') // 'EUR'
20
+ ```
21
+
22
+ ## The storage rule
23
+
24
+ Amboras stores **every currency at x100, including zero-decimal ones** (JPY 1200 is
25
+ stored as `120000`, VND 5,810,000 as `581000000`), and **x1000 for exactly KWD, BHD,
26
+ OMR, TND, JOD, IQD, LYD**. The rule is keyed by currency code, never derived from ISO
27
+ 4217 digits: `10 ** isoDigits` as a divisor is the shape of every 100x money bug
28
+ Amboras has had. Medusa's payment module is the reference implementation.
29
+
30
+ ## Four digit concepts
31
+
32
+ They answer different questions and disagree on purpose. Never substitute one for another.
33
+
34
+ | Concept | Question | Values | API |
35
+ |---|---|---|---|
36
+ | Storage scale | What do I multiply a major amount by to store it? | 100, or 1000 for the seven | `storageScale(code)` |
37
+ | Storage digits | How many fraction digits does a stored amount keep on the way in? (Medusa `fractionDigitsFor`) | 0 for JPY, KRW, VND...; 3 for the seven; else 2 | `storageDigits(code)` |
38
+ | Display digits | How many fraction digits do people see? (Medusa `isoFractionDigits`) | 0 for JPY, HUF, IDR, IQD...; 3 for KWD, BHD, OMR, TND, JOD, LYD; else 2 | `displayDigits(code)` |
39
+ | PSP wire digits | What does a given payment provider expect on the wire? | per provider | `pspWireDigits` in `@amboras-dev/money/wire` |
40
+
41
+ IQD stores 3 digits and displays 0; HUF stores 2 and displays 0; JPY stores 0 digits
42
+ but at x100 scale, so a stored JPY integer has 2 implied decimals.
43
+
44
+ ## Entry points
45
+
46
+ Each subpath is a thin public surface over the core and the shared internals.
47
+ None re-implements a conversion, a rounding, a lookup or a parser.
48
+
49
+ | Import | For | Main exports |
50
+ |---|---|---|
51
+ | `@amboras-dev/money` | everyone | registry, `Money`, `fromMajor`, `readStored`, arithmetic, allocation, decimal text, `formatMoney` |
52
+ | `@amboras-dev/money/wire` | Medusa payment providers, pixels, feeds, carriers | `encodeWire`, `decodeWire`, `sameWireAmount`, `conventionOf`, `destinationAmount`, `feedPrice` |
53
+ | `@amboras-dev/money/sql` | Medusa and orchestrator SQL | `storedToMajorSql`, `toReportingSql`, `convertStoredSql`, `summaryAmountSql` |
54
+ | `@amboras-dev/money/fx` | rates and conversion (rates are passed in, never fetched) | `rate`, `rateFromDecimal`, `convert`, `toReporting`, `roundRetail` |
55
+ | `@amboras-dev/money/commerce` | Medusa and the orchestrator backend | tax, discounts, markup, cost, order value |
56
+ | `@amboras-dev/money/billing` | Amboras SaaS money (USD cents, credits), never merchant money | `usdCost`, `usdToCredits`, `applyCoupons` |
57
+ | `@amboras-dev/money/ai` | agent tools, prompts, manifests | `MONEY_UNIT_SENTENCE`, `moneyView`, `parseModelMoney`, `moneyMentions` |
58
+ | `@amboras-dev/money/input` | admin and ds money fields, imports, scrapers | `parseAmountInput`, `formatAmountInput`, `parseImportAmount`, `moneyTemplateOf` |
59
+ | `@amboras-dev/money/testing` | consumers' tests | `CURRENCY_CASES`, `MEDUSA_AMOUNT_SHAPES`, `PSP_GOLDEN_VECTORS`, `checkWireDigits` |
60
+
61
+ The checker that keeps hand-written money code out of the apps is a separate dev package, [`@amboras-dev/money-gate`](../money-gate).
62
+
63
+ ## Core API: currencies and Money
64
+
65
+ **Currencies** (`CurrencyCode` is a branded upper-case string)
66
+
67
+ - `currencyCode(input, { allowHistoric?, field? })`: `'usd'` to `'USD'`. Throws
68
+ `currency_invalid` for unknown codes. Retired codes (BGN, HRK) are accepted by default, so an
69
+ existing store's currency keeps validating; `allowHistoric: false` refuses them where a
70
+ currency is picked for something new (a new store or region).
71
+ - `parseCurrencyCode(input, { allowHistoric? })`, `findCurrency`: the same rule, `null` instead of a throw.
72
+ - `isCurrencyCode(value, { allowHistoric? })`: type guard; only canonical upper case; historic codes by default.
73
+ - `currencyOptions({ preferred?, locale?, includeHistoric? })`: the picker for a new choice, active
74
+ codes only unless `includeHistoric: true`.
75
+ - `currencyInfo(code)`: name, symbol, narrow symbol, `symbolIsAmbiguous` (another active
76
+ currency shares the narrow symbol), ISO numeric code, status, `replacedBy`, and the
77
+ three digit/scale values. Accepts historic codes.
78
+ - `storageScale(code)`, `storageDigits(code)`, `displayDigits(code)`; unknown codes throw
79
+ (no silent 2-digit default).
80
+ - `CURRENCIES`: every known currency, active and historic, sorted, frozen.
81
+ - `defaultCurrencyForCountry(iso2)`: current legal tender, or `null`. Never guesses USD.
82
+
83
+ **Money** (`Money = { amount: StoredAmount; currency: CurrencyCode }`, `StoredAmount` a
84
+ branded safe integer, `MajorDecimal` branded canonical decimal text)
85
+
86
+ - `money(stored, currency)`: wrap an already-stored integer.
87
+ - `fromMajor(major, currency, { precision?, rounding?, field? })`: number or decimal
88
+ string to Money. `precision: 'reject'` (default) is capability-kit `toStorageAmount`
89
+ exactly: too many fraction digits throws `precision` unless a number is within float
90
+ noise of an allowed amount (`0.1 + 0.2`); strings are exact and get no noise
91
+ allowance. `precision: 'round'` rounds to the storage digits with `rounding`
92
+ (`half-up` default, a tie away from zero; also `half-even`, `down`, `up`, `floor`,
93
+ `ceil`). `unit: 'storage'` keeps one storage unit instead of the storage digits, Medusa
94
+ `toMinorUnits` (`Math.round(major x divisor)`): JPY 1200.5 is 120050 (with `'currency'`,
95
+ the default, it throws `precision`, or is 120100 with `precision: 'round'`).
96
+ More than 2^46 units throws `overflow`.
97
+ - `toMajorDecimal(m, { digits?, rounding? })`: exact text by default, with the storage
98
+ digits, widened to the storage scale only when the stored value needs it (JPY
99
+ `120050` is `'1200.50'`); `digits: 'display'` rounds to the display digits.
100
+ - `toMajorNumber(m)`: the double nearest the exact amount.
101
+ - `readStored(raw, currency, { mode?, field? })`: Medusa's amount shapes (number,
102
+ numeric string, `{ value, precision }`, BigNumber) with Medusa `unwrapAmount`
103
+ precedence `value ?? numeric ?? itself`. A non-integer or unreadable amount throws
104
+ `amount_invalid`, or returns `null` with `mode: 'lenient'`; never `0`.
105
+
106
+ Every function accepts historic codes so stores and amounts in BGN or HRK keep working;
107
+ only choosing a currency for something new refuses them (`currencyOptions`, or
108
+ `currencyCode(input, { allowHistoric: false })`).
109
+
110
+ **Errors**: everything throws `MoneyError` with `code` in `currency_invalid`,
111
+ `currency_mismatch`, `amount_invalid`, `precision`, `overflow`, `parse`, and `field`
112
+ when the caller named one.
113
+
114
+ ## Core API: arithmetic, Medusa shapes, allocation, formatting
115
+
116
+ **Currencies**: `parseCurrencyCode` (null while unknown, never throws), `firstCurrency(...candidates)`
117
+ (no implicit default: `currency_missing` when every candidate is empty, `currency_invalid` for a
118
+ corrupt one), `sameCurrency`, `medusaCurrencyCode` (the only sanctioned lower-casing),
119
+ `currencySymbol`, `currencyName`, `currencyLabel`, `currencyOptions`, `localeForCurrency` (opt-in),
120
+ `medusaEpsilon` (Medusa core payment-status epsilon from the generated `medusaDigits` column,
121
+ pinned to `@medusajs/utils` 2.13.4). `CurrencyInfo` also carries `symbolAliases`, `localeHint` and `commaDecimal` (TND: its decimal mark is a comma, so `'12,500'` is 12.5).
122
+
123
+ **Medusa shapes**: `readStored` reads `.value ?? .raw.value ?? .raw_.value`, then
124
+ `.numeric ?? .numeric_`, then the primitive. `readStoredField` (`raw_<key>` first, no fallback
125
+ when it is unreadable), `readStoredDecimal` (keeps fractions, canonical text),
126
+ `readCalculatedPrice`, `toMedusaRaw` (never an exponent), `toMedusaAmountInput` (`'0'` for zero),
127
+ `moneyByCurrency` / `moneyFor` (per-currency settings and price lists), `isPositiveAmount`.
128
+
129
+ **Arithmetic** (exact, rounded once): `add`, `subtract`, `sum(items, currency)`, `multiply`,
130
+ `divide`, `percentOf`, `compare`, `equals` (false across currencies), `isZero`, `isPositive`,
131
+ `isNegative`, `min`, `max`, `clamp`, `remaining`, `within`, `excessOver`, `isStoredAmount`,
132
+ `roundToUnit(value, currency, { unit: 'storage' | 'currency' | 'major', rounding })`, `roundMajor`.
133
+ Every result is bounded at 2^46 units of the storage digits (`overflow`).
134
+ Rounding modes: `half-up` (default), `half-even`, `down`, `up`, `floor`, `ceil`,
135
+ `floor-after-tenth` (Shopify discount truncation).
136
+
137
+ **ISO minor** (not for PSPs): `toIsoMinor`, `fromIsoMinor` at the display digits or declared ones.
138
+
139
+ **Allocation**: `allocate(total, weights, { strategy, unit, caps })` with strategies
140
+ `largest-remainder` (default), `largest-weight`, `last`, `last-first`, `cumulative`; shares
141
+ always add up exactly; a DecimalText overload splits exact decimals at a scale.
142
+ `allocateWaterfall` (greedy capped, shortfall), `splitUnitPrice` (one or two unit prices one
143
+ unit apart).
144
+
145
+ **Decimal text**: `parseDecimalText`, `isDecimalLiteral` (the one storage-literal grammar),
146
+ `assertDecimalLiteral`, `trimDecimalZeros`, `decimalSum`, `decimalSubtract`, `decimalMultiply`,
147
+ `decimalDivide`, `decimalRound`, `decimalCompare`, `decimalClamp`, `decimalWithin`. Exact; `''`
148
+ and exponents are refused.
149
+
150
+ **Formatting** (for people, not byte-stable across ICU): `formatMoney` (display digits, or
151
+ `widen` / `trim-zero`; explicit locale, default `en-US`), `formatMoneyCompact`,
152
+ `formatMoneyRange`, `moneyAxisFormatter`.
153
+
154
+ Internals other entry points build on (never exported), each the only implementation of its concept:
155
+ `internal/number-text.ts` `readNumberText` (the one separator engine), `internal/rational.ts`
156
+ `mulDiv`/`ratioToDecimal`/`compareRatio`, `internal/percent.ts` `hundredPercent`/`percentOfUnits`/`percentRatio`/`basisPointsOf`,
157
+ `internal/intl.ts`, `decimal.ts` `addDecimal`/`multiplyDecimal`/`roundToScale`, `iso-minor.ts` `isoMinorFactor`,
158
+ `rounding.ts` `unitSize`/`mulDivToUnit`/`roundStoredDecimal`, `allocate.ts` `allocateUnits`, `medusa.ts` `storedFieldValue`,
159
+ `registry.ts` `currencyMarks`, `money.ts` `storedDecimal`/`majorDecimal`/`boundedMoney`/`medusaAmountDecimal`.
160
+
161
+ ## `/wire`: what payment providers and destinations receive
162
+
163
+ ```ts
164
+ import { money } from '@amboras-dev/money'
165
+ import { ISO_MINOR_V2, conventionOf, decodeWire, encodeWire } from '@amboras-dev/money/wire'
166
+
167
+ encodeWire('stripe', money(120000, 'JPY'), { convention: ISO_MINOR_V2 }) // 1200
168
+ decodeWire('stripe', 1200, 'JPY', conventionOf(paymentIntent)) // { amount: 120000, currency: 'JPY' }
169
+ ```
170
+
171
+ - Codec: `encodeWire`, `decodeWire`, `tryDecodeWire` (never throws), `wireFactor`, `pspWireDigits`, `oneWireUnit`, `wireBreakdown` (line items that sum to the encoded total), `wireKeyPart`.
172
+ - Cutover marker: `WIRE_SCALE_KEY`, `WIRE_SCALE_ISO_MINOR_V2`, `WIRE_SCALE_MARKER`, `ISO_MINOR_V2`, `PRE_CUTOVER`, `conventionOf` (gated PSPs need the convention; there is no hidden default).
173
+ - Provider tables: `PSP_IDS`, `pspSupportsCurrency`, `assertPspCurrency`, `pspCurrencies`, `pspDefaultCurrency`, `pspMinimumAmount`.
174
+ - Comparison: `sameWireAmount` (a webhook amount against the expected Money, at storage scale).
175
+ - Provider quirks: `verificationCharge`, `RAZORPAY_AFA_FREE_CAP`, `iyzicoPrice`, `authorizeNetReversibleAmount`, `jsonDecimalLiteral`.
176
+ - Provider text: `parseProviderAmount` (carrier quotes, dispute amounts; `decimalSeparator: 'auto'` is the `/input` rule).
177
+ - Destinations: `destinationAmount` (pixels, ad platforms, email tools), `feedPrice`, `googleMicros`, `eventMoney` (ClickHouse `Int64` contract).
178
+ - Plugin slots: `slotMoney`, `readSlotMoney`.
179
+
180
+ ## `/sql`: money in Postgres and ClickHouse
181
+
182
+ Every builder returns one shape, a `SqlFragment` `{ sql, bindings }` with knex `?` placeholders: run it as `knex.raw(f.sql, f.bindings)`.
183
+ Values a caller passes are bindings, never inlined: `convertStoredSql`'s rate numerator and denominator (exact bigint text), `summaryAmountSql`'s JSON keys; `chEventRevenueByCurrencySql` returns ClickHouse `params` for its `{revenue_event:String}` query parameter.
184
+ Column and expression arguments are identifiers and expressions written in code (a column name), or another builder's `SqlFragment`, whose bindings are carried along in placeholder order (`storedToMajorSql(summaryAmountSql('os.totals', 'paid_total'), 'o.currency_code')`).
185
+ Text arguments are inlined as-is and never sanitised; the guard only refuses a blank value, the statement breakers `;`, `--`, `/*`, `*/` and NUL, and a `?` (which would shift the bindings), a tripwire for mistakes, not an injection defence.
186
+ The DDL builders refuse fragments with bindings, since Postgres DDL takes no parameters.
187
+ Values the package inlines itself (code lists, digits, divisors) come from the registry; currency facts never come from `currency.decimal_digits`.
188
+ ClickHouse money is Decimal: `storedToMajorSql` divides `toDecimal128(amount, 3)` and `roundMajorSql` rounds `toDecimal128(value, 12)`, half away from zero like Postgres `ROUND(numeric)` (a Float64 is inexact and ClickHouse rounds it half-to-even; checked on ClickHouse 24.8).
189
+
190
+ - Amounts: `storageDivisorSql`, `storedToMajorSql`, `roundMajorSql`, `medusaEpsilonSql`, `withinEpsilonSql`, `summaryAmountSql` (`raw_<key>` first), `rawAmountJsonSql`, `parseDecimalSql`, `normalizeCurrencySql`.
191
+ - FX: `convertStoredSql`, `convertStoredByColumnsSql`, `toReportingSql`, `reportingCompleteSql` (with `fx.storeFactors` tables).
192
+ - DDL: `STORAGE_AMOUNT_SQL_TYPE`, `storageAmountCheckSql` (`columnType: 'bigint'` default checks only the sign, since Postgres refuses `bigint <> 'NaN'`; `'numeric'` emits migration 106's NaN/Infinity guard compared as text), `currencyCodeCheckSql`.
193
+ - Billing twins: `usdToCreditsSql`, `monthlyEquivalentSql` (same results as `/billing`).
194
+ - ClickHouse: `chEventRevenueByCurrencySql`.
195
+
196
+ ## `/fx`: rates and conversion
197
+
198
+ Rates are exact fractions (`Rate = { numerator, denominator }`, bigint) passed in by the caller, never fetched.
199
+
200
+ - Rates: `rate`, `rateFromDecimal`, `rateFromRow`, `rateToDecimal`, `rateToNumber`, `rateEquals`, `invertRate`, `crossRate`, `formatRate`, `RATE_REF`, `RATE_BOUNDS`.
201
+ `rateFromDecimal` rounds the exact decimal, while the orchestrator's `lockedRateNumerator` rounds the float `Math.round(rate * 1e7)`; about 0.6% of rates get a numerator one unit apart.
202
+ Adoption: the orchestrator and Medusa switch rate encoding in the same release, and the dynamic rate refresh compares a fresh rate with the locked one by `rateEquals` (value, cross-multiplied), never by numerator, or migrating one repo first re-locks those pairs every day.
203
+ - Conversion: `convert` (one rounding to a unit, then an optional retail ending), `convertFactor`, `impliedRate`.
204
+ - Reporting: `storeFactors`, `toReporting` (per-item rounding, missing currencies reported, never dropped silently).
205
+ - Retail endings: `roundRetail`, `priceRoundingParams`.
206
+ - Plausibility: `classifyRate`, `deviationFactor`, `rateConsensus`, `confirmStoredFactor`, `DEFAULT_RATE_MAX_DEVIATION`, `FACTOR_TOLERANCE`.
207
+ - Rate tables: `USD_PEGS`, `pegFallback`, `mergeRateTables`, `sanitizeRateTable`.
208
+ - Store settings: `RATE_MODES`, `parseRateMode`, `PRICE_ROUNDINGS`, `parsePriceRounding`, `resolveMoneyByCurrency`.
209
+
210
+ ## `/commerce`: tax, discounts, pricing, cost
211
+
212
+ - Tax: `splitGross`, `unitNetOfTax`, `allocateWithTax` (refunds; per row `net + tax == gross`), `inferTaxRatePercent`, `solveTaxRate`, `inferTaxesIncluded`, `displayTotals`.
213
+ - Discounts: `discountAmount` (order or per-unit rounding, Shopify truncation via `floor-after-tenth`), `buyGetReward`, `formatDiscount`.
214
+ - Pricing: `applyMarkup`, `retailPrice`, `marginPercent`, `percentChange`, `netOfFee`.
215
+ - Inventory cost (major-unit decimals): `landedUnitCosts`, `weightedAverageCost`.
216
+ - Orders: `resolveOrderValue` (the value ad platforms receive), `requiresPayment`, `storageConventionOf`.
217
+
218
+ ## `/billing`: Amboras SaaS money (USD cents and AI credits)
219
+
220
+ - Usage: `usdCost` (exact token cost, 6 decimals).
221
+ - Credits (1 credit = 1 US cent): `usdToCredits` (`ceil`, no epsilon; text and Money exact, a float cost first rounded half-even to 8 decimals so `0.07 * 3` gives 21), `creditsToUsd`, `prorateCredits`, `bucketCredits`.
222
+ - Plans: `annualPrice`, `monthlyEquivalent`, `applyCoupons` (Stripe sequential or additive), `grossUpForPercentOffs`.
223
+ `grossUpForPercentOffs` is the orchestrator's `grossUpCredit` verbatim, float product included (9 cents at `[10, 20]` gives 12): it inverts `applyCoupons` for one percent coupon, and can miss by a cent for stacked percents because Stripe rounds each coupon's cut.
224
+
225
+ ## `/input`: text to amount and back
226
+
227
+ One rule for every money field (the contract a future ds `<MoneyInput>` builds on), and one for files.
228
+ All of them read text through the one separator engine (`internal/number-text.ts`); none uses `parseFloat` or `Number`.
229
+
230
+ ```ts
231
+ import { parseAmountInput, formatAmountInput, parseImportAmount, inputStep } from '@amboras-dev/money/input'
232
+
233
+ parseAmountInput('1,250', 'USD') // { ok: true, money: { amount: 125000, ... }, text: '1250.00' }
234
+ parseAmountInput('29,99', 'EUR') // 29.99 (no locale: the 'auto' rule, as admin reads it today)
235
+ parseAmountInput('1,5', 'KWD') // 1.5
236
+ parseAmountInput('1200.5', 'JPY') // 1201 (no locale: a whole yen, half-up, as admin)
237
+ parseAmountInput('29,99', 'USD', { locale: 'en-US' }) // { ok: false, reason: 'unparseable' } (en-US groups with ',')
238
+ parseAmountInput('29,99', 'EUR', { locale: 'de-DE' }) // 29.99
239
+ parseAmountInput('1200.5', 'JPY', { locale: 'en-US' }) // { ok: false, reason: 'too_many_decimals' } (never rounded)
240
+ parseAmountInput('1,2345', 'USD') // { ok: false, reason: 'too_many_decimals' }
241
+ parseAmountInput('99.000', 'VND') // 99000 (3-digit tail groups in a currency without decimals)
242
+ parseAmountInput('12.99.', 'USD') // { ok: false, reason: 'unparseable' } (groups must be thousands or lakh)
243
+ parseAmountInput('1.500', 'TND', { decimalSeparator: '.' }) // 1.5 (an <input type="number"> value: '.' is always the decimal)
244
+ formatAmountInput(money(1500, 'TND'), { decimalSeparator: '.' }) // '1.5' (the number input's prefill, read back exactly)
245
+ formatAmountInput(money(123456789, 'USD'), { grouped: true, locale: 'de-DE' }) // '1.234.567,89'
246
+ parseImportAmount('1.299,00') // '1299'
247
+ parseImportAmount('0,500') // '0.5' (a group never follows a lone zero)
248
+ parseImportAmount('12,500', { currency: 'TND' }) // '12.5' (TND writes a decimal comma; KWD '1,500' is '1500')
249
+ parseImportAmount('1200,5', { currency: 'JPY' }) // '1201' (rounded to a whole yen)
250
+ inputStep('KWD') // '0.001'
251
+ ```
252
+
253
+ - `parseAmountInput(text, currency, { locale, decimalSeparator, allowNegative, allowZero, min, max, original })`: strips the currency's symbols and code and refuses more fraction digits than the currency stores.
254
+ Without `locale` (the chat wizard and any field that has none) it reads the `'auto'` rule below, which is today's admin `parseMajorUnits` except the listed differences, and a currency storing 0 digits rounds a fraction half-up to a whole unit (`'1200.5'` JPY is 1201), as admin does.
255
+ With `locale` it reads that locale's separators: the group separator is valid only in thousands groups (`'1,234,567'`) or Indian lakh groups (`'12,34,567'`: three digits on the right, then pairs), and only when every group fits (`'1,2345'` and `'12,3,456'` are `unparseable`), and extra digits are `too_many_decimals` in every currency.
256
+ **Every number input (`<input type="number">`) must pass `{ decimalSeparator: '.' }`**: browsers always serialise its value with a `.` decimal and no grouping, so the free-text rule would misread it (TND and IQD `'1.500'` are 1,500 there, 1.5 here).
257
+ In `'.'` mode `.` is the decimal, `,` and grouping are `unparseable`, a zero whole part may be omitted (`'.5'` is 0.5), and a currency storing 0 digits rounds a fraction half-up to a whole unit, as admin `numericEntryToMinorUnits` does (`'1200.5'` JPY is 1201).
258
+ It equals that function on every `.`-only value within the storage digits (`test/input/input.test.ts`), except one class:
259
+ - `extra-fraction-digits`: in a currency storing 2 or 3 digits, more fraction digits than it stores is `too_many_decimals`, never rounded (`'1.239'` USD, admin 124 cents; KWD `'1.0005'`, admin 1001).
260
+ Failure reasons: `empty`, `unparseable`, `too_many_decimals`, `negative`, `zero`, `below_min`, `above_max`.
261
+ - `formatAmountInput(m, { locale, decimalSeparator, grouped, digits })`: the field text for a stored amount, no symbol, exact: a sub-unit stored amount keeps its digits (JPY 120050, which Medusa writes today, is `'1200.5'`), never rounded on display.
262
+ Without `locale` it prints the spelling `parseAmountInput` reads back without a locale: trailing fraction zeros dropped (USD `'12.5'`, KWD 1500 `'1.5'`, IQD 10000000 `'10000'`), and TND, which writes a decimal comma, with `,` as the decimal and `.` as the group (`'1,125'`, `'1.234,5'`).
263
+ Two amounts have no such spelling and read back only through `original`: IQD with a three-digit fraction (`'1.125'` reads as 1,125, since IQD displays no decimals) and a sub-unit amount of a currency storing 0 digits (`'1200.5'` JPY reads as a whole 1,201).
264
+ With `locale` it uses that locale's separators and the storage digits (`'12,50'` de-DE); `digits: 'storage'` or `'trim-zero'` overrides the zeros either way.
265
+ With `decimalSeparator: '.'` (a number input's prefill) it prints `.` as the decimal, never groups and drops trailing zeros (TND 1500 is `'1.5'`), so `parseAmountInput(text, currency, { decimalSeparator: '.' })` reads it back exactly (a sub-unit JPY amount through `original`).
266
+ Prefill and save with `parseAmountInput(text, currency, { original: m })`: the untouched prefill text, or any text equal in value to `m`, returns `m` unchanged, so saving an untouched field never rewrites the amount.
267
+ - `inputStep`, `inputPlaceholder`: from the storage digits, never a hardcoded `0.01`.
268
+ - `parseImportAmount(text, { decimalSeparator: 'auto' | '.' | ',', currency, allowNegative, allowTrailingText })`: CSV, supplier, scraped and carrier text to `DecimalText` (major units), or `null`.
269
+ `'auto'` is the only comma/dot rule in the package (`/ai` `moneyMentions`, `/wire` `parseProviderAmount` and `moneyTemplateOf` use it too), and it treats `.` and `,` the same way:
270
+ when both appear, the later one is the decimal and the other groups (`'1.299,00'` and `'1,299.00'` are 1299).
271
+ One separator used more than once groups (`'1.299.000'` is 1299000, `'₹1,00,000'` is 100000).
272
+ One separator after nothing but zeros is the decimal (`'0,500'` and `'.5'` are 0.5).
273
+ One separator before exactly three digits groups (`'1.299'` EUR, `'1,299'` USD and `'Rp 199.000'` IDR are thousands), unless the currency displays three decimals and the separator is its decimal mark: KWD `'1.500'` is 1.5 while KWD `'1,500'` is 1500, and TND, which writes a decimal comma, reads `'12,500'` as 12.5 and `'1.500'` as 1500; IQD (displays 0 digits) groups both.
274
+ One separator before any other number of digits is the decimal (`'29,99'` is 29.99); more digits than the currency stores is left to the caller's precision policy.
275
+ Currencies without decimals (JPY, VND) group before three digits and round any other fraction to a whole unit (`'1200,5'` JPY is 1201).
276
+ Wherever a separator groups, the groups must be thousands (first group 1 to 3 digits, then exactly 3) or Indian lakh, none empty and none followed by a trailing separator; otherwise the text is `unparseable` (`'12.99.'`, `'1,2,3'`, `'1,23,4567'`, `'1,299,'`).
277
+ - `parseDecimalInput`, `parsePercentInput`: strict decimal and percent entry with digit limits and bounds.
278
+ - `parseRateInput(text, { locale })`: a typed exchange rate as a precise `fx` `Rate`, or `null`.
279
+ - `moneyTemplateOf(sample)`, `formatWithTemplate(m, template)`: replay a merchant's captured print format (`'Rp 199.000,00'`, `"1'299.00 CHF"`); the separators are read by the `'auto'` rule (`'Rp 199.000'` captures dot grouping and no decimals); the amount converts by the storage scale, so x1000 currencies are right.
280
+
281
+ ## `/ai`: money for agent tools
282
+
283
+ ```ts
284
+ import { MONEY_UNIT_SENTENCE, moneyFieldDescription, moneyView, parseModelMoney, moneyMentions } from '@amboras-dev/money/ai'
285
+
286
+ moneyFieldDescription('Refund amount.') // 'Refund amount. Amount in normal currency units as the merchant says it ...'
287
+ moneyView(money(1999, 'USD')) // { amount: 19.99, currency: 'USD', display: '$19.99' }
288
+ parseModelMoney('1,299.00', 'USD') // { ok: true, money: { amount: 129900, currency: 'USD' } }
289
+ parseModelMoney(19.999, 'USD') // { ok: false, error: '19.999 has more decimal places than USD uses (at most 2). Amount in ...' }
290
+ moneyMentions('Now AED 4,200.00') // [{ raw: 'AED 4,200.00', index: 4, symbol: 'AED', code: 'AED', amount: '4200' }]
291
+ ```
292
+
293
+ - `MONEY_UNIT_SENTENCE` and `MONEY_META_KEY` are verbatim from capability-kit and hashed into manifests.
294
+ `STORAGE_UNIT_SENTENCE` and `RATE_UNIT_SENTENCE` are the wording for stored amounts and manual rates.
295
+ - `moneyView(m)`: the one stored-to-model rendering: exact `amount`, and a `display` at the display digits, widened to keep a stored value finer than them (JPY 550 is `'¥5.5'`, HUF 435050 `'HUF 4,350.5'`).
296
+ Not capability-kit's digits: its `moneyView` uses the storage digits as the minimum (`'HUF 4,350.00'`, `'IQD 1.500'` where this shows `'HUF 4,350'`, `'IQD 1.5'`); see [Behaviour changes](#behaviour-changes-for-adopters).
297
+ - `parseModelMoney(input, currency, { allowNegative, allowZero })`: numbers through `fromMajor` (float noise allowed), strings through the `/input` entry rule in English.
298
+ It refuses extra fraction digits and negatives instead of guessing, and every error ends with `MONEY_UNIT_SENTENCE`, so the model is asked again.
299
+ - `moneyMentions(text, { currency })`, `moneyLiteralPattern(...)`, `currencySymbolAliases(code)`: prices in visible text from one fixed table (registry symbols, aliases and codes plus any Unicode currency sign), never the runtime's Intl data.
300
+ The pattern is `{ source, flags }` so a browser script (`page.evaluate`) runs exactly what Node runs.
301
+
302
+ ## `/testing`: shared fixtures and golden data
303
+
304
+ Framework-agnostic: no test runner or fast-check dependency.
305
+
306
+ - `CURRENCY_CASES`: one currency per storage, display and wire class (USD, JPY, KWD, IQD, HUF, IDR, ISK, UGX, CLP, CVE, MGA, XOF, VND, INR, historic BGN).
307
+ - `MEDUSA_AMOUNT_SHAPES`: every amount shape Medusa hands over, with what `readStored` and `readStoredDecimal` give.
308
+ - `ROUNDING_VECTORS`: the cases float code got wrong (`1.005`, `0.1 + 0.2`, `-2.5`, Shopify truncation) and the tie rules.
309
+ - `PSP_GOLDEN_VECTORS`: the wire bytes today's Medusa codecs produce for every PSP, every currency it presents and 18 storage amounts (one unit, ties at /100 and /1000, typical prices, the 2^46-unit bound), with refusals as their verbatim messages.
310
+ `/wire`'s `encodeWire` must reproduce every vector (decision D2: wire bytes feed idempotency keys, Redis keys and HMAC strings).
311
+ A positive amount whose wire rounds to 0 (68 vectors: one storage unit of a zero-decimal currency at Stripe, Adyen, Razorpay, Airwallex, PayPal HUF/JPY/TWD, Mollie ISK/JPY) is a refusal vector by package policy: `encodeWire` refuses it with `wire_refused` (`... rounds to 0 at <Tag> ...`).
312
+ Medusa today puts 0 on the wire at initiate for Stripe and Adyen, and for Razorpay, PayPal and Airwallex everywhere; it refuses at refund and capture for Stripe and Adyen, and in Mollie, iyzico and Authorize.Net.
313
+ - `DESTINATION_GOLDEN_VECTORS`: what `destinationAmount`, `feedPrice` and `googleMicros` must give per destination.
314
+ - `checkWireDigits(psp, documented)`: the capability-consistency probe; returns mismatch messages.
315
+ - `normalizeIntlSpaces(s)`, `sampleMoney(currency, major)`.
316
+
317
+ The golden vectors are generated, never edited: `scripts/gen-golden-vectors.mjs` runs Medusa's own adapter encoders (`src/payment-seam/adapters/*/amount.ts` and `src/common/money`) under Node's type stripping.
318
+ The file header pins a SHA-256 of every Medusa source it read, so `--check` fails when a codec changes.
319
+
320
+ ```sh
321
+ node scripts/gen-golden-vectors.mjs --medusa ../medusa-backend-orchestrator --ref origin/staging
322
+ node scripts/gen-golden-vectors.mjs --medusa ../medusa-backend-orchestrator --ref origin/staging --check
323
+ ```
324
+
325
+ ## Behaviour changes for adopters
326
+
327
+ What an app sees change when it moves its own money code onto the package.
328
+ The full list, with the decision behind each, is in [`docs/money/API-SPEC.md`](../../docs/money/API-SPEC.md) ("Behaviour changes").
329
+
330
+ - **Display digits for 15 currencies.** AFN, ALL, COP, HUF, IDR, IQD, IRR, KPW, LAK, LBP, MMK, PKR, SOS, SYP and YER display fewer digits than they store (IQD stores 3 and shows 0, the rest store 2 and show 0).
331
+ `formatMoney`, `moneyView` (`/ai`), `destinationAmount` and `feedPrice` (`/wire`) use the display digits; capability-kit, the plugins and Medusa's `formatMoney` use the storage digits today.
332
+ This is deliberate: it is what the PSPs charge and what people read.
333
+ - `formatMoney` (default `digits: 'display'`) rounds half-up: HUF 435050 was `HUF 4,350.50` and is `HUF 4,351`; IQD 1500 was `IQD 1.500` and is `IQD 2`.
334
+ - Catalog feeds and destinations drop the sub-unit the same way: `feedPrice(money(435050, 'HUF'))` is `'4351 HUF'`, IQD 1500 is `'2 IQD'`.
335
+ - Model views keep the value but show fewer zeros: `moneyView` returns the exact `amount` and widens `display` only for a sub-unit value (`'HUF 4,350.5'`), so HUF 435000 is `'HUF 4,350'` where capability-kit shows `'HUF 4,350.00'`, and IQD 1500 is `'IQD 1.5'` where it shows `'IQD 1.500'`.
336
+ - Machine files that need storage precision use `toMajorDecimal(m)` or `formatAmountInput(m)`.
337
+ - **Retail `'none'` rounds zero-decimal currencies to a whole unit**, as Medusa `applyRounding` does (JPY 150050 gives 150100).
338
+ - **Rate encoding** rounds the exact decimal, not the float (see `/fx`); both repos switch together and compare rates with `rateEquals`.
339
+ - **BGN is historic, and only new choices drop it.** Existing BGN stores (401 regions, 363 `store_currency` rows on prod) keep working: `currencyCode`, `parseCurrencyCode`, `isCurrencyCode`, `moneyByCurrency` and every Money function accept historic codes by default.
340
+ The picker for a new store or region, `currencyOptions()`, no longer lists BGN (Bulgaria is EUR), while Medusa's `iso-currencies.ts` and the orchestrator still do; validate a new choice with `currencyCode(input, { allowHistoric: false })`.
341
+ - **Lesotho defaults to LSL** (the orchestrator says LSL too; CLDR lists ZAR first). BY, SL and ZW default to BYN, SLE and ZWG, where the orchestrator's map still seeds the retired BYR, SLL and ZWL.
342
+ - **The OMR fallback peg is 0.3845** (CBO midpoint), not the orchestrator's 0.385: OMR fallbacks move by 0.13%.
343
+ - **Lakh grouping reads**: `parseAmountInput('1,00,000', 'INR')` is 100000 (it was `unparseable`), and `moneyMentions('₹1,00,000')` reads 100000 (it was 1).
344
+ - **Typed price text: `parseAmountInput` without a locale is admin `parseMajorUnits` + `majorToMinorUnits` except these classes.**
345
+ `test/input/parse-corpus.test.ts` pins it on 643 inputs x 12 currencies (7,716 cases) against a verbatim port of the admin rule (dev-orch `admin/lib/chat/price-units.ts` at `8125e05c`); every other case is identical, including every case in JPY, VND and CLP outside `negative-sign`, `foreign-characters`, `above-storage-limit` and `invalid-grouping`.
346
+ Number inputs are not in this list: they use `{ decimalSeparator: '.' }`, compared with admin `numericEntryToMinorUnits` above.
347
+ Counts are differing cases in that corpus.
348
+ - `lone-dot-thousands` (68): in a currency storing two decimals, one `.` before exactly three digits groups thousands, as `,` does: `'1.299'` USD is $1,299 (admin $1.30), `'99.000'` is $99,000 (admin $99).
349
+ - `dot-not-decimal-mark` (26): one separator before exactly three digits is the decimal only when the currency displays three decimals and it is that currency's decimal mark (the dot for KWD, BHD, OMR, JOD and LYD, the comma for TND); otherwise it groups. So TND `'1.500'` is 1,500 (admin 1.5) while TND `'12,500'` is 12.5, and IQD, which displays 0 digits, reads `'1.500'` and `'1,500'` as 1,500 (admin 1.5 and 1,500); KWD `'1.500'` is 1.5 and `'1,500'` is 1,500, as admin.
350
+ `formatAmountInput` therefore prints TND with a decimal comma and drops trailing zeros, so its no-locale text reads back without `original`, except IQD amounts with a three-digit fraction.
351
+ - `repeated-dot` (117): `.` written more than once, in valid groups, groups, where admin kept the first dot as the decimal: `'1.299.000'` USD is $1,299,000 (admin $1.30), KWD is 1,299,000 (admin 1.299).
352
+ - `comma-long-fraction` (158): one `,` before more digits than the currency stores (other than three) is the decimal and the precision rule decides, where admin removed the comma: `'1,2345'` USD is `too_many_decimals` (admin $12,345); KWD `'1,5000'` is 1.5 (admin 15,000).
353
+ - `extra-fraction-digits` (297): more fraction digits than the currency stores is `too_many_decimals`, where admin rounded to the storage unit: `'1.2345'` USD (admin 123 cents), `'1,299.005'` USD (admin 129901).
354
+ - `negative-sign` (72): a leading `-` or U+2212 is a negative amount (`negative` unless `allowNegative`), where admin dropped it: `'-5'` USD (admin $5).
355
+ - `foreign-characters` (316): text other than digits, separators, spaces, apostrophes, a sign and the currency's own symbols and code is `unparseable`, where admin deleted it: `'1e3'` (admin 13), `'1,299 abc'` (admin 1299), `'Rs. 1,299'` in USD (admin $1.30; INR reads it as 1299).
356
+ - `above-storage-limit` (144): past 2^46 units of the storage digits is `above_max`, where admin lost digits in a float: `'12345678901234567'` JPY (admin 1234567890123460000).
357
+ - `invalid-grouping` (3,652): a separator read as grouping must sit in thousands groups (first group 1 to 3 digits, then groups of exactly 3) or Indian lakh groups, with no empty group and no separator after them; otherwise the text is `unparseable`, where admin read a number: `'12.99.'` USD (admin $12.99; the package read $1,299 before this rule), `'1,2,3'` (admin $123), `'12,3,456'`, `'1,23,4567'`, `'1,299,'`, `'1234.500'` USD (admin $1,234.50) and `'1.2.3'` JPY (admin 12 yen).
358
+ Most of this class comes from the corpus's generated doubled-segment strings (`'12.50.50'`); `'1.299.000'` and `'12,34,567'` still read.
359
+ - **Negative exact ties round away from zero (D1).** The default `half-up` gives `fromMajor(-2.5, 'JPY', { precision: 'round' })` = -300 where Medusa `Math.round` (ties toward +infinity) gives -200; positive ties agree.
360
+ A read of Medusa `origin/staging` on 2026-10-22 (all 205 `Math.round` calls in `src/` and every caller of `toMinorUnits`, `roundToDisplayUnit` and `roundToStorageUnit`) found no refund, return, store-credit, gift-card or dispute path that rounds a negative exact tie: each operand is non-negative, or clamped to zero or above around the rounding.
361
+ One path does: `src/common/orders/order-address-change.ts:290` rounds the total change of a shipping-address tax requote with `roundToDisplayUnit`, and that delta is negative when the new address taxes less and can be an exact tie (the totals are unrounded).
362
+ A -0.5 unit delta rounds to -0 there, which also passes the hold gate (`delta !== 0` in `order-address-change-service.ts`), where the package gives -1; that site's adoption must choose its tie rule explicitly.
363
+
364
+ ## Decisions
365
+
366
+ - **One implementation per concept.** One decimal engine (bigint, exact) and one
367
+ rounding function; one registry lookup for every scale and digits question.
368
+ Every amount is computed exactly, except one documented verbatim port:
369
+ `/billing` `grossUpForPercentOffs` keeps the orchestrator's float64 `grossUpCredit`
370
+ so granted upgrade credits match to the cent (money-gate exemptions MG002/MG003 on
371
+ its lines; see `docs/decisions.md` for the plan to make it exact).
372
+ - **Generated registry.** `src/data/currencies.ts` and `src/data/countries.ts` come from
373
+ `scripts/gen-currency-data.mjs` and pinned CLDR JSON (version in `CLDR_VERSION`),
374
+ never from the runtime's `Intl`, which changes with Node/ICU. CI runs `--check`.
375
+ The script holds the decisions: the storage sets, Medusa's digit tables, retired codes
376
+ and their replacements, active codes CLDR no longer lists as tender (SVC, VED).
377
+ It fails instead of guessing when a CLDR release needs a new decision.
378
+ - **Codes** are Medusa's ISO 4217 list plus retired codes (ANG, BGN, BYR, CUC, EEK,
379
+ HRK, LTL, LVL, MRO, SLL, STD, VEF, ZMK, ZWL). BGN is historic: CLDR 48.2.0 ends it on
380
+ 2026-01-31 after Bulgaria adopted the euro.
381
+ - **Numeric codes** come from CLDR `codeMappings` and are `null` where CLDR has none
382
+ (BYN, SLE, SVC, XCG, ZWG and some retired codes).
383
+ - **Countries** map each CLDR region to its first listed open legal tender (what ICU
384
+ uses: PA is PAB, BT is BTN), except where that is a foreign currency: Lesotho is LSL
385
+ (CLDR lists ZAR first). See `docs/decisions.md` for every difference from the
386
+ orchestrator's country map.
387
+
388
+ ## Testing
389
+
390
+ `pnpm test` (vitest, 100% coverage). Property tests check the rounding modes against an
391
+ independent distance-based reference and the classic rounding table, the
392
+ `fromMajor`/`toMajorDecimal` round trip for every currency, and registry parity with
393
+ Medusa's digit tables (encoded in the test as fixtures). capability-kit's
394
+ `toStorageAmount` tests are ported as parity tests for `precision: 'reject'`.
package/dist/ai.cjs ADDED
@@ -0,0 +1,185 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_money = require("./money-D5-vlv0h.cjs");
3
+ const require_format = require("./format-BnA1aLJk.cjs");
4
+ const require_number_text = require("./number-text-BpvN6NAo.cjs");
5
+ const require_amount = require("./amount-DHtvZSSm.cjs");
6
+ //#region src/ai/mentions.ts
7
+ const ACTIVE = require_money.CURRENCIES.filter((c) => c.status === "active");
8
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9
+ const LETTER_START = /^\p{L}/u;
10
+ const LETTER_END = /\p{L}$/u;
11
+ const SINGLE_LETTER = /^\p{L}$/u;
12
+ const OWNERS = /* @__PURE__ */ new Map();
13
+ for (const info of ACTIVE) for (const mark of require_money.currencyMarks(info)) OWNERS.set(mark, [...OWNERS.get(mark) ?? [], info.code]);
14
+ /**
15
+ * Every spelling a store may print for `currency` besides its ISO code, from the registry:
16
+ * symbol, narrow symbol and aliases (`['₹', 'Rs.', 'Rs']` for INR, `['$', 'US$']` for USD).
17
+ */
18
+ function currencySymbolAliases(currency) {
19
+ const info = require_money.resolveCurrency(currency, { allowHistoric: true });
20
+ return Object.freeze(require_money.currencyMarks(info).filter((mark) => mark !== info.code));
21
+ }
22
+ /** A mark as a regex alternative; a mark that starts or ends with a letter must not touch another letter. */
23
+ function markSource(mark) {
24
+ const before = LETTER_START.test(mark) ? "(?<!\\p{L})" : "";
25
+ const after = LETTER_END.test(mark) ? "(?!\\p{L})" : "";
26
+ return `${before}${escapeRegExp(mark)}${after}`;
27
+ }
28
+ function numberSource(maxFractionDigits) {
29
+ const fraction = maxFractionDigits === 0 ? "" : `(?:[.,]\\d{1,${maxFractionDigits}})?`;
30
+ return `(?:${require_number_text.digitGroupingSource("[ \\u00a0\\u2009\\u202f.,'’]")})${fraction}|\\d+${fraction}`;
31
+ }
32
+ /**
33
+ * The money-literal regex as `{ source, flags }` (flags `'gu'`), a string so a browser script
34
+ * (`page.evaluate`) runs exactly what Node runs. It matches a currency mark next to a number,
35
+ * either order, at most one space between: the marks of `currency`, or of every active
36
+ * currency (single-letter marks such as `R` only when `currency` names them), plus any Unicode
37
+ * currency sign. Numbers group in threes or in Indian lakh (`1,00,000`) with `.`, `,`, space
38
+ * or apostrophe and carry up to `maxFractionDigits` (default 3) fraction digits. Groups: 1 mark and 2 number, or 3 number
39
+ * and 4 mark. A bare number never matches.
40
+ */
41
+ function moneyLiteralPattern(options = {}) {
42
+ const digits = options.maxFractionDigits ?? 3;
43
+ if (!Number.isInteger(digits) || digits < 0 || digits > 6) throw new require_money.MoneyError("amount_invalid", `maxFractionDigits must be an integer from 0 to 6, got ${require_money.describeValue(digits)}`);
44
+ const mark = `(${[...(options.currency === void 0 ? [...OWNERS.keys()].filter((mark) => !SINGLE_LETTER.test(mark)) : require_money.currencyMarks(require_money.resolveCurrency(options.currency, { allowHistoric: true }))).sort((a, b) => b.length - a.length || (a < b ? -1 : 1)).map(markSource), "\\p{Sc}"].join("|")})`;
45
+ const number = `(${numberSource(digits)})`;
46
+ return {
47
+ source: `${mark}\\s?${number}|${number}\\s?${mark}`,
48
+ flags: "gu"
49
+ };
50
+ }
51
+ function codeOfMark(mark, currency) {
52
+ if (currency && require_money.currencyMarks(currency).includes(mark)) return currency.code;
53
+ const owners = OWNERS.get(mark);
54
+ return owners?.length === 1 ? owners[0] : void 0;
55
+ }
56
+ /**
57
+ * Every price in `text`, in order (see `moneyLiteralPattern`). With `currency`, its marks are
58
+ * matched (single letters too) and claim the mentions they appear in; a mark only one active
59
+ * currency prints names that currency; `$`, `kr` and other shared marks leave `code` unset.
60
+ * `amount` is read with the separator engine's `'auto'` rule in the named currency (one
61
+ * separator before exactly three digits groups, except a KWD-style decimal mark).
62
+ */
63
+ function moneyMentions(text, options = {}) {
64
+ const currency = options.currency === void 0 ? null : require_money.resolveCurrency(options.currency, { allowHistoric: true });
65
+ const { source, flags } = moneyLiteralPattern({ currency: currency?.code });
66
+ const mentions = [];
67
+ for (const match of String(text).matchAll(new RegExp(source, flags))) {
68
+ const symbol = match[1] ?? match[4];
69
+ const code = codeOfMark(symbol, currency);
70
+ const read = require_number_text.readNumberText(match[2] ?? match[3], {
71
+ decimalSeparator: "auto",
72
+ currency: code
73
+ });
74
+ mentions.push({
75
+ raw: match[0],
76
+ index: match.index,
77
+ symbol,
78
+ ...code === void 0 ? {} : { code },
79
+ ..."reason" in read ? {} : { amount: require_money.toDecimalText({
80
+ units: read.units,
81
+ scale: read.scale
82
+ }) }
83
+ });
84
+ }
85
+ return mentions;
86
+ }
87
+ //#endregion
88
+ //#region src/ai/wording.ts
89
+ /** The one unit sentence every money field carries, for the model and for people. Verbatim. */
90
+ const MONEY_UNIT_SENTENCE = "Amount in normal currency units as the merchant says it (99 means 99.00; 1.5 means 1.50). Never cents or minor units.";
91
+ /** JSON Schema keyword marking a money field. Verbatim. */
92
+ const MONEY_META_KEY = "x-amboras-money";
93
+ /** How store API amounts are scaled, for tool descriptions that hand stored amounts to the model. */
94
+ const STORAGE_UNIT_SENTENCE = "Store API amounts are storage units: major units x100, x1000 for KWD, BHD, OMR, TND, JOD, IQD and LYD, and x100 for zero-decimal currencies too. Render them with the money formatter; never divide or multiply by hand.";
95
+ /** What a manual exchange rate means, for rate fields. */
96
+ const RATE_UNIT_SENTENCE = "1 unit of the store currency equals this many units of the other currency.";
97
+ /** A money field's description: `describe` (trimmed) followed by the unit sentence, or the sentence alone. */
98
+ function moneyFieldDescription(describe) {
99
+ const lead = typeof describe === "string" ? describe.trim() : "";
100
+ return lead ? `${lead} ${MONEY_UNIT_SENTENCE}` : MONEY_UNIT_SENTENCE;
101
+ }
102
+ //#endregion
103
+ //#region src/ai/model-money.ts
104
+ /**
105
+ * `{ amount: 19.99, currency: 'USD', display: '$19.99' }`. `amount` is exact; `display` shows the
106
+ * display digits, widened to keep a stored value finer than them (JPY 550 is `'¥5.5'`, HUF 435050
107
+ * `'HUF 4,350.5'`). capability-kit's `moneyView` uses the storage digits as the minimum instead
108
+ * (HUF 435000 is `'HUF 4,350.00'` there and `'HUF 4,350'` here, IQD 1500 `'IQD 1.500'` against
109
+ * `'IQD 1.5'`): a deliberate change, listed in the README's behaviour changes.
110
+ */
111
+ function moneyView(m) {
112
+ const { info } = require_money.storedDecimal(m);
113
+ return {
114
+ amount: require_money.toMajorNumber(m),
115
+ currency: info.code,
116
+ display: require_format.formatMoney(m, {
117
+ locale: "en",
118
+ digits: "widen"
119
+ })
120
+ };
121
+ }
122
+ function failure(reason, input, code, digits) {
123
+ const shown = require_money.describeValue(input);
124
+ return {
125
+ ok: false,
126
+ error: `${{
127
+ empty: `Not an amount: ${shown}.`,
128
+ unparseable: `Not an amount: ${shown}.`,
129
+ too_many_decimals: `${shown} has more decimal places than ${code} uses (at most ${digits}).`,
130
+ negative: "The amount must not be negative.",
131
+ zero: "The amount must be more than zero.",
132
+ below_min: `${shown} is out of range for ${code}.`,
133
+ above_max: `${shown} is out of range for ${code}.`
134
+ }[reason]} ${MONEY_UNIT_SENTENCE}`
135
+ };
136
+ }
137
+ function fromNumber(input, currency, options) {
138
+ if (!Number.isFinite(input)) return "unparseable";
139
+ if (input < 0 && !options.allowNegative) return "negative";
140
+ if (input === 0 && options.allowZero === false) return "zero";
141
+ try {
142
+ return require_money.fromMajor(input, currency);
143
+ } catch (error) {
144
+ return error.code === "precision" ? "too_many_decimals" : "above_max";
145
+ }
146
+ }
147
+ /**
148
+ * An amount from a model tool call (major units, as the merchant says it) as Money, or an
149
+ * error to send back to the model ending with `MONEY_UNIT_SENTENCE`. Numbers go through
150
+ * `fromMajor` (float noise such as 0.1 + 0.2 allowed); strings through `/input`'s entry rule
151
+ * in English (`'1,299.00'`, `'$1,299'`, `'99.000'` VND, `'1.500'` JPY). More fraction digits
152
+ * than the currency stores and negative amounts are refused, never rounded or flipped. An
153
+ * unknown currency throws `currency_invalid` (it is the tool's, not the model's).
154
+ */
155
+ function parseModelMoney(input, currency, options = {}) {
156
+ const info = require_money.resolveCurrency(currency, { allowHistoric: true });
157
+ if (typeof input === "number") {
158
+ const result = fromNumber(input, info.code, options);
159
+ return typeof result === "string" ? failure(result, input, info.code, info.storageDigits) : {
160
+ ok: true,
161
+ money: result
162
+ };
163
+ }
164
+ if (typeof input !== "string") return failure("unparseable", input, info.code, info.storageDigits);
165
+ const result = require_amount.parseAmountInput(input, info.code, {
166
+ locale: "en",
167
+ allowNegative: options.allowNegative,
168
+ allowZero: options.allowZero
169
+ });
170
+ return result.ok ? {
171
+ ok: true,
172
+ money: result.money
173
+ } : failure(result.reason, input, info.code, info.storageDigits);
174
+ }
175
+ //#endregion
176
+ exports.MONEY_META_KEY = MONEY_META_KEY;
177
+ exports.MONEY_UNIT_SENTENCE = MONEY_UNIT_SENTENCE;
178
+ exports.RATE_UNIT_SENTENCE = RATE_UNIT_SENTENCE;
179
+ exports.STORAGE_UNIT_SENTENCE = STORAGE_UNIT_SENTENCE;
180
+ exports.currencySymbolAliases = currencySymbolAliases;
181
+ exports.moneyFieldDescription = moneyFieldDescription;
182
+ exports.moneyLiteralPattern = moneyLiteralPattern;
183
+ exports.moneyMentions = moneyMentions;
184
+ exports.moneyView = moneyView;
185
+ exports.parseModelMoney = parseModelMoney;