diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1aff213 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md + +This file provides guidance to coding agents working in this repository, and is the only copy of it: +`CLAUDE.md` is a stub that imports this file, so edit this one. + +## What this is + +`pelmered/larapara` — a standalone Laravel package (no app skeleton) wrapping [Money PHP] with the +Laravel pieces it leaves out: a localized formatter/parser, Eloquent casts, migration macros, and a +cached currency registry. `README.md` is the reference documentation and is unusually complete — read +the relevant section before changing behaviour, and update it when behaviour changes. + +## Commands + +```bash +composer test # Pest via testbench package:test +composer test -- tests/Unit/MoneyFormatterTest.php # single file (args forward to Pest) +composer test -- --filter="parses" # single test by name +composer lint # pint + rector --dry-run + phpstan (level 8) +composer fix # pint --fix + rector (applies changes) +composer coverage # clover coverage over src/ +composer types # Pest type coverage +``` + +Tests run on Testbench with sqlite `:memory:`; there is no dev database to protect. + +## Architecture + +**Minor units are the currency of the codebase.** Every amount crossing an API boundary — what +`Money::getAmount()` returns, what `parseToMinor()` produces, what `formatFromMinor()` takes, what the +casts are assigned — is an integer count of a currency's minor units. Scaling happens only where the +currency's `minorUnit` is in hand, and JPY (0) and BHD (3) are the cases that break naive `* 100` code. +The database boundary is the exception: under `store.format = decimal`, `MoneyCast::toDecimal()` writes +major units (`123456` USD as the column value `1234.56`) and `fromDecimal()` reads them back, so a +migration, a raw query or a backfill against a decimal column is working in major units. + +**An amount is two columns.** `LaraParaServiceProvider::moneyColumns()` (the `money()`/`nullableMoney()`/ +`smallMoney()`/`unsignedMoney()` Blueprint macros) writes the amount column, a never-nullable currency +column, and an index over both. `currencyColumnFor()` is the single source of the suffix, used by the +macros and by both casts, so the two sides cannot disagree. `store.format` (`int` vs `decimal`) changes +what the macros create *and* how `MoneyCast` converts; `MoneyCast` refuses an amount the configured +`decimal_scale` would round away rather than letting the database silently lose it. + +**ICU is the authority on formatting, and it is not ours.** `MoneyFormatter` (static, locale passed on +every call — no configured locale) delegates every symbol, separator and digit to `intl`/CLDR. ICU +version differs per PHP build, so exact formatted output is *not* stable across platforms: tests must +derive the volatile characters from `MoneyFormatter::getFormattingRules()` or normalize the space +characters, never assert a literal like `'1 234,56 kr'`. `tests/Pest.php` provides +`replaceNonBreakingSpaces()` for this; CI prints `INTL_ICU_VERSION` in every job. + +**Parsing is deliberately asymmetric to formatting.** `parseToMinor()` accepts what `format()` and +`formatFromMinor()` write, plus two forgiveness rules (a dot read as the locale's decimal separator; a +grouping separator out of position dropped), refuses a number written in some *other* locale, and turns +both off under `strict`. The round trip is format→parse and never parse→parse: the input is a localized +amount in *major* units and the output is *minor* units, so `'100'` in USD parses to `'10000'` and +feeding the result back in scales it by the minor unit a second time. The accept/refuse boundary is +specified case-by-case in the README's parsing section — treat those examples as the spec. + +**Currencies flow provider → repository → cache.** A `CurrenciesProvider` (ISO by default, optional +crypto, or a custom container-resolved class) supplies the list; `CurrencyRepository` applies +`available_currencies`/`excluded_currencies` and caches the result; `Currency::fromCode()` throws +`UnsupportedCurrency` for anything outside it. The cache hooks into `php artisan optimize` via the +`money:cache`/`money:clear` commands. + +## Constraints + +- Supports PHP 8.2–8.5 and Laravel 11.28 / 12 / 13 — this package **does** keep backwards + compatibility, overriding the global "current versions only" preference. Code must work across that + whole matrix (see `CurrencyRepository::FLEXIBLE_CREATED_KEY_PREFIX` for the shape this takes). +- No UI dependencies. No Filament, Livewire or Blade code belongs here; that lives in + `pelmered/filament-money-field`, which builds on this package. +- Types are declared with `php-static-analysis` attributes (`#[Returns]`, `#[Throws]`), not only + docblocks, and PHPStan runs at level 8 with full type coverage. +- Behaviour changes go in `UPGRADE.md` with the migration an affected application needs. + +[Money PHP]: https://www.moneyphp.org/en/stable/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1864af1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +The guide lives in `AGENTS.md`, so there is one copy to maintain for every agent that reads this +repository. Claude Code follows the import below; read `AGENTS.md` directly if you do not. + +@AGENTS.md diff --git a/README.md b/README.md index 934d270..d3b7874 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ MONEY_AVAILABLE_CURRENCIES="USD,EUR,SEK" | `intl_currency_symbol` | `MONEY_INTL_CURRENCY_SYMBOL` | `false` | Use ISO 4217 codes (`USD`, `EUR`, `SEK`) instead of symbols (`$`, `€`, `kr`). | | `parse.strict` | `MONEY_PARSE_STRICT` | `false` | Accept only what the locale itself writes when parsing. See [strict mode](#strict-mode). | | `currency_provider` | – | `ISOCurrenciesProvider` | Class that provides the currency list. See [custom currency lists](#custom-currency-lists). | -| `available_currencies` | `MONEY_AVAILABLE_CURRENCIES` | `[]` (all) | Allow list of ISO codes. Comma separated in `.env`, array in the config file. Codes are trimmed and upper-cased; a code the currency provider does not know throws `UnsupportedCurrency`. | +| `available_currencies` | `MONEY_AVAILABLE_CURRENCIES` | `[]` (all) | Allow list of codes the configured provider supplies — ISO by default, crypto codes with `load_crypto_currencies`, whatever a custom provider brings. Comma-separated in `.env`, array in the config file. Codes are trimmed and upper-cased; a code the currency provider does not know throws `InvalidConfiguration`. | | `excluded_currencies` | – | `[]` | Deny list. Only applied when `available_currencies` is empty. | | `currency_column_suffix` | `MONEY_CURRENCY_COLUMN_SUFFIX` | `_currency` | Suffix for the currency column belonging to an amount column. | | `currency_cache.type` | `MONEY_CURRENCY_CACHE` | `flexible` | `remember`, `flexible`, `forever` or `false` to disable. | @@ -172,6 +172,17 @@ UYW; crypto currencies carry eight. `MoneyCast` refuses an amount whose minor un represent rather than letting the database round it away, so raise the scale — in the config and in the column — or store amounts as integer minor units. +A column given a scale of its own has to tell the cast the same, since the cast is the side that refuses +the amount: `MoneyCast::class.':8'` beside `money('price', scale: 8)`. Told nothing, the cast refuses by +`store.decimal_scale`, which turns down a satoshi the column has room for. + +The scale has to leave the column a digit for the amount itself, and `smallMoney()` holds six digits +where the others hold twelve — so the eight decimals a crypto amount needs do not fit in a small column +at all. A macro that would write such a column throws +`Pelmered\LaraPara\Exceptions\InvalidColumnScale` rather than leaving it to the database: MySQL and +PostgreSQL refuse `decimal(6, 8)`, while SQLite accepts it and every amount written to it, so a test +suite on SQLite would have nothing to say about the migration production refuses. + To add a currency column to an existing amount column, add it as nullable, backfill the rows you already have, and only then make it required: @@ -210,6 +221,10 @@ protected function casts(): array } ``` +A column the migration gave a scale of its own takes that scale here too, so the cast refuses the amounts +the column cannot hold and no others: `'price' => MoneyCast::class.':8'` beside +`$table->money('price', scale: 8)`. See [migrations](#migrations). + Reading gives you value objects: ```php @@ -241,6 +256,11 @@ $product->price = ['amount' => 5000, 'currency' => 'EUR']; $product->price = 5000; // Currency from the model's currency column, or the default currency ``` +A plain amount is whole minor units, as an int or a numeric string. Anything else — `'1234.56'`, +`'twelve'` — throws `Pelmered\LaraPara\Exceptions\InvalidAmount` rather than storing the int it +casts to, which is the same rule the formatter holds. An amount a person typed is read by +[`parseToMoney()`](#parsetomoney), which knows the scale of the currency. + Currency codes are validated and upper-cased as they are written, by both casts. Writing a currency that `available_currencies` does not list throws `Pelmered\LaraPara\Exceptions\UnsupportedCurrency`, since reading such a row back would throw the same exception. @@ -401,7 +421,9 @@ public static function formatFromMinor( - `$value`: minor units as an int or a numeric string, or `null`/`''` (returns an empty string). An amount that is not whole minor units — `'199.99'`, `'1,234'`, `'not a number'` — throws `Pelmered\LaraPara\Exceptions\InvalidAmount` rather than being truncated to a wrong amount. - For a `Money` object, use [`format()`](#format). + So does an amount above 2^53 minor units, which is what ICU renders through a double: it would be + written as a neighbouring amount, so it is refused instead. `formatShortFromMinor()` still + abbreviates it, being an approximation by intent. For a `Money` object, use [`format()`](#format). - `$currency`: a LaraPara `Currency` or a `Money\Currency`, which says how many minor units make a unit. - `$decimals`: how many decimals to write, defaulting to the minor unit of the currency, so ¥ amounts carry no decimals and BHD amounts carry three. @@ -444,8 +466,9 @@ public static function formatNumber( [`parseToMinor()`](#parsetominor)'s job. `null` and `''` return an empty string, and anything else that is not a number throws `Pelmered\LaraPara\Exceptions\InvalidNumber` rather than rendering as nothing. -- `$decimals`: how many decimals to write. Defaults to as many as the value has, which is what the locale - would print. +- `$decimals`: how many decimals to write. Defaults to as many as the value has: every decimal of a + numeric string, since a string carries exactly the decimals it was written with, and up to fourteen + places of a float, which is where the noise of a binary representation starts. - `$significantDigits`: an alternative to `$decimals`, not a companion to it. Passing both throws. This formats the number it is given and scales nothing. A count of minor units means nothing without @@ -459,6 +482,7 @@ MoneyFormatter::formatNumber(1234.56, 'en_US'); // 1,234.56 MoneyFormatter::formatNumber('1234.56', 'en_US'); // 1,234.56 MoneyFormatter::formatNumber(1234, 'en_US'); // 1,234 MoneyFormatter::formatNumber(1234.5, 'en_US'); // 1,234.5 +MoneyFormatter::formatNumber(1234.5678, 'en_US'); // 1,234.5678 — however many it has MoneyFormatter::formatNumber(1234.56, 'de_DE'); // 1.234,56 MoneyFormatter::formatNumber(1234.56, 'sv_SE'); // 1 234,56 @@ -469,6 +493,12 @@ MoneyFormatter::formatNumber(1234.56, 'en_US', significantDigits: 2); // 1,200 MoneyFormatter::formatNumber(null, 'en_US'); // '' MoneyFormatter::formatNumber('not a number', 'en_US'); // InvalidNumber MoneyFormatter::formatNumber('1.234,56', 'en_US'); // InvalidNumber — that is a localized string + +// A double carries 53 bits of precision, and ICU renders through one, so a value needing more of them +// is refused rather than rendered as the number a double happens to hold. Which is a ceiling rather +// than a digit count: sixteen digits are exact below it, and refused above. +MoneyFormatter::formatNumber('1234567890123456', 'en_US'); // 1,234,567,890,123,456 — exact +MoneyFormatter::formatNumber('9007199254740993', 'en_US'); // InvalidNumber — past 2^53 ``` For an amount without a currency symbol — which is what a minor-unit value usually wants — use @@ -794,10 +824,10 @@ Both rules pass an empty value, so `required` and `nullable` stay in charge of w Validates that a string is an amount `parseToMinor()` can read, in the same locale and under the same rules. ```php -new MoneyString( - Currency|MoneyCurrency|string|null $currency = null, // defaults to config('larapara.default_currency') - ?string $locale = null, // defaults to app()->getLocale() - ?bool $strict = null, // defaults to config('larapara.parse.strict') +public function __construct( + mixed $currency = null, // a Currency, a Money\Currency or a code; defaults to config('larapara.default_currency') + ?string $locale = null, // defaults to app()->getLocale() + ?bool $strict = null, // defaults to config('larapara.parse.strict') ) ``` @@ -815,7 +845,8 @@ it was given — *"The price field must be a valid amount, such as 1 234,56."* Whether the currency itself is supported is `SupportedCurrency`'s business: an unsupported one here does not fail the amount, because the scale of a currency does not decide whether a string is a number. That way a bad currency and a bad amount each report their own problem, and passing `$request->input('price_currency')` -straight in cannot turn into an exception. +straight in cannot turn into an exception — the parameter is `mixed` for that reason, since a client is free +to send an array where a code was expected, and anything that is not a code is read as the default currency. ### `SupportedCurrency` @@ -924,28 +955,54 @@ A crypto currency list ships with the package, but is not loaded by default: MONEY_LOAD_CRYPTO_CURRENCIES=true ``` -Support for them is partial, since crypto currencies are not part of ISO 4217 and `intl` has no data for -them. `Currency::fromCode('BTC')` works and gives you the right minor unit (8), and `getFormattingRules()` -returns the currency code as the symbol and its eight fraction digits, but formatting an amount *with* a -currency symbol through `formatFromMinor()` or `format()` throws `Money\Exception\UnknownCurrencyException`. - -Everything else works, because the symbol is the only part ICU needs its own data for. Leave it out and -add your own: +Crypto currencies are not part of ISO 4217, so their minor unit comes from the package's own registry +rather than from ICU — `Currency::fromCode('BTC')` gives you the right one (8), and formatting and parsing +both scale by it: ```php // 100000000 minor units = 1 BTC -MoneyFormatter::formatFromMinor(100000000, Currency::fromCode('BTC'), 'en_US', showCurrencySymbol: false).' BTC'; -// 1.00000000 BTC +MoneyFormatter::formatFromMinor(100000000, Currency::fromCode('BTC'), 'en_US'); +// BTC 1.00000000 + +MoneyFormatter::formatFromMinor(100000000, Currency::fromCode('BTC'), 'en_US', showCurrencySymbol: false); +// 1.00000000 + +MoneyFormatter::parseToMinor('1.00000000', Currency::fromCode('BTC'), 'en_US'); // '100000000' ``` -`parseToMinor()` reads it back the same way, scaling by the minor unit of the currency you pass: +ICU has no *symbol* for a currency outside ISO 4217, so it writes the code where the symbol would go and +places it the way the locale places a symbol. That is the only part of the output crypto support is +missing, and it is what `getFormattingRules()->currencySymbol` reports too. + +The code comes out in full whatever its length. ICU carries a currency as a three-character code — +truncating a longer one and refusing a shorter one — so the 181 bundled codes that are not three +characters (`1000SATS`, `AUCTION`, `AI`) are handed to it as the symbol instead, which is the same thing +it writes for the rest. Parsing reads that notation back in strict mode as well as lenient, since it is +what this package writes and ICU has no reading of these codes to be strict about: ```php -MoneyFormatter::parseToMinor('1.00000000', Currency::fromCode('BTC'), 'en_US'); // '100000000' +$sats = Currency::fromCode('1000SATS'); +$formatted = MoneyFormatter::formatFromMinor(100000000, $sats, 'en_US'); // 1000SATS 1.00000000 + +MoneyFormatter::parseToMinor($formatted, $sats, 'en_US', strict: true); // '100000000' ``` -Pass a bare `Money\Currency` rather than a LaraPara `Currency` and there is no minor unit to read, so -both directions fall back to two decimals. +Strict mode holds such a code to the rules ICU holds the codes it does carry to, so the two behave +alike: the code where the locale puts the symbol and nowhere else, separated by the space the locale +writes — a no-break one here — or by nothing at all, with the sign where the locale puts it. A plain +space typed in its place, or the code written on the wrong side, is [lenient](#strict-mode) +forgiveness rather than something strict parsing accepts: + +```php +MoneyFormatter::parseToMinor('1000SATS 1.00000000', $sats, 'en_US'); // '100000000' +MoneyFormatter::parseToMinor('1.00000000 1000SATS', $sats, 'en_US'); // '100000000' +MoneyFormatter::parseToMinor('1000SATS 1.00000000', $sats, 'en_US', strict: true); // ParserException +``` + +The minor unit is read from the code, so it does not matter which object you hold: a bare +`Money\Currency` is looked up in the registry the same way a LaraPara `Currency` carries it. A code no +currency list has — one you built a `Money\Currency` for by hand — has nothing to read, and falls back to +two decimals in both directions. The crypto list carries no names of its own, so `Currency::name` is the code for those — `BTC - BTC` in a select array. @@ -998,6 +1055,13 @@ They are also wired into Laravel's optimization commands, so `php artisan optimi Set `MONEY_CURRENCY_CACHE=false` to disable caching, for example while developing a custom provider. +Within a single process the list is also held in memory, since reading a money attribute resolves the +scale of its amount through it — a page of a thousand rows would otherwise be a thousand round trips to +the cache store. It is held against the configuration it was built from, so changing +`available_currencies`, `excluded_currencies`, `load_crypto_currencies` or `currency_provider` at +runtime builds the list that configuration asks for. `CurrencyRepository::clearCache()` drops it, which +is what `money:clear` and `money:cache` both call. + ## Using LaraPara with Filament LaraPara has no Filament dependency. If you want localized money input fields, table columns and infolist diff --git a/UPGRADE.md b/UPGRADE.md index 2383f25..e5795f0 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -35,6 +35,10 @@ which is the default, was never affected. `1998` instead of `1999`. Nothing needs migrating — the column always held the right value — but any figure your application copied out of a read is a cent short. + The read moves the decimal point rather than multiplying, the way the write places it, so an amount a + double cannot hold exactly — above 2\*\*53 minor units, which needs a wider column than the macros + write — comes back as it went in rather than a unit or two off. + - **Assigning `null` to a nullable money column now stores `NULL`.** It used to store `0.000`, so "no price set" and "free" were indistinguishable. If you have rows that were meant to be null, they are zeros in the column now and only you can tell which is which. @@ -104,7 +108,10 @@ named for what it takes: - `formatFromMinor()` is the old `format()`: the same arguments, minus the `Money` it no longer accepts, and named for the unit it takes — neither `format` nor `formatAmount` said that 123456 means $1,234.56. - `formatNumber()` scales nothing: `formatNumber(1234.56, 'en_US')` is `1,234.56` and - `formatNumber(1234, 'en_US')` is `1,234.00`. Its `$minorDecimals` parameter is gone, and so is the + `formatNumber(1234, 'en_US')` is `1,234`. It keeps the decimals the value has rather than the three + ICU's own default stops at — `formatNumber(1234.5678, 'en_US')` is `1,234.5678` — as far as a double + carries them: a value needing more precision than its 53 bits throws `InvalidNumber` rather than being + rendered as the number a double happens to hold. Its `$minorDecimals` parameter is gone, and so is the `minorUnits` argument and the `number_format.minor_units` config key of the previous iteration. ```php @@ -195,21 +202,75 @@ forgiveness, so strict mode refuses it. ## An amount in a currency outside ISO 4217 formats and parses -`formatFromMinor(..., showCurrencySymbol: false)` used to throw `UnknownCurrencyException` for a crypto -currency, because it placed the decimal point from ISO 4217 data. The symbol is the only part that needs -ICU's data for the currency, so without it the minor unit of the currency is enough: +Formatting a crypto currency used to throw `UnknownCurrencyException`, because the decimal point was +placed from ISO 4217 data alone. The minor unit of the currency is placed alongside it now, in both +directions, so the round trip holds for those currencies too: ```php +MoneyFormatter::formatFromMinor(100000000, Currency::fromCode('BTC'), 'en_US'); +// BTC 1.00000000 + MoneyFormatter::formatFromMinor(100000000, Currency::fromCode('BTC'), 'en_US', showCurrencySymbol: false); // 1.00000000 MoneyFormatter::parseToMinor('1.00000000', Currency::fromCode('BTC'), 'en_US'); // '100000000' ``` -`parseToMinor()` scales by the same minor unit, so the round trip holds for those currencies too. This -also fixes the `MoneyString` validation rule, which raised `UnknownCurrencyException` out of the parser -for a crypto currency instead of reporting a validation failure. Formatting *with* a symbol still throws: -ICU has no symbol to give. +ICU has no *symbol* for such a currency, so it writes the code where the symbol would go — which is all +that crypto support is missing, rather than the exception it used to be. This also fixes the `MoneyString` +validation rule, which raised `UnknownCurrencyException` out of the parser for a crypto currency instead +of reporting a validation failure. + +The code is written in full however long it is. ICU carries a currency as a three-character code, so it +truncated the 170 bundled codes that are longer — `1000SATS` came out as `100`, an amount labelled as a +currency it is not — and threw a `TypeError` out of the money library for the 11 that are shorter. Such a +code is handed to ICU as the currency's symbol now, which is what it writes for a currency outside +ISO 4217 anyway, and `parseToMinor()` reads that notation back in strict mode as well. + +Strict mode holds such a code to the same rules as one ICU carries: the code where the locale puts the +symbol, separated by the space of the locale or by nothing, and the sign where the locale puts it — which +for `en_US` is before the code, so a negative amount in such a currency now reads back at all. A plain +space where the locale writes a no-break one, or the code on the wrong side of the number, is forgiven +leniently and refused strictly, exactly as it is for `USD`. + +## Formatting refuses a value a double cannot carry + +ICU renders through a double, which carries 53 bits of precision, and so did every format +call here — silently. `formatNumber('9007199254740993', 'en_US')` returned `9,007,199,254,740,992`, and +an amount of `900719925474099301` minor units in USD was written as `$9,007,199,254,740,994.00`: a dollar +away from an amount the casts store and read back exactly. + +Such a value now throws `Pelmered\LaraPara\Exceptions\InvalidNumber` from `formatNumber()` and +`Pelmered\LaraPara\Exceptions\InvalidAmount` from `formatFromMinor()` and `format()`, rather than being +rendered as the neighbouring number a double happens to hold. The value decides it, not the count of its +digits: a sixteen-digit amount below 2^53 is carried exactly, and so is one written with trailing zeros. +`formatShortFromMinor()` still abbreviates any amount, being an approximation by intent. + +`formatNumber()` also keeps every decimal a numeric string carries, rather than the fourteen places that +absorb the noise of a float: `formatNumber('0.000000000000001', 'en_US')` is that number, not `0`. + +## A currency's minor unit is read from its code + +Only a LaraPara `Currency` carried a minor unit, so a bare `Money\Currency` outside ISO 4217 was read at +two decimals: `parseToMoney('1.00000000', new Money\Currency('BTC'), 'en_US')` gave 100 minor units where +the same call with `'BTC'` gave 100000000 — the same amount a factor of a million apart, decided by which +object the caller happened to hold. The code is looked up in the registry now, so every way of naming a +currency reads and writes the same amount. A code no currency list has still falls back to two decimals, +since nothing knows any better. + +## `CurrencyFormattingRules` moved to the `Currencies` namespace + +`MoneyFormatter::getFormattingRules()` returns the same object, from a new namespace: + +```php +-use Pelmered\LaraPara\MoneyFormatter\CurrencyFormattingRules; ++use Pelmered\LaraPara\Currencies\CurrencyFormattingRules; +``` + +It describes a currency rather than the formatter that reads it, and it sits beside `Currency` now. Code +that only calls `getFormattingRules()` and reads its properties needs no change; an import, a type +declaration or a `new` of the old name fails with a class-not-found error, and there is no alias left +behind at the old name. ## The currency column is never nullable @@ -249,6 +310,21 @@ and a `scale` argument on each macro. An amount whose minor units the scale cann carry four minor units, and every crypto currency carries eight. Raise the scale in the config and in the column, pass `scale:` to the macro, or store amounts as integer minor units. +A column given its own scale has to tell the cast the same — `MoneyCast::class.':8'` beside +`money('price', scale: 8)` — since the cast is the side that refuses the amount. Told nothing, it refuses +by `store.decimal_scale` whatever the column holds. + +A negative scale is refused too, wherever it is named — the config key, the `scale:` argument or the cast +parameter. It used to reach the column as `decimal(12, -1)`, which MySQL rejects, and the cast moved the +point the wrong way rather than refusing: `$1,230.00` was stored as `1.23`, a thousandth of the amount, +while `$1,234.56` threw. A scale is a count of decimals, so it starts at zero. + +A scale that leaves the column no digits for the amount itself is refused with +`Pelmered\LaraPara\Exceptions\InvalidColumnScale` as the migration is built. `smallMoney()` holds six +digits, so `store.decimal_scale = 8` — the setting a crypto project is told to use — used to write +`decimal(6, 8)`: a column MySQL and PostgreSQL reject and SQLite silently accepts. Use `money()` for +those columns, or keep the small ones on integer storage. + `MoneyCast::set()` also writes the decimal by placing the point rather than by dividing, so the value reaching the column is a numeric string instead of a float and an amount larger than a double holds exactly is no longer deformed. @@ -280,7 +356,7 @@ If your application writes a currency it does not list — a crypto code without ## `available_currencies` is normalized Codes from the config are trimmed and upper-cased before use, so `MONEY_AVAILABLE_CURRENCIES="USD, EUR"` -works. A code the currency provider does not know now throws `UnsupportedCurrency` naming that code, +works. A code the currency provider does not know now throws `InvalidConfiguration` naming that code, instead of `ErrorException: Undefined array key` on the first currency read. ## Formatting refuses an amount that is not whole minor units @@ -289,6 +365,75 @@ Formatting cast its input to an int, so `'199.99'` rendered as `$1.99` and `'not Anything that is not whole minor units now throws `Pelmered\LaraPara\Exceptions\InvalidAmount`. If you were passing a major-unit amount, multiply it by the minor unit first, or use `formatNumber()`. +## Storing an amount refuses what is not whole minor units + +`MoneyCast` cast its input to an int, the way formatting used to, so `$post->price = '1234.56'` stored +1234 — $12.34 for the amount that was written — and `'twelve'` stored 0. An amount that is not whole +minor units now throws `Pelmered\LaraPara\Exceptions\InvalidAmount`, the same rule the formatter +holds, since both are given the same amounts: + +```php +$post->price = 123456; // unchanged +$post->price = '123456'; // unchanged: a numeric string of minor units +$post->price = '1234.56'; // InvalidAmount, where it used to store 1234 + +// A major-unit amount from a request is read by the parser, which knows the currency's scale: +$post->price = MoneyFormatter::parseToMoney($request->input('price'), $currency, app()->getLocale()); +``` + +## Storing an amount refuses one larger than an integer holds + +A `Money` carries its amount as a string and the money library counts in arbitrary precision, so an +amount can be larger than the integer a column stores — `$money->multiply()` on a large amount reaches +one easily. `MoneyCast` cast it to an int, which clamps: `99999999999999999999` was stored as +`9223372036854775807` in an integer column and as `92233720368547758.07` in a decimal one, both +silently, and neither the amount that was written. + +Such an amount now throws `Pelmered\LaraPara\Exceptions\InvalidAmount`, the same way reading a +column holding one does. `PHP_INT_MAX` minor units is still stored — about 92 quadrillion units of a +two-decimal currency — so this only reaches an application counting in a unit far smaller than the +amounts it holds. + +## `currency_cast_to` chooses the object, not whether the code is validated + +With `currency_cast_to = Money\Currency::class`, `CurrencyCast` built the object straight from the +column and validated nothing. A row holding a code `available_currencies` does not list therefore read +cleanly and failed later, out of `CurrencyCast::set()` — which Eloquent calls to merge a cast attribute +back into the model — so `toArray()`, `save()` and `getAttributes()` threw on a row whose attribute was +fine, and the exception came from the write path of a read. + +Both casts now resolve the code through the registry, as `Currency::class` always did: an unlisted code +throws `UnsupportedCurrency` on the first read of the row, naming the code. If you were relying on +reading codes the configuration does not list, add them to `available_currencies` (or a custom +`currency_provider`). A stored code is also normalized on the way out now, so a column holding `'sek'` +reads back as `'SEK'`. + +## A misconfigured `available_currencies` entry throws `InvalidConfiguration` + +A code in `available_currencies` that the currency provider does not have raised +`UnsupportedCurrency`, which is also what "this code is not one of the configured currencies" means — +and every caller asking that question catches it to answer no. So one typo made +`CurrencyRepository::isValidCode()` return `false` for every code, and the `SupportedCurrency` rule +report a correctly spelled `USD` as unsupported, with nothing naming the entry that was wrong. + +That case now throws `Pelmered\LaraPara\Exceptions\InvalidConfiguration`, which no such caller +catches, so it reaches you naming the entry. Catch it alongside `UnsupportedCurrency` if you were +handling a misconfigured registry yourself; both extend `RuntimeException`. + +## A currency provider decides the minor unit of the currencies it supplies + +`getMinorUnit()` consulted ISO 4217 before the configured registry, so a custom `currency_provider` +was honoured for a currency's existence and its name but not for its scale: a provider declaring USD +with four decimals — what per-unit pricing needs — still formatted, parsed and stored amounts two +decimals wide. The same ordering sat inside the currency data handed to the money library, so even an +explicit minor unit lost to ISO there. + +The currency's own minor unit now wins, with ISO 4217 behind it for a currency that names none — one +built by hand, or a code the registry does not list. Nothing changes for the bundled providers, which +carry the ISO minor units already. If you supply a provider that disagrees with ISO on a currency's +scale, note that this now reaches storage: `MoneyCast` reads its scale through the same resolver, so +check `store.decimal_scale` and your column against it. + ## `MoneyFormatter::parseToMinor()` rejects what it used to truncate - The whole string has to be accounted for. `'1.2.3'`, `'0x1A'`, `'NaN'` and `'12 dollars'` now throw @@ -327,7 +472,7 @@ Negative amounts are abbreviated now instead of always being formatted in full, ## `formatFromMinor(..., showCurrencySymbol: false)` uses the minor unit of the currency It divided by a hardcoded 100 whatever the currency, so -`formatFromMinor(1234, JPY, showCurrencySymbol: false)` gave `12.34` and now gives `1,234.00`. A currency +`formatFromMinor(1234, JPY, showCurrencySymbol: false)` gave `12.34` and now gives `1,234`. A currency outside ISO 4217 works here too — see the section on that above. ## `formatNumber()` with negative decimals @@ -444,6 +589,9 @@ MONEY_AVAILABLE_CURRENCIES=USD,EUR,GBP #### Example: ```php MoneyFormatter::formatAsDecimal(123456, Currency::fromCode('USD')); // Output: $1,234.56 -// should be changed to: -MoneyFormatter::formatNumber(123456, 'en_US'); // Output: 1,234.56 +// should be changed to formatFromMinor() without the symbol, which scales by the minor unit of the +// currency it is given — a fixed division by 100 is wrong for JPY (0 decimals) and BHD (3): +MoneyFormatter::formatFromMinor(123456, Currency::fromCode('USD'), 'en_US', showCurrencySymbol: false); // 1,234.56 +// formatNumber() is the replacement where the value is a plain number rather than minor units: +MoneyFormatter::formatNumber(1234.56, 'en_US'); // Output: 1,234.56 ``` diff --git a/config/larapara.php b/config/larapara.php index 8b67cd1..b443980 100644 --- a/config/larapara.php +++ b/config/larapara.php @@ -19,7 +19,7 @@ 'format' => 'int', // Allowed values: 'int' or 'decimal' // Decimals a decimal column keeps, which is what the column macros give it and what an - // amount is refused for carrying more of. Most currencies needs only 2 so that might + // amount is refused for carrying more of. Most currencies needs only 2 so that might // be enough if you want to optimize. Three covers all ISO currencies except // CLF and UYW that needs 4 // Crypto currencies needs up to 8 @@ -52,12 +52,12 @@ | Strict parsing |--------------------------------------------------------------------------- | - | MoneyFormatter::parseDecimal() forgives a separator that is out of place: + | MoneyFormatter::parseToMinor() forgives a separator that is out of place: | a dot is read as the decimal separator of the locale, and a grouping | separator out of position is dropped. Set this to true to accept only | what the locale itself writes, and throw for anything else. | - | Every parseDecimal() call takes a `strict` argument that overrides this, + | Every parseToMinor() call takes a `strict` argument that overrides this, | so a lenient form and a strict import can live in the same application. | */ @@ -138,6 +138,9 @@ | Supported values are: | - 'Pelmered\LaraPara\Currencies\Currency::class' (default and recommended) | - 'Money\Currency::class' + | + | This chooses the object a read hands back, not whether the code is validated: either way a + | code 'available_currencies' does not list throws UnsupportedCurrency when the row is read. */ 'currency_cast_to' => env('MONEY_CURRENCY_CAST', Currency::class), ]; diff --git a/src/Casts/CurrencyCast.php b/src/Casts/CurrencyCast.php index fe0ba5f..ec226b1 100644 --- a/src/Casts/CurrencyCast.php +++ b/src/Casts/CurrencyCast.php @@ -26,9 +26,16 @@ public function get(Model $model, string $key, mixed $value, array $attributes): return null; } + // Resolved through the registry whichever object the configuration asks for, since + // `currency_cast_to` chooses the type a read hands back and not whether the code is one this + // configuration knows. Built straight from the column, a code available_currencies does not + // list read cleanly and then threw out of set(), which Eloquent calls to merge a cast + // attribute back into the model — a write validator failing on what a read had handed out. + $currency = Currency::fromCode($value); + return match (config('larapara.currency_cast_to')) { - \Money\Currency::class => new \Money\Currency($value), - default => Currency::fromCode($value) + \Money\Currency::class => $currency->toMoneyCurrency(), + default => $currency, }; } @@ -42,7 +49,19 @@ public function get(Model $model, string $key, mixed $value, array $attributes): #[Param(attributes: 'array')] public function serialize(Model $model, string $key, mixed $value, array $attributes): ?string { - return $value === null ? null : Currency::toCode($value); + if ($value === null) { + return null; + } + + // The code of the object get() built, read rather than resolved a second time: get() hands + // back a \Money\Currency unvalidated where the configuration asks for one, so resolving it + // here would throw for a stored code this configuration no longer lists — a row that reads + // cleanly would fail on toArray(), and every serialized attribute would cost a lookup. + if ($value instanceof Currency || $value instanceof \Money\Currency) { + return (string) $value; + } + + return Currency::toCode($value); } /** diff --git a/src/Casts/MoneyCast.php b/src/Casts/MoneyCast.php index 2953925..bcb697f 100644 --- a/src/Casts/MoneyCast.php +++ b/src/Casts/MoneyCast.php @@ -22,10 +22,19 @@ */ class MoneyCast implements CastsAttributes { + /** + * @param int|null $scale Decimals the column keeps, where they are not the configured ones: + * `MoneyCast::class.':8'` beside `$table->money('price', scale: 8)`. + * The scale an amount is refused for carrying more of, so a column + * given its own scale has to say so here as well — otherwise the + * amounts this cast accepts are not the ones the column holds. + */ + public function __construct(private readonly ?int $scale = null) {} + /** * Cast the given value. */ - #[Param(value: '?int')] + #[Param(value: 'int|float|string|null')] #[Param(attributes: 'array')] public function get(Model $model, string $key, mixed $value, array $attributes): ?Money { @@ -36,10 +45,8 @@ public function get(Model $model, string $key, mixed $value, array $attributes): $currency = $this->getCurrencyFromModel($model, $key); $amount = config('larapara.store.format') === 'decimal' - // Rounded, because scaling the stored decimal back is not exact in binary floating - // point: 19.99 * 100 is 1998.9999999999998, which truncates to a cent too little. - ? (int) round((float) $value * 10 ** $this->getDecimals($currency->getCode())) - : (int) $value; + ? $this->fromDecimal((string) $value, $currency->getCode()) + : $this->fromInteger($value, $currency->getCode()); return new Money($amount, $currency); } @@ -52,28 +59,25 @@ public function get(Model $model, string $key, mixed $value, array $attributes): #[Returns('array')] public function set(Model $model, string $key, mixed $value, array $attributes): array { - $amount = $this->getAmount($model, $key, $value); - $currency = $this->getCurrency($model, $key, $value); - $currencyKey = $key.config('larapara.currency_column_suffix', '_currency'); + $amount = $this->getAmount($model, $key, $value); + $currency = $this->getCurrency($model, $key, $value); - // Before the format branch, since dividing null by the scale factor would store a zero. - if ($amount === null) { - return [ - $key => null, - $currencyKey => $currency, - ]; - } + $stored = match (true) { + // Before the format branch, since dividing null by the scale factor would store a zero. + $amount === null => null, + config('larapara.store.format') === 'decimal' => $this->toDecimal($amount, $currency), + default => $amount, + }; return [ - $key => config('larapara.store.format') === 'decimal' - ? $this->toDecimal($amount, $currency) - : $amount, - $currencyKey => $currency, + $key => $stored, + LaraParaServiceProvider::currencyColumnFor($key) => $currency, ]; } #[Param(value: 'array{0?: int, 1?: string, amount?: int, currency?: string}|Money|int|string|null')] #[Returns('int|null')] + #[Throws(InvalidAmount::class)] protected function getAmount(Model $model, string $key, Money|array|int|string|null $value): ?int { $amount = match (true) { @@ -82,7 +86,24 @@ protected function getAmount(Model $model, string $key, Money|array|int|string|n default => $value, }; - return $amount !== null ? (int) $amount : null; + if ($amount === null) { + return null; + } + + // By the formatter's rule rather than by a second one here, since both are given the same + // amounts: (int) read "1234.56" as 1234 and stored $12.34 for the amount whoever wrote it + // meant, which is the value the formatter refuses outright. + $amount = MoneyFormatter::toMinorUnits($amount); + + // A Money holds its amount as a string and the money library counts in arbitrary precision, + // so an amount can be larger than the integer a column stores. Cast, it clamps to the + // largest one there is: PHP_INT_MAX stored for an amount that is not it, silently, which is + // the same deformation fromDecimal() refuses on the way back out. + if (filter_var($amount, FILTER_VALIDATE_INT) === false) { + throw InvalidAmount::exceedsStoredRange((string) $amount); + } + + return (int) $amount; } #[Param(value: 'array{0?: int, 1?: string, amount?: int, currency?: string}|Money|int|string|null')] @@ -101,7 +122,7 @@ protected function getCurrency(Model $model, string $key, Money|array|int|string protected function getCurrencyFromModel(Model $model, string $name): MoneyCurrency { - $currency = $model->{$name.config('larapara.currency_column_suffix', '_currency')} ?? config('larapara.default_currency'); + $currency = $model->{LaraParaServiceProvider::currencyColumnFor($name)} ?? config('larapara.default_currency'); if ($currency instanceof MoneyCurrency) { return $currency; @@ -119,14 +140,14 @@ protected function getCurrencyFromModel(Model $model, string $name): MoneyCurren * The amount as the decimal string a decimal column stores. * * Built by placing the point rather than by dividing, so an amount larger than a double holds - * exactly is not deformed on its way to the column, and refused outright when the configured - * scale would round a digit away instead of letting the database drop it silently. + * exactly is not deformed on its way to the column, and refused outright when the scale in + * effect would round a digit away instead of letting the database drop it silently. */ #[Throws(InvalidAmount::class)] protected function toDecimal(int $amount, string $currency): string { $minorUnit = $this->getDecimals($currency); - $scale = LaraParaServiceProvider::decimalScale(); + $scale = LaraParaServiceProvider::decimalScale($this->scale); if ($minorUnit > $scale) { $unrepresentable = 10 ** ($minorUnit - $scale); @@ -141,16 +162,100 @@ protected function toDecimal(int $amount, string $currency): string $minorUnit = $scale; } + // The digits of the amount rather than abs(), which has no integer to return for PHP_INT_MIN + // and hands back a float: the point was placed in "9.2233720368548E+18" and the column was + // given "-9.2233720368548E+.18", which a strict database refuses and SQLite stores as text. $sign = $amount < 0 ? '-' : ''; - $digits = str_pad((string) abs($amount), $minorUnit + 1, '0', STR_PAD_LEFT); + $digits = str_pad(ltrim((string) $amount, '-'), $minorUnit + 1, '0', STR_PAD_LEFT); return $minorUnit === 0 ? $sign.$digits : $sign.substr($digits, 0, -$minorUnit).'.'.substr($digits, -$minorUnit); } + /** + * The minor units a decimal column holds: "1234.56" in USD is 123456. + * + * The inverse of toDecimal(), and exact the same way: the point is moved rather than the value + * multiplied, so an amount larger than a double holds exactly reads back as it was written. The + * column keeps `store.decimal_scale` decimals, which is at least the minor unit of most + * currencies, so the zeros it pads a shorter amount with are dropped again here. + */ + #[Returns('numeric-string')] + #[Throws(InvalidAmount::class)] + protected function fromDecimal(string $value, string $currency): string + { + $minorUnit = $this->getDecimals($currency); + + [$whole, $fraction] = array_pad(explode('.', trim($value), 2), 2, ''); + + $sign = str_starts_with($whole, '-') ? '-' : ''; + $whole = ltrim($whole, '+-'); + $fraction = rtrim($fraction, '0'); + + if (strlen($fraction) <= $minorUnit) { + $digits = ltrim($whole.str_pad($fraction, $minorUnit, '0'), '0'); + $amount = $digits === '' ? '0' : $sign.$digits; + + // Digits, and not merely numeric: exponent notation is numeric and pads to a digit + // string that is not — "1E+25" becomes "1E+2500" — which Money refuses a character at a + // time rather than reading. The reading below is the one written for that notation. + if (($digits === '' || ctype_digit($digits)) && is_numeric($amount)) { + // Refused here as the reading below refuses it, since both are read into an int by + // everything downstream: a column carrying more minor units than one holds would + // otherwise read back exactly and then fail to be written back. + return $this->withinIntegerRange($amount) + ? $amount + : throw InvalidAmount::exceedsIntegerRange(trim($value), $currency); + } + } + + // Not a plain decimal carrying the decimals of its currency: a float in exponent notation + // from a driver that hands one back, or a row written by hand with more decimals than the + // currency has. Read as the number it is, which rounds rather than reads the amount short. + $minorAmount = round((float) $value * 10 ** $minorUnit); + + // Cast beyond the integer range, the amount wraps to an unrelated — usually negative — one, + // so a column holding more than this cast can read says so instead of reading it wrongly. + if (! is_finite($minorAmount) || abs($minorAmount) >= (float) PHP_INT_MAX) { + throw InvalidAmount::exceedsIntegerRange(trim($value), $currency); + } + + return (string) (int) $minorAmount; + } + + /** + * The minor units an integer column holds, which is the number in it. + * + * Read with (int) alone, a column carrying more minor units than an integer holds — which a text + * or a decimal column can, whatever the macros write — clamped to PHP_INT_MAX: an amount that is + * not the one stored, silently, and one the cast would refuse to write. + */ + #[Param(value: 'int|float|string')] + #[Throws(InvalidAmount::class)] + protected function fromInteger(mixed $value, string $currency): int + { + $amount = trim((string) $value); + + if (ctype_digit(ltrim($amount, '-')) && ! $this->withinIntegerRange($amount)) { + throw InvalidAmount::exceedsIntegerRange($amount, $currency); + } + + return (int) $amount; + } + + /** + * Whether an amount is one an integer holds, which is what every path from a column reads into. + */ + protected function withinIntegerRange(string $amount): bool + { + return filter_var($amount, FILTER_VALIDATE_INT) !== false; + } + public function getDecimals(string $currencyCode): int { - return Currency::fromCode($currencyCode)->minorUnit ?? 2; + // The formatter's resolver, so the scale an amount is stored at is the scale it is + // formatted and parsed at — two lookups here would let the two drift apart. + return MoneyFormatter::getMinorUnit(Currency::fromCode($currencyCode)); } } diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php index e27f573..471cbd3 100644 --- a/src/Commands/CacheCommand.php +++ b/src/Commands/CacheCommand.php @@ -14,9 +14,14 @@ class CacheCommand extends Command public function handle(): void { + // Cleared first, since the read below writes through the cache only on a miss: an entry the + // `flexible` type still counts as fresh would be returned as it stands, and the command + // would report the currencies from before the configuration changed as the ones it cached. + CurrencyRepository::clearCache(); + $currencies = CurrencyRepository::getAvailableCurrencies(); - if (config('larapara.currency_cache.type')) { + if (CurrencyRepository::isCacheEnabled()) { $this->info($currencies->count().' Currencies cached.'); } else { $this->warn('The currency cache is disabled, so nothing was cached. Set larapara.currency_cache.type to enable it.'); diff --git a/src/Currencies/Currency.php b/src/Currencies/Currency.php index ef94589..2b9521c 100644 --- a/src/Currencies/Currency.php +++ b/src/Currencies/Currency.php @@ -21,7 +21,9 @@ public function __construct( public static function fromCode(string $currencyCode): self { - $currencyCode = strtoupper($currencyCode); + // The one place a string becomes a code, so the trimming and upper-casing every caller + // relies on live here rather than at each call site. + $currencyCode = strtoupper(trim($currencyCode)); return CurrencyRepository::getAvailableCurrencies()->get($currencyCode) ?? throw new UnsupportedCurrency($currencyCode); @@ -33,12 +35,8 @@ public static function fromCode(string $currencyCode): self #[Throws(UnsupportedCurrency::class)] public static function toCode(self|MoneyCurrency|\Stringable|string $currency): string { - $currencyCode = match (true) { - $currency instanceof self, $currency instanceof MoneyCurrency => $currency->getCode(), - default => (string) $currency, - }; - - return static::fromCode(trim($currencyCode))->getCode(); + // Every accepted representation stringifies to its code, Money\Currency included. + return static::fromCode((string) $currency)->getCode(); } public static function fromMoneyCurrency(MoneyCurrency $currency): self diff --git a/src/MoneyFormatter/CurrencyFormattingRules.php b/src/Currencies/CurrencyFormattingRules.php similarity index 85% rename from src/MoneyFormatter/CurrencyFormattingRules.php rename to src/Currencies/CurrencyFormattingRules.php index 63a8f97..abc79a6 100644 --- a/src/MoneyFormatter/CurrencyFormattingRules.php +++ b/src/Currencies/CurrencyFormattingRules.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pelmered\LaraPara\MoneyFormatter; +namespace Pelmered\LaraPara\Currencies; class CurrencyFormattingRules { diff --git a/src/Currencies/CurrencyRepository.php b/src/Currencies/CurrencyRepository.php index 5334d42..4ba7596 100644 --- a/src/Currencies/CurrencyRepository.php +++ b/src/Currencies/CurrencyRepository.php @@ -10,9 +10,11 @@ use Illuminate\Support\Facades\Config; use Pelmered\LaraPara\Currencies\Providers\CryptoCurrenciesProvider; use Pelmered\LaraPara\Currencies\Providers\ISOCurrenciesProvider; +use Pelmered\LaraPara\Exceptions\InvalidConfiguration; use Pelmered\LaraPara\Exceptions\UnsupportedCurrency; use PhpStaticAnalysis\Attributes\Returns; use PhpStaticAnalysis\Attributes\Throws; +use PhpStaticAnalysis\Attributes\Type; class CurrencyRepository { @@ -27,6 +29,34 @@ class CurrencyRepository */ public const FLEXIBLE_CREATED_KEY_PREFIX = 'illuminate:cache:flexible:created:'; + /** + * The cache types getAvailableCurrencies() writes through, which is what "the cache is + * enabled" means: any other type resolves the currencies uncached. + */ + private const CACHE_TYPES = ['remember', 'flexible', 'forever']; + + /** + * The currencies as this process last built them, and the configuration they were built from. + * + * The read path asks for the currencies once or twice per row — MoneyCast::get() resolves the + * scale of every amount through them — so without this a thousand rows with two money columns + * is a thousand round trips to the cache store, or a thousand rebuilds of the ISO list and the + * crypto one where the cache is off. + */ + private static ?CurrencyCollection $memo = null; + + #[Type('list|null')] + private static ?array $memoConfig = null; + + /** + * Whether getAvailableCurrencies() writes through the cache — the same question money:cache + * answers when it reports, so the two cannot drift. + */ + public static function isCacheEnabled(): bool + { + return in_array(Config::get('larapara.currency_cache.type'), self::CACHE_TYPES, true); + } + public static function isValid(Currency $currency): bool { // By code, since Collection::contains() compares whole objects: a currency built from just a @@ -47,6 +77,15 @@ public static function isValidCode(string $currencyCode): bool public static function getAvailableCurrencies(): CurrencyCollection { + // Compared against the configuration the list was built from rather than kept for the life + // of the process outright, since changing that configuration — which an application may do + // per tenant, and the tests do constantly — has to build the list it now asks for. + $memoConfig = static::memoConfig(); + + if (self::$memo instanceof CurrencyCollection && self::$memoConfig === $memoConfig) { + return self::$memo; + } + $config = Config::get('larapara.currency_cache', []); $ttl = data_get($config, 'ttl', 0); @@ -58,16 +97,37 @@ public static function getAvailableCurrencies(): CurrencyCollection // everything is a string, and each type takes a different shape of it. Passing the wrong // shape does not fail loudly — a string TTL is read one character at a time and an array one // becomes the int 1, so the cache quietly lives for seconds instead of months. - return match (data_get($config, 'type')) { + $currencies = match (data_get($config, 'type')) { 'remember' => Cache::remember(static::CACHE_KEY, static::secondsTtl($ttl), $callback), 'flexible' => Cache::flexible(static::CACHE_KEY, static::flexibleTtl($ttl), $callback), 'forever' => Cache::rememberForever(static::CACHE_KEY, $callback), default => $callback(), }; + + self::$memoConfig = $memoConfig; + + return self::$memo = $currencies; + } + + /** + * The configuration the currency list is built from, which is what makes a memoized one stale. + */ + #[Returns('list')] + protected static function memoConfig(): array + { + return [ + Config::get('larapara.currency_provider'), + Config::get('larapara.available_currencies'), + Config::get('larapara.excluded_currencies'), + Config::get('larapara.load_crypto_currencies'), + ]; } public static function clearCache(): void { + self::$memo = null; + self::$memoConfig = null; + Cache::forget(static::CACHE_KEY); // The flexible type keeps the age of the entry under a companion key of its own. @@ -95,8 +155,17 @@ protected static function flexibleTtl(mixed $ttl): array return [(int) $ttl, (int) $ttl]; } + /** + * A code the way both sides of a lookup have to spell it, since a provider is free to key its + * currencies as it likes and a code in configuration is written by hand. + */ + protected static function normalizeCode(string $code): string + { + return strtoupper(trim($code)); + } + #[Throws(BindingResolutionException::class)] - #[Throws(UnsupportedCurrency::class)] + #[Throws(InvalidConfiguration::class)] protected static function loadAvailableCurrencies(): CurrencyCollection { $currencyProvider = Config::get('larapara.currency_provider', ISOCurrenciesProvider::class); @@ -113,38 +182,42 @@ protected static function loadAvailableCurrencies(): CurrencyCollection ); } - if (! $availableCurrencies) { - $availableCurrencies = array_keys($currencies); + // Codes come from configuration and from the provider, so neither side can be trusted to be + // normalized. Both are keyed the same way before either is matched against the other, so + // neither the exclusion below nor the lookup further down can silently miss. + $currencies = array_change_key_case($currencies, CASE_UPPER); - // Filter out excluded currencies - $availableCurrencies = array_diff( - $availableCurrencies, - Config::get('larapara.excluded_currencies', []) + if (! $availableCurrencies) { + $excluded = array_map( + static fn (mixed $code): string => static::normalizeCode((string) $code), + (array) Config::get('larapara.excluded_currencies', []), ); + + $availableCurrencies = array_diff(array_keys($currencies), $excluded); } if (is_string($availableCurrencies)) { $availableCurrencies = explode(',', $availableCurrencies); } - // Codes come from configuration and from the provider, so neither side can be trusted to be - // normalized. Both are keyed the same way here so the lookup below cannot silently miss. - $currencies = array_change_key_case($currencies, CASE_UPPER); - return new CurrencyCollection( Arr::mapWithKeys($availableCurrencies, static function (string $currencyCode) use ($currencies): array { - $currencyCode = strtoupper(trim($currencyCode)); + $currencyCode = static::normalizeCode($currencyCode); if (! array_key_exists($currencyCode, $currencies)) { - throw new UnsupportedCurrency($currencyCode); + // The configuration is wrong, rather than the code being unsupported: every + // caller asking whether a code is supported catches UnsupportedCurrency to + // answer no, so raising that here made one typo in available_currencies + // report every currency — including the ones spelled correctly — as invalid. + throw InvalidConfiguration::unknownCurrency($currencyCode); } return [ $currencyCode => new Currency( $currencyCode, - $currencies[$currencyCode]['currency'] ?? '', - $currencies[$currencyCode]['minorUnit'], + $currencies[$currencyCode]['currency'] ?? '', + $currencies[$currencyCode]['minorUnit'] ?? null, ), ]; } diff --git a/src/Exceptions/InvalidAmount.php b/src/Exceptions/InvalidAmount.php index f5e8c2c..fc2a39a 100644 --- a/src/Exceptions/InvalidAmount.php +++ b/src/Exceptions/InvalidAmount.php @@ -17,6 +17,33 @@ public static function exceedsStoredScale(string $value, string $currency, int $ ); } + public static function exceedsIntegerRange(string $value, string $currency): self + { + return new self( + 'The stored amount "'.$value.'" in '.$currency.' is more minor units than an integer holds, so it ' + .'cannot be read back as the amount it is. Store amounts this large as a string in a column of their ' + .'own, or in a currency whose minor unit needs fewer digits.' + ); + } + + public static function exceedsStoredRange(string $value): self + { + return new self( + 'The amount "'.$value.'" is more minor units than an integer holds, and an amount is stored as an ' + .'integer count of them, so storing it would write a different amount — the largest one an integer ' + .'holds. A Money carries an amount of any size; a column does not.' + ); + } + + public static function exceedsFormattingPrecision(string $value, string $currency): self + { + return new self( + 'The amount "'.$value.'" in '.$currency.' is more minor units than a double carries exactly, ' + .'and formatting renders it through one, so it would be written as a neighbouring amount. ' + .'Abbreviate it with formatShortFromMinor(), which is an approximation by intent.' + ); + } + public static function notMinorUnits(string $value): self { return new self( diff --git a/src/Exceptions/InvalidColumnScale.php b/src/Exceptions/InvalidColumnScale.php new file mode 100644 index 0000000..9863480 --- /dev/null +++ b/src/Exceptions/InvalidColumnScale.php @@ -0,0 +1,30 @@ +nullable()`, `->default()` and the rest of the chain land on * the column they read as landing on. */ + #[Throws(InvalidColumnScale::class)] public static function moneyColumns( Blueprint $table, string $name, @@ -111,18 +114,21 @@ public static function moneyColumns( bool $nullable = false, ?int $scale = null, ): ColumnDefinition { - $currencyColumn = $name.config('larapara.currency_column_suffix', '_currency'); + $currencyColumn = static::currencyColumnFor($name); if (config('larapara.store.format') === 'decimal') { - $decimalScale = $scale ?? static::decimalScale(); - - if ($decimalScale > $decimalTotal) { - throw new \InvalidArgumentException( - 'The decimal scale '.$decimalScale.' does not fit a decimal('.$decimalTotal.') column.' - ); + $scale = static::decimalScale($scale); + + // A decimal column cannot keep more decimals than it holds digits at all: MySQL and + // PostgreSQL both refuse the column, while SQLite takes it and every amount written to + // it — so a project whose tests run on SQLite would hear this from its own database, at + // deploy time. The narrow macro is the one that runs out of digits: eight decimals, the + // scale a crypto amount needs, leave nothing of smallMoney()'s six. + if ($scale >= $decimalTotal) { + throw InvalidColumnScale::exceedsColumnDigits($name, $scale, $decimalTotal); } - $amount = $table->decimal($name, $decimalTotal, $decimalScale); + $amount = $table->decimal($name, $decimalTotal, $scale); } else { $amount = $table->{$integerType}($name); } @@ -149,10 +155,27 @@ public static function moneyColumns( } /** - * Decimals a decimal amount column keeps, which is what a stored amount is rounded to. + * The name of the currency column beside an amount column, which the macros write and the + * casts read, so the two sides cannot disagree about it. + */ + public static function currencyColumnFor(string $name): string + { + return $name.config('larapara.currency_column_suffix', '_currency'); + } + + /** + * Decimals a decimal amount column keeps, which is what a stored amount is written with. + * + * Takes the scale a caller names — a macro argument or a cast parameter — and answers with the + * configured one otherwise, so the one gate below sees every scale either side works from. A + * scale is a count of decimals: a negative one is not a wider column but a nonsense one, and it + * moved the point the wrong way rather than being refused. */ - public static function decimalScale(): int + #[Throws(InvalidColumnScale::class)] + public static function decimalScale(?int $scale = null): int { - return (int) config('larapara.store.decimal_scale', self::DEFAULT_DECIMAL_SCALE); + $scale ??= (int) config('larapara.store.decimal_scale', self::DEFAULT_DECIMAL_SCALE); + + return $scale >= 0 ? $scale : throw InvalidColumnScale::negative($scale); } } diff --git a/src/MoneyFormatter/MoneyFormatter.php b/src/MoneyFormatter/MoneyFormatter.php index 09b03bf..3a29081 100644 --- a/src/MoneyFormatter/MoneyFormatter.php +++ b/src/MoneyFormatter/MoneyFormatter.php @@ -4,6 +4,7 @@ namespace Pelmered\LaraPara\MoneyFormatter; +use Illuminate\Support\Str; use Locale; use Money\Currencies; use Money\Currencies\AggregateCurrencies; @@ -16,6 +17,7 @@ use Money\Parser\DecimalMoneyParser; use NumberFormatter; use Pelmered\LaraPara\Currencies\Currency; +use Pelmered\LaraPara\Currencies\CurrencyFormattingRules; use Pelmered\LaraPara\Exceptions\InvalidAmount; use Pelmered\LaraPara\Exceptions\InvalidNumber; use Pelmered\LaraPara\Exceptions\UnsupportedCurrency; @@ -31,13 +33,23 @@ class MoneyFormatter private const ABBREVIATIONS = ['', 'K', 'M', 'B', 'T', 'Q']; /** - * Decimal places a parsed amount is rendered with before it is rounded to its minor unit. + * Decimal places carried where no precision was asked for. * * Enough to carry any amount a double holds meaningfully, and few enough to absorb the noise of * the binary representation — 1.005 is stored as 1.00499999999999989, and rendering it to fewer - * places than this would round it down to 1.00 rather than to the 1.01 that was typed. + * places than this would round it down to 1.00 rather than to the 1.01 that was typed. Which is + * what a parsed amount is rendered to before it is rounded to its minor unit, and what a number + * whose caller named no precision keeps: ICU's own default of three would round both away. */ - private const PARSE_DECIMAL_PLACES = 14; + private const SIGNIFICANT_DECIMAL_PLACES = 14; + + /** + * Characters of a currency code ICU can carry. + * + * ISO 4217 codes are three, and so is every code ICU accepts: a longer one is truncated to its + * first three characters by every call that takes one. + */ + private const ICU_CURRENCY_CODE_LENGTH = 3; /** * Decimals an abbreviated mantissa carries when the caller names none. @@ -52,15 +64,26 @@ class MoneyFormatter */ private const CURRENCY_PLACEHOLDER = "\u{a4}"; + /** + * The space-like characters, which stand for one another wherever one of them may stand: + * sv_SE groups with a no-break space, fr_FR with a narrow one, a keyboard writes the plain one. + */ + private const SPACE_SEPARATORS = ["\u{0020}", "\u{00a0}", "\u{2009}", "\u{202f}"]; + /** * Characters that stand for one another as a grouping separator. * - * A locale's grouping separator is one member of a class: sv_SE groups with a no-break space, - * fr_FR with a narrow one, de_CH with a right single quotation mark. Keyboards produce the plain - * member — a space, an apostrophe — and ICU reads any of them as grouping where the grouping - * belongs, so the second reading has to know them all too. Otherwise which member CLDR happens to - * name decides whose input is forgiven, and that differs between ICU releases. + * A locale's grouping separator is one member of a class: a space of some kind, or an apostrophe + * of some kind (de_CH groups with a right single quotation mark). Keyboards produce the plain + * member, and ICU reads any of them as grouping where the grouping belongs, so the second reading + * has to know them all too. Otherwise which member CLDR happens to name decides whose input is + * forgiven, and that differs between ICU releases. */ + private const GROUPING_SEPARATOR_CLASSES = [ + self::SPACE_SEPARATORS, + ["\u{0027}", "\u{2019}", "\u{02bc}"], + ]; + /** * Formatters built so far, by everything they were built from. * @@ -75,11 +98,6 @@ class MoneyFormatter */ private static array $currencyFormatters = []; - private const GROUPING_SEPARATOR_CLASSES = [ - ["\u{0020}", "\u{00a0}", "\u{2009}", "\u{202f}"], - ["\u{0027}", "\u{2019}", "\u{02bc}"], - ]; - /** * Formats a Money object, which carries both the amount and the currency it is counted in. */ @@ -130,28 +148,44 @@ public static function formatFromMinor( // what makes ¥1,000 and BHD 1,234.567 come out right without being asked for. $decimals ??= $significantDigits === null ? $minorUnit : null; + $amount = self::toMinorUnits($value); + + // Both routes below hand the amount to ICU as a double — the money library's formatter casts + // to one too — so an amount a double cannot carry would be written as a neighbouring one. + if (! self::carriedExactly($amount)) { + throw InvalidAmount::exceedsFormattingPrecision((string) $amount, self::asMoneyCurrency($currency)->getCode()); + } + if (! $showCurrencySymbol) { // Nothing here needs ICU's data for the currency — only the minor unit, to place the // decimal point — so this reads a currency ICU has never heard of, crypto included. return static::formatNumber( - (float) self::toMinorUnits($value) / 10 ** $minorUnit, + self::toMajorUnits($amount, $minorUnit), $locale, $decimals, $significantDigits, ); } - $money = new Money( - self::toMinorUnits($value), - $currency instanceof Currency ? $currency->toMoneyCurrency() : $currency - ); + $moneyCurrency = self::asMoneyCurrency($currency); + + if (! self::icuCarriesCode($moneyCurrency->getCode())) { + return self::formatWithCodeAsSymbol( + self::toMajorUnits($amount, $minorUnit), + $moneyCurrency->getCode(), + $locale, + $outputStyle, + $decimals, + $significantDigits, + ); + } $moneyFormatter = new IntlMoneyFormatter( self::getNumberFormatter($locale, $outputStyle, $decimals, $significantDigits), - new ISOCurrencies, + self::currenciesFor($moneyCurrency, $minorUnit), ); - return $moneyFormatter->format($money); // Outputs something like "$1.234,56" + return $moneyFormatter->format(new Money($amount, $moneyCurrency)); // "$1.234,56" } /** @@ -183,7 +217,17 @@ public static function formatNumber( self::assertDigits($decimals, $significantDigits); - $numberFormatter = self::getNumberFormatter($locale, NumberFormatter::DECIMAL, $decimals, $significantDigits); + if (! self::carriedExactly($value)) { + throw InvalidNumber::exceedsDoublePrecision(); + } + + $numberFormatter = self::getNumberFormatter( + $locale, + NumberFormatter::DECIMAL, + $decimals, + $significantDigits, + maxDecimals: self::writtenDecimals($value), + ); return (string) $numberFormatter->format((float) $value); // Outputs something like "1.234,56" } @@ -225,7 +269,7 @@ public static function formatShortFromMinor( self::assertDigits($decimals, $significantDigits); - $major = (float) self::toMinorUnits($value) / 10 ** self::getMinorUnit($currency); + $major = self::toMajorUnits(self::toMinorUnits($value), self::getMinorUnit($currency)); // No need to abbreviate if the amount is less than 1000 if (abs($major) < 1000) { @@ -243,7 +287,7 @@ public static function formatShortFromMinor( // that the zero minor units of the yen would drop. $mantissaDecimals = $significantDigits === null ? $decimals ?? self::ABBREVIATED_DECIMALS : null; - [$mantissa, $suffix] = self::abbreviate($major, $mantissaDecimals ?? self::ABBREVIATED_DECIMALS); + [$mantissa, $suffix] = self::abbreviate($major, $mantissaDecimals, $significantDigits); if (! $showCurrencySymbol) { return static::formatNumber($mantissa, $locale, $mantissaDecimals, $significantDigits).$suffix; @@ -251,17 +295,30 @@ public static function formatShortFromMinor( // The suffix goes into the ICU pattern rather than into the formatted output, which leaves // the symbol, its placement, the digits of the locale and its directional marks to ICU. - $moneyCurrency = $currency instanceof Currency ? $currency->toMoneyCurrency() : $currency; + $code = self::asMoneyCurrency($currency)->getCode(); + + if (! self::icuCarriesCode($code)) { + return self::formatWithCodeAsSymbol( + $mantissa, + $code, + $locale, + NumberFormatter::CURRENCY, + $mantissaDecimals, + $significantDigits, + numberSuffix: $suffix, + ); + } return (string) self::getNumberFormatter($locale, NumberFormatter::CURRENCY, $mantissaDecimals, $significantDigits, numberSuffix: $suffix) - ->formatCurrency($mantissa, $moneyCurrency->getCode()); + ->formatCurrency($mantissa, $code); } /** * Reads a localized amount string into the minor units of a currency: "1,234.56" in USD is 123456. * - * A numeric string rather than an int, since that is what a Money holds and what the casts store, - * and it carries an amount past the range an int keeps losslessly. + * A numeric string rather than an int, since that is what a Money holds and what the casts store. + * The string is read through a double on the way, so an amount above 2**53 minor units is exact + * only to the precision a double has. */ #[Returns("numeric-string|''")] public static function parseToMinor( @@ -278,12 +335,13 @@ public static function parseToMinor( // Read before the currency is narrowed to a Money one, which carries no minor unit of its own. $minorUnit = self::getMinorUnit($currency); - $currency = $currency instanceof Currency ? $currency->toMoneyCurrency() : $currency; + $currency = self::asMoneyCurrency($currency); $moneyString = trim($moneyString); // The scale of the result comes from the currency, in the parser below: a number formatter - // reads every decimal the string carries whatever its fraction digits are set to. - $numberFormatter = self::getNumberFormatter($locale, NumberFormatter::DECIMAL, $minorUnit); + // reads every decimal the string carries whatever its fraction digits are set to, so the + // parse formatters are built without any. + $numberFormatter = self::getNumberFormatter($locale, NumberFormatter::DECIMAL); $parsed = self::parseLocalizedNumber($numberFormatter, $moneyString); if ($parsed === false) { @@ -291,7 +349,15 @@ public static function parseToMinor( // application that shows $1,234.56 in a field gets that string in the request, and a // parser that refuses its own output is a trap. Strict mode accepts this, since this is // what the locale writes — in the notation this configuration writes it in. - $parsed = self::parseCurrencyAmount($moneyString, $currency, $locale, $minorUnit, anyNotation: false); + $parsed = self::parseCurrencyAmount($moneyString, $currency, $locale, anyNotation: false); + } + + if ($parsed === false && ! self::icuCarriesCode($currency->getCode())) { + // ICU can neither write nor read a code it cannot carry, so the notation the formatter + // writes for one is read here rather than by ICU — and held to the same rules ICU holds + // its own codes to, since strict mode accepts this notation. Everything looser is left + // to the forgiveness below, which is what separates the two modes. + $parsed = self::parseCodeAsSymbol($numberFormatter, $moneyString, $locale, $currency); } if ($parsed === false && ! $strict) { @@ -299,14 +365,10 @@ public static function parseToMinor( // Separators are the most common way for user input to miss its locale, so give them a // second reading before giving up. See: https://github.com/pelmered/larapara/issues/20 - $rewritten = self::rewriteSeparators($moneyString, $formattingRules); - - if ($rewritten !== $moneyString) { - $parsed = self::parseLocalizedNumber($numberFormatter, $rewritten); - } + $parsed = self::parseWithSeparatorsRewritten($numberFormatter, $moneyString, $formattingRules); if ($parsed === false) { - $parsed = self::parseCurrencyAmount($moneyString, $currency, $locale, $minorUnit, anyNotation: true); + $parsed = self::parseCurrencyAmount($moneyString, $currency, $locale, anyNotation: true); } if ($parsed === false) { @@ -320,11 +382,7 @@ public static function parseToMinor( $parsed = self::parseLocalizedNumber($numberFormatter, $withoutCurrency); if ($parsed === false) { - $rewritten = self::rewriteSeparators($withoutCurrency, $formattingRules); - - if ($rewritten !== $withoutCurrency) { - $parsed = self::parseLocalizedNumber($numberFormatter, $rewritten); - } + $parsed = self::parseWithSeparatorsRewritten($numberFormatter, $withoutCurrency, $formattingRules); } } } @@ -334,35 +392,48 @@ public static function parseToMinor( throw new ParserException('The value must be a valid numeric value.'); } - try { - // Formatted rather than cast to a string: (string) on a float goes through the `precision` - // ini setting, which deforms anything above 14 significant digits. The rounding to the - // minor unit is left to the parser, which rounds half up, rather than done here, where the - // last representable digit of the double would decide it instead. - $decimalString = sprintf('%.'.self::PARSE_DECIMAL_PLACES.'F', $parsed); + // Formatted rather than cast to a string: (string) on a float goes through the `precision` + // ini setting, which deforms anything above 14 significant digits. The rounding to the + // minor unit is left to the parser, which rounds half up, rather than done here, where the + // last representable digit of the double would decide it instead. + $decimalString = sprintf('%.'.self::SIGNIFICANT_DECIMAL_PLACES.'F', $parsed); - return (new DecimalMoneyParser(self::parseCurrencies($currency, $minorUnit))) - ->parse($decimalString, $currency) - ->getAmount(); - } catch (ParserException $parserException) { - throw new ParserException('The value must be a valid numeric value.', 0, $parserException); - } + return (new DecimalMoneyParser(self::currenciesFor($currency, $minorUnit))) + ->parse($decimalString, $currency) + ->getAmount(); } /** - * The currency data the parser scales its result by. + * A second reading of a string its locale refused, with the separators rewritten the way they + * were likely meant. False where the rewrite changes nothing, since the string was already + * refused as written. + */ + private static function parseWithSeparatorsRewritten( + NumberFormatter $numberFormatter, + string $value, + CurrencyFormattingRules $formattingRules, + ): float|false { + $rewritten = self::rewriteSeparators($value, $formattingRules); + + return $rewritten === $value ? false : self::parseLocalizedNumber($numberFormatter, $rewritten); + } + + /** + * The currency data a formatter or a parser places the decimal point by. * - * ISO 4217 first, since it is authoritative for the currencies it covers, with the minor unit of - * the currency being parsed behind it — otherwise a currency ISO has never heard of could be - * formatted but not read back, and the exception for it is not one a caller can catch as a - * parse failure. + * The minor unit in hand first, since getMinorUnit() has already decided it and this is the same + * scale the amount was measured in: ISO ahead of it overrode a provider's scale for its own + * currencies, so an amount counted in four decimals was written and read in two. ISO stays + * behind it for every other currency, since a currency neither knows throws an exception that is + * neither a parse failure a caller can catch nor anything ICU could not have rendered: ICU + * writes the code as the symbol for a currency it has no symbol for. */ #[Param(minorUnit: 'int<0, max>')] - private static function parseCurrencies(MoneyCurrency $currency, int $minorUnit): Currencies + private static function currenciesFor(MoneyCurrency $currency, int $minorUnit): Currencies { return new AggregateCurrencies([ - new ISOCurrencies, new CurrencyList([$currency->getCode() => $minorUnit]), + new ISOCurrencies, ]); } @@ -390,10 +461,7 @@ public static function parseToMoney( return null; } - return new Money( - $amount, - $currency instanceof Currency ? $currency->toMoneyCurrency() : $currency, - ); + return new Money($amount, self::asMoneyCurrency($currency)); } public static function getFormattingRules(string $locale, Currency|MoneyCurrency $currency): CurrencyFormattingRules @@ -428,8 +496,10 @@ public static function getFormattingRules(string $locale, Currency|MoneyCurrency */ private static function currencyFormatter(string $locale, string $currencyCode): NumberFormatter { + $locale = self::resolveLocale($locale); + return self::$currencyFormatters[$locale.'|'.$currencyCode] ??= new NumberFormatter( - self::currencyKeywordLocale($locale, $currencyCode), + $locale.'@currency='.$currencyCode, NumberFormatter::CURRENCY, ); } @@ -465,27 +535,300 @@ private static function assertDigits(?int $decimals, ?int $significantDigits): v /** * The minor unit of the currency, which is how many decimals its amounts carry. + * + * The currency's own first, so the configured provider decides the scale of the currencies it + * supplies — a crypto currency, which ISO 4217 has never heard of, and equally an ISO one the + * provider deliberately gives a different scale. ISO is where a minor unit comes from when + * nothing else names it, not a rule the configuration cannot reach. */ #[Returns('int<0, max>')] - private static function getMinorUnit(Currency|MoneyCurrency $currency): int + public static function getMinorUnit(Currency|MoneyCurrency $currency): int { + $moneyCurrency = self::asMoneyCurrency($currency); + + // Read from the registry for a bare Money\Currency, which carries a code and nothing else: + // otherwise the same code means eight decimals as a Currency and two as a Money\Currency, + // and the amount a call renders would depend on which object the caller happened to hold. + $minorUnit = $currency instanceof Currency + ? $currency->minorUnit + : self::registeredMinorUnit($moneyCurrency); + + if ($minorUnit !== null) { + return max($minorUnit, 0); + } + $isoCurrencies = new ISOCurrencies; - $moneyCurrency = $currency instanceof Currency ? $currency->toMoneyCurrency() : $currency; - if ($isoCurrencies->contains($moneyCurrency)) { - return max($isoCurrencies->subunitFor($moneyCurrency), 0); + // A currency built by hand carries no minor unit, and the registry has none for a code it + // does not list, so ISO answers for both — and two decimals for a code even it has never + // heard of, which is what most currencies carry. + return $isoCurrencies->contains($moneyCurrency) + ? max($isoCurrencies->subunitFor($moneyCurrency), 0) + : 2; + } + + /** + * Formats a major-unit amount with a currency code ICU cannot carry beside it. + * + * ICU carries a currency as a three-character code, so a longer one — 1000SATS and AUCTION in + * the bundled crypto list — comes out truncated to a currency the amount is not counted in. + * Such a code goes in as the currency's symbol instead, which is what ICU writes for a currency + * outside ISO 4217 anyway, and leaves the placement, spacing, digits and directional marks of + * the locale ICU's to decide. + */ + private static function formatWithCodeAsSymbol( + float $major, + string $code, + string $locale, + int $outputStyle, + ?int $decimals, + ?int $significantDigits, + string $numberSuffix = '', + ): string { + return (string) self::getNumberFormatter( + $locale, + $outputStyle, + $decimals, + $significantDigits, + $numberSuffix, + currencyCode: $code, + )->format($major); + } + + /** + * Whether a double carries the value as it was written, digit for digit. + * + * ICU takes a double and a double carries fifteen significant decimal digits, so a value written + * with more of them is rendered as the neighbouring one a double holds instead. Compared against + * the double's own decimal expansion rather than by counting digits, since a sixteen-digit value + * below 2**53 is carried exactly and a nineteen-digit one ending in zeros is too. + * + * A float is a double already, and so is a value in exponent notation: neither was written in + * decimal digits, so neither has any to lose here. + */ + private static function carriedExactly(int|float|string $value): bool + { + $written = self::plainDecimal($value); + + if ($written === null) { + return true; + } + + [, $fraction] = array_pad(explode('.', $written, 2), 2, ''); + + return self::sameDigits($written, sprintf('%.'.strlen($fraction).'F', (float) $written)); + } + + /** + * The decimals a value was written with, or null where it was not written as a plain decimal. + * + * What "the decimals it has" means for a numeric string, which carries exactly the decimals it + * was written with — a float carries as many as its binary representation happens to expand to. + */ + private static function writtenDecimals(int|float|string $value): ?int + { + $written = self::plainDecimal($value); + + if ($written === null) { + return null; } - // Crypto currencies are not part of ISO 4217, so their minor unit comes from our own data. - return $currency instanceof Currency ? max($currency->minorUnit ?? 2, 0) : 2; + [, $fraction] = array_pad(explode('.', $written, 2), 2, ''); + + return strlen($fraction); + } + + /** + * The value as the plain decimal it was written as, or null where it was not written as one. + */ + private static function plainDecimal(int|float|string $value): ?string + { + if (is_float($value)) { + return null; + } + + $written = ltrim(trim((string) $value), '+'); + + return preg_match('/^-?\d*(?:\.\d*)?$/', $written) === 1 ? $written : null; + } + + /** + * Whether two decimals are written with the same digits, leading zeros aside. + */ + private static function sameDigits(string $left, string $right): bool + { + $digits = static function (string $number): string { + [$whole, $fraction] = array_pad(explode('.', $number, 2), 2, ''); + $sign = str_starts_with($whole, '-') ? '-' : ''; + $whole = ltrim(ltrim($whole, '-'), '0'); + + return $sign.($whole === '' ? '0' : $whole).($fraction === '' ? '' : '.'.$fraction); + }; + + return $digits($left) === $digits($right); + } + + /** + * Reads an amount written with a currency code ICU cannot carry, in the notation it is written in. + * + * Held to what ICU holds a code it does carry to: the code where the symbol goes and nowhere + * else, separated from the number by the space of the locale or by nothing at all — which is + * what ICU reads back — and the sign where the locale puts it, which for en_US is before the + * code rather than at either end of the string. + */ + private static function parseCodeAsSymbol( + NumberFormatter $numberFormatter, + string $value, + string $locale, + MoneyCurrency $currency, + ): float|false { + foreach ([1, -1] as $sign) { + foreach (self::codeAffixes($locale, $currency->getCode(), $sign) as [$prefix, $suffix]) { + $number = self::betweenAffixes($value, $prefix, $suffix); + + if ($number === null) { + continue; + } + + $parsed = self::parseLocalizedNumber($numberFormatter, $number); + + if ($parsed !== false) { + return $sign * $parsed; + } + } + } + + return false; + } + + /** + * What ICU writes on either side of the number, for a code it carries as the symbol. + * + * Read from ICU rather than assembled here: two amounts formatted the same way differ only in + * their digits, so what the two share is the affix, carrying the code, the sign, the space ICU + * inserts and any directional marks of the locale. Read that way because the code itself can + * contain digits — 1000SATS does — so the number cannot be found by looking for one. + * + * Both with that space and without it, since ICU reads its own codes either way. + * + * @return list + */ + private static function codeAffixes(string $locale, string $code, int $sign): array + { + $formatter = self::getNumberFormatter($locale, NumberFormatter::CURRENCY, 0, null, currencyCode: $code); + + $one = mb_str_split((string) $formatter->format($sign)); + $two = mb_str_split((string) $formatter->format($sign * 2)); + $len = min(count($one), count($two)); + + $prefix = ''; + + for ($i = 0; $i < $len && $one[$i] === $two[$i]; $i++) { + $prefix .= $one[$i]; + } + + $suffix = ''; + + for ($i = 1; $i <= $len - mb_strlen($prefix) && $one[count($one) - $i] === $two[count($two) - $i]; $i++) { + $suffix = $one[count($one) - $i].$suffix; + } + + $affixes = [[$prefix, $suffix]]; + $spaceless = [self::withoutSeparatingSpace($prefix, trailing: true), self::withoutSeparatingSpace($suffix, trailing: false)]; + + if ($spaceless !== $affixes[0]) { + $affixes[] = $spaceless; + } + + return $affixes; + } + + /** + * The affix without the space ICU inserts between an alphanumeric code and a digit. + */ + private static function withoutSeparatingSpace(string $affix, bool $trailing): string + { + $characters = mb_str_split($affix); + + if ($characters === []) { + return $affix; + } + + $index = $trailing ? count($characters) - 1 : 0; + + if (! in_array($characters[$index], self::SPACE_SEPARATORS, true)) { + return $affix; + } + + unset($characters[$index]); + + return implode('', $characters); + } + + /** + * What stands between the two affixes, or null where the string does not carry them both. + */ + private static function betweenAffixes(string $value, string $prefix, string $suffix): ?string + { + if (! str_starts_with($value, $prefix) || ! str_ends_with($value, $suffix)) { + return null; + } + + $number = substr($value, strlen($prefix), strlen($value) - strlen($prefix) - strlen($suffix)); + + return $number === '' ? null : $number; + } + + /** + * Whether ICU can carry a currency code, which it can only where the code is three characters. + */ + private static function icuCarriesCode(string $code): bool + { + return mb_strlen($code) === self::ICU_CURRENCY_CODE_LENGTH; + } + + /** + * An amount in minor units as the number of major ones it counts: 123456 in USD is 1234.56. + * + * A double, which is what ICU takes, so an amount above 2**53 minor units is carried only to + * the precision a double has. + */ + private static function toMajorUnits(int|string $amount, int $minorUnit): float + { + return (float) $amount / 10 ** $minorUnit; + } + + /** + * The currency as the Money one, which is what moneyphp and ICU take. + */ + private static function asMoneyCurrency(Currency|MoneyCurrency $currency): MoneyCurrency + { + return $currency instanceof Currency ? $currency->toMoneyCurrency() : $currency; + } + + /** + * The minor unit the registry holds for a code, or null where this configuration has no such + * currency and there is nothing left to read it from. + */ + private static function registeredMinorUnit(MoneyCurrency $currency): ?int + { + try { + return Currency::fromCode($currency->getCode())->minorUnit; + } catch (UnsupportedCurrency) { + return null; + } } /** * Amounts are whole minor units. Anything else is a mistake we should not silently truncate. + * + * Public because the casts hold the same rule from the other side: the amount a column is given + * is the amount a formatter is given, and two readings of what counts as one would let the + * cast store "1234.56" as $12.34 while the formatter refuses to render it at all. */ #[Returns('int|numeric-string')] #[Throws(InvalidAmount::class)] - private static function toMinorUnits(int|string $value): int|string + public static function toMinorUnits(int|string $value): int|string { if (is_int($value)) { return $value; @@ -505,14 +848,17 @@ private static function toMinorUnits(int|string $value): int|string * Splits a major amount into a mantissa below one thousand and its magnitude suffix. */ #[Returns('array{0: float, 1: string}')] - private static function abbreviate(float $major, int $decimals): array + private static function abbreviate(float $major, ?int $decimals, ?int $significantDigits): array { $lastMagnitude = count(self::ABBREVIATIONS) - 1; $magnitude = min((int) (log10(abs($major)) / 3), $lastMagnitude); $mantissa = $major / 10 ** ($magnitude * 3); - // Rounding to the requested decimals can carry the mantissa into the next magnitude. - if ($magnitude < $lastMagnitude && abs(round($mantissa, max($decimals, 0))) >= 1000) { + // Rounding to the precision the output carries can take the mantissa into the next + // magnitude, and 1,000K is not an abbreviation of anything. + $rounded = self::roundToPrecision($mantissa, $decimals, $significantDigits); + + if ($magnitude < $lastMagnitude && abs($rounded) >= 1000) { $magnitude++; $mantissa /= 1000; } @@ -520,6 +866,22 @@ private static function abbreviate(float $major, int $decimals): array return [$mantissa, self::ABBREVIATIONS[$magnitude]]; } + /** + * A number rounded the way the output will write it, in decimals or in significant digits. + */ + private static function roundToPrecision(float $value, ?int $decimals, ?int $significantDigits): float + { + if ($significantDigits === null) { + return round($value, $decimals ?? self::ABBREVIATED_DECIMALS); + } + + // Significant digits count from the first one, so how many decimals they leave depends on + // how many integer digits there are: 999.6 to one significant digit is 1000, not 999.6. + $integerDigits = (int) floor(log10(abs($value))) + 1; + + return round($value, max($significantDigits - $integerDigits, 0)); + } + /** * Reads the separators of a string that is not a number in its locale the way it was likely meant. * @@ -584,24 +946,24 @@ private static function groupingSeparators(string $groupingSeparator): array * ICU reads any currency's symbol, so the code it read has to be the one asked for: €10 read as * USD is a refusal rather than ten dollars. */ - #[Param(minorUnit: 'int<0, max>')] private static function parseCurrencyAmount( string $value, MoneyCurrency $currency, string $locale, - int $minorUnit, bool $anyNotation, ): float|false { - $formatters = [self::getNumberFormatter($locale, NumberFormatter::CURRENCY, $minorUnit)]; + $formatters = [self::getNumberFormatter($locale, NumberFormatter::CURRENCY)]; $candidates = [$value]; if ($anyNotation) { $formatters[] = self::currencyFormatter($locale, $currency->getCode()); - $candidates[] = strtr($value, array_fill_keys(self::GROUPING_SEPARATOR_CLASSES[0], "\u{00a0}")); + $candidates[] = strtr($value, array_fill_keys(self::SPACE_SEPARATORS, "\u{00a0}")); } + $candidates = array_unique($candidates); + foreach ($formatters as $formatter) { - foreach (array_unique($candidates) as $candidate) { + foreach ($candidates as $candidate) { $code = null; $position = 0; $parsed = $formatter->parseCurrency($candidate, $code, $position); @@ -632,11 +994,11 @@ private static function withoutCurrency(string $value, string $locale, MoneyCurr continue; } - foreach ([self::removePrefix($value, $notation), self::removeSuffix($value, $notation)] as $stripped) { + foreach ([Str::chopStart($value, $notation), Str::chopEnd($value, $notation)] as $stripped) { if ($stripped !== $value) { // Whatever stood between the two is the space of the locale, or the space of a // keyboard, and the separator rules below read either. - return trim(str_replace(self::GROUPING_SEPARATOR_CLASSES[0], ' ', $stripped)); + return trim(str_replace(self::SPACE_SEPARATORS, ' ', $stripped)); } } } @@ -663,16 +1025,6 @@ private static function currencyNotations(string $locale, MoneyCurrency $currenc return [$code, $symbol]; } - private static function removePrefix(string $value, string $prefix): string - { - return str_starts_with($value, $prefix) ? substr($value, strlen($prefix)) : $value; - } - - private static function removeSuffix(string $value, string $suffix): string - { - return str_ends_with($value, $suffix) ? substr($value, 0, -strlen($suffix)) : $value; - } - /** * Parses a localized number, or returns false unless the whole string is one. */ @@ -696,7 +1048,10 @@ private static function getNumberFormatter( ?int $decimals = null, ?int $significantDigits = null, string $numberSuffix = '', + ?string $currencyCode = null, + ?int $maxDecimals = null, ): NumberFormatter { + $locale = self::resolveLocale($locale); $intlCurrencySymbol = (bool) config('larapara.intl_currency_symbol'); // Building one costs more than everything else a format call does put together, and the same @@ -710,6 +1065,8 @@ private static function getNumberFormatter( $significantDigits ?? 'default', $numberSuffix, (int) $intlCurrencySymbol, + $currencyCode ?? '', + $maxDecimals ?? 'default', ]); return self::$numberFormatters[$key] ??= self::buildNumberFormatter( @@ -719,6 +1076,8 @@ private static function getNumberFormatter( $significantDigits, $numberSuffix, $intlCurrencySymbol, + $currencyCode, + $maxDecimals, ); } @@ -729,6 +1088,8 @@ private static function buildNumberFormatter( ?int $significantDigits, string $numberSuffix, bool $intlCurrencySymbol, + ?string $currencyCode, + ?int $maxDecimals, ): NumberFormatter { $numberFormatter = new NumberFormatter($locale, $style); @@ -744,12 +1105,27 @@ private static function buildNumberFormatter( $numberFormatter->setPattern(self::numberSuffixPattern($numberFormatter->getPattern(), $numberSuffix)); } - // Neither given leaves the digits of the locale in place, which for a plain number is as many - // decimals as the value has. + // A code ICU cannot carry stands in for the symbol, in both the places a pattern asks for + // one, so which of them the pattern carries does not decide whether the code comes out. + if ($currencyCode !== null) { + $numberFormatter->setSymbol(NumberFormatter::CURRENCY_SYMBOL, $currencyCode); + $numberFormatter->setSymbol(NumberFormatter::INTL_CURRENCY_SYMBOL, $currencyCode); + } + if ($significantDigits !== null) { $numberFormatter->setAttribute(NumberFormatter::MAX_SIGNIFICANT_DIGITS, $significantDigits); } elseif ($decimals !== null) { $numberFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $decimals); + } else { + // Neither given keeps the decimals the value has, rather than the three ICU stops at of + // its own accord: nothing asked for 1234.5678 to be rendered as 1,234.568, or for + // 0.00001234 to be rendered as 0. A value written as a decimal says how many it has; a + // float says only what its binary representation expands to, and the places below carry + // that without writing out its noise. + $numberFormatter->setAttribute( + NumberFormatter::MAX_FRACTION_DIGITS, + $maxDecimals ?? self::SIGNIFICANT_DECIMAL_PLACES, + ); } return $numberFormatter; @@ -769,14 +1145,17 @@ private static function intlCurrencyPattern(string $pattern): string } /** - * The locale ICU needs to report the rules of a currency the locale itself does not use. + * The locale a formatter is built for, with the empty one resolved to intl's default. * - * An empty locale stands for the default one to every other intl call, but appending a keyword to - * it makes an identifier ICU refuses outright, so it is resolved before the keyword goes on. + * An empty locale stands for the default one to every other intl call, and two things here need + * it spelled out: appending a `@currency=` keyword to an empty locale makes an identifier ICU + * refuses outright, and a formatter is kept under the locale it was built for — so leaving the + * resolution to intl would key one under the empty string and outlive the default it was built + * from, which a long-running process can change between calls. */ - private static function currencyKeywordLocale(string $locale, string $currencyCode): string + private static function resolveLocale(string $locale): string { - return ($locale === '' ? Locale::getDefault() : $locale).'@currency='.$currencyCode; + return $locale === '' ? Locale::getDefault() : $locale; } /** diff --git a/src/Rules/MoneyString.php b/src/Rules/MoneyString.php index a2d83f4..13929d5 100644 --- a/src/Rules/MoneyString.php +++ b/src/Rules/MoneyString.php @@ -22,8 +22,14 @@ */ class MoneyString implements ValidationRule { + /** + * @param mixed $currency A currency object or a code. Deliberately untyped, because the + * idiomatic call passes `$request->input('price_currency')` straight + * in, and a client is free to send an array there: anything that is + * not a code is the default currency rather than a TypeError. + */ public function __construct( - protected Currency|MoneyCurrency|string|null $currency = null, + protected mixed $currency = null, protected ?string $locale = null, protected ?bool $strict = null, ) {} @@ -57,11 +63,16 @@ protected function currency(): Currency|MoneyCurrency return $this->currency; } - try { - return Currency::fromCode(trim((string) ($this->currency ?? config('larapara.default_currency')))); - } catch (UnsupportedCurrency) { - return MoneyFormatter::getDefaultCurrency(); + if (is_string($this->currency) || $this->currency instanceof \Stringable) { + try { + return Currency::fromCode((string) $this->currency); + } catch (UnsupportedCurrency) { + // Not a supported code, so the default reads the amount: refusing the code is + // SupportedCurrency's business. + } } + + return MoneyFormatter::getDefaultCurrency(); } protected function fail(Closure $fail, Currency|MoneyCurrency $currency, string $locale): void diff --git a/src/Rules/SupportedCurrency.php b/src/Rules/SupportedCurrency.php index 3c4c413..70dc24b 100644 --- a/src/Rules/SupportedCurrency.php +++ b/src/Rules/SupportedCurrency.php @@ -6,9 +6,7 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; -use Money\Currency as MoneyCurrency; use Pelmered\LaraPara\Currencies\Currency; -use Pelmered\LaraPara\Currencies\CurrencyRepository; use Pelmered\LaraPara\Exceptions\UnsupportedCurrency; use PhpStaticAnalysis\Attributes\Param; use PhpStaticAnalysis\Attributes\Throws; @@ -39,21 +37,17 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - $currencyCode = match (true) { - $value instanceof Currency, $value instanceof MoneyCurrency => $value->getCode(), - is_string($value), $value instanceof \Stringable => (string) $value, - default => null, - }; - - if ($currencyCode === null) { - $fail('larapara::validation.supported_currency')->translate(); - - return; + try { + // The same normalization the casts apply on write, so anything this rule passes can be + // stored. Currency objects stringify to their code, so one type guard reads them all. + $currencyCode = is_string($value) || $value instanceof \Stringable + ? Currency::toCode($value) + : null; + } catch (UnsupportedCurrency) { + $currencyCode = null; } - $currencyCode = strtoupper(trim($currencyCode)); - - $isSupported = CurrencyRepository::isValidCode($currencyCode) + $isSupported = $currencyCode !== null && ($this->currencyCodes === null || in_array($currencyCode, $this->currencyCodes, true)); if (! $isSupported) { diff --git a/tests/Unit/BlueprintMacrosTest.php b/tests/Unit/BlueprintMacrosTest.php index 3e59acd..2f8de91 100644 --- a/tests/Unit/BlueprintMacrosTest.php +++ b/tests/Unit/BlueprintMacrosTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Schema\Grammars\PostgresGrammar; use Illuminate\Support\Facades\DB; use Illuminate\Support\Fluent; +use Pelmered\LaraPara\Exceptions\InvalidColumnScale; /** * Laravel 12 moved the connection into the Blueprint constructor's first argument. @@ -185,6 +186,50 @@ function currencyColumn(string $name = 'price_currency'): array expect(macroColumns('money', [null, 4])['amount'])->toBe(amount('decimal', total: 12, places: 4)); }); +// A decimal column cannot keep more decimals than it holds digits: MySQL and PostgreSQL refuse the +// column outright, while SQLite takes it and every amount written to it — so this used to be +// something a project heard from its own database at deploy time, with a green test suite behind it. +// smallMoney() is where it bites: its six digits have no room for the eight decimals a crypto amount +// needs, which is the scale the README tells crypto projects to configure. +it('refuses a scale the column has no digits for', function (string $macro, ?int $scale, int $configured): void { + config(['larapara.store.format' => 'decimal', 'larapara.store.decimal_scale' => $configured]); + + expect(fn (): array => macroColumns($macro, [null, $scale])) + ->toThrow(InvalidColumnScale::class); +})->with([ + 'small column, scale from the macro' => ['smallMoney', 8, 3], + 'small column, scale from the config' => ['smallMoney', null, 8], + 'as many decimals as digits' => ['smallMoney', 6, 3], + 'wide column, from the macro' => ['money', 12, 3], + 'wide column, from the config' => ['money', null, 20], +]); + +it('takes a scale the column has one digit left for', function (): void { + config(['larapara.store.format' => 'decimal']); + + expect(macroColumns('smallMoney', [null, 5])['amount']) + ->toBe(amount('decimal', unsigned: true, nullable: true, total: 6, places: 5)); +}); + +// The way out of the exception is the point of it, so the message names both numbers and the macro +// that has the digits to spare. +it('names the digits and the wider macro when it refuses a scale', function (): void { + expect(InvalidColumnScale::exceedsColumnDigits('price', 8, 6)->getMessage()) + ->toContain('"price"') + ->toContain('8 decimals') + ->toContain('6 digits') + ->toContain('money() holds 12'); +}); + +// The scale belongs to a decimal column, so integer storage passes it by rather than refusing it: a +// project storing minor units has no column for the decimals to be too many for. +it('ignores a scale integer storage has no column for', function (): void { + config(['larapara.store.format' => 'int', 'larapara.store.decimal_scale' => 8]); + + expect(macroColumns('smallMoney', [null, 8])['amount']) + ->toBe(amount('smallInteger', unsigned: true, nullable: true)); +}); + // The returned column is the amount, so the chain lands where it reads as landing. it('returns a column the caller can keep building on', function (): void { $blueprint = newBlueprint('test_table'); @@ -237,3 +282,14 @@ function currencyColumn(string $name = 'price_currency'): array '"price" decimal(12, 3) not null', ], ]); + +// A scale is a count of decimals, so a negative one is not a narrower column but a nonsense one: the +// macro wrote decimal(12, -1), which MySQL rejects outright and other drivers read as they please. +it('refuses a negative scale', function (?int $scale, int $configured): void { + config(['larapara.store.format' => 'decimal', 'larapara.store.decimal_scale' => $configured]); + + expect(fn (): array => macroColumns('money', [null, $scale]))->toThrow(InvalidColumnScale::class); +})->with([ + 'from the macro' => [-1, 3], + 'from the config' => [null, -1], +]); diff --git a/tests/Unit/Casts/CurrencyCastTest.php b/tests/Unit/Casts/CurrencyCastTest.php index 345de62..2849b7c 100644 --- a/tests/Unit/Casts/CurrencyCastTest.php +++ b/tests/Unit/Casts/CurrencyCastTest.php @@ -3,6 +3,7 @@ namespace Pelmered\LaraPara\Tests\Unit\Casts; use Money\Currency as MoneyCurrency; +use Pelmered\LaraPara\Casts\CurrencyCast; use Pelmered\LaraPara\Currencies\Currency; use Pelmered\LaraPara\Exceptions\UnsupportedCurrency; use Pelmered\LaraPara\Tests\Support\Models\Post; @@ -130,3 +131,69 @@ ->and(json_decode($model->toJson(), true)['price_currency'])->toBe('SEK') ->and(json_encode($model->price_currency))->toBe('"SEK"'); }); + +// serialize() reads the code of the object it is given rather than resolving it a second time: it +// threw for a currency the configuration does not list, failing to serialize a value it had been +// handed, and paid a registry lookup per serialized attribute per row to do it. +it('serializes a currency it is handed without resolving it', function (): void { + config(['larapara.currency_cast_to' => MoneyCurrency::class]); + + expect((new CurrencyCast)->serialize(new Post, 'price_currency', new MoneyCurrency('GBP'), [])) + ->toBe('GBP'); +}); + +// The code was read into a \Money\Currency straight from the column, so a row holding a code +// available_currencies does not list read cleanly and threw later, out of set(), which Eloquent +// calls to merge a cast attribute back: toArray(), save() and getAttributes() all failed on a row +// whose attribute was fine, and the exception came from the write path of a read. +it('refuses a code the configuration does not know in either cast', function (string $castTo): void { + config(['larapara.currency_cast_to' => $castTo]); + + $model = (new Post)->newFromBuilder(['price' => 123456, 'price_currency' => 'GBP']); + + expect(fn (): mixed => $model->price_currency)->toThrow(UnsupportedCurrency::class); +})->with([ + 'currency' => [Currency::class], + 'money currency' => [MoneyCurrency::class], +]); + +// The registry is what a code is read through now, so a column written before the codes were +// normalized reads back as the code this configuration spells. +it('normalizes a code the column spells differently', function (string $castTo): void { + config(['larapara.currency_cast_to' => $castTo]); + + $model = (new Post)->newFromBuilder(['price' => 123456, 'price_currency' => 'sek']); + + expect($model->price_currency->getCode())->toBe('SEK') + ->and($model->toArray()['price_currency'])->toBe('SEK'); +})->with([ + 'currency' => [Currency::class], + 'money currency' => [MoneyCurrency::class], +]); + +// The code carried by the object get() built, whatever the configuration casts to. +it('serializes the currency the configuration casts to', function (string $castTo, string $code): void { + config(['larapara.currency_cast_to' => $castTo]); + + $model = (new Post)->newFromBuilder(['price' => 123456, 'price_currency' => $code]); + + expect($model->toArray()['price_currency'])->toBe($code); +})->with([ + 'currency' => [Currency::class, 'SEK'], + 'money currency' => [MoneyCurrency::class, 'SEK'], +]); + +// A row written before the column was made non-nullable holds a null, and toArray() says so rather +// than reporting the default currency as the unit of an amount that is not there. +it('serializes a null as a null', function (): void { + $model = (new Post)->newFromBuilder(['price' => null, 'price_currency' => null]); + + expect($model->toArray()['price_currency'])->toBeNull() + ->and((new CurrencyCast)->serialize(new Post, 'price_currency', null, []))->toBeNull(); +}); + +// A code reaches serialize() as a string where nothing resolved the attribute into an object first, +// and the code it serializes as is the one the registry spells. +it('serializes a code it is handed as a string', function (): void { + expect((new CurrencyCast)->serialize(new Post, 'price_currency', 'sek', []))->toBe('SEK'); +}); diff --git a/tests/Unit/Casts/MoneyCastTest.php b/tests/Unit/Casts/MoneyCastTest.php index 75a40ab..d17361d 100644 --- a/tests/Unit/Casts/MoneyCastTest.php +++ b/tests/Unit/Casts/MoneyCastTest.php @@ -6,6 +6,7 @@ use Pelmered\LaraPara\Casts\CurrencyCast; use Pelmered\LaraPara\Casts\MoneyCast; use Pelmered\LaraPara\Exceptions\InvalidAmount; +use Pelmered\LaraPara\Exceptions\InvalidColumnScale; use Pelmered\LaraPara\Exceptions\UnsupportedCurrency; use Pelmered\LaraPara\Tests\Support\Models\Post; @@ -26,6 +27,20 @@ class TestModel extends Model protected $fillable = ['amount', 'currency']; } +// A column given a scale of its own by the macro — `$table->money('price', scale: 8)` — needs the +// cast told the same, since the cast is the side that refuses an amount the column cannot hold. +class FineScaleModel extends Model +{ + protected $guarded = []; + + public $timestamps = false; + + protected $casts = [ + 'price' => MoneyCast::class.':8', + 'price_currency' => CurrencyCast::class, + ]; +} + // Casting only the amount is a supported configuration too — nothing in MoneyCast requires // CurrencyCast — and it is the only one that can hold a currency code the registry would refuse. class AmountOnlyModel extends Model @@ -284,4 +299,269 @@ class AmountOnlyModel extends Model expect($model->getAttributes()['price'])->toBe('922337203685477.58'); }); + +// And reads it back the same way, by moving the point rather than by multiplying: a double carries +// 2**53 exactly and nothing above it, so the read used to hand back a different amount than was +// written for anything larger. +it('round trips a decimal column exactly', function (string $currency, string $amount, string $expectedColumn): void { + config([ + 'larapara.store.format' => 'decimal', + 'larapara.store.decimal_scale' => 8, + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', 'JPY', 'BHD', 'BTC'], + ]); + + $cast = new MoneyCast; + $model = new TestModel; + $model->price_currency = $currency; + + $stored = $cast->set($model, 'price', new Money($amount, new Currency($currency)), [])['price']; + + expect($stored)->toBe($expectedColumn) + ->and($cast->get($model, 'price', $stored, [])->getAmount())->toBe($amount); +})->with([ + 'past what a double holds' => ['USD', '10000000000000001', '100000000000000.01'], + 'the largest int' => ['USD', '92233720368547758', '922337203685477.58'], + 'an ordinary amount' => ['USD', '123456', '1234.56'], + 'below one' => ['USD', '5', '0.05'], + 'negative' => ['USD', '-123456', '-1234.56'], + 'zero' => ['USD', '0', '0.00'], + 'no minor units' => ['JPY', '1234', '1234'], + 'three minor units' => ['BHD', '1234567', '1234.567'], + 'eight minor units' => ['BTC', '2100000000000000', '21000000.00000000'], +]); + +// A column keeps its own scale, so it hands back an amount padded past the minor unit of its +// currency — and a row written by hand can carry more decimals than the currency has at all. +it('reads a decimal column whatever scale it kept', function (string $currency, string $column, string $expectedAmount): void { + config([ + 'larapara.store.format' => 'decimal', + 'larapara.available_currencies' => ['USD', 'JPY'], + ]); + + $model = new TestModel; + $model->price_currency = $currency; + + expect((new MoneyCast)->get($model, 'price', $column, [])->getAmount())->toBe($expectedAmount); +})->with([ + 'padded to the column scale' => ['USD', '1234.560', '123456'], + 'padded with nothing to spare' => ['USD', '1234.500', '123450'], + 'no fractional part at all' => ['USD', '1234', '123400'], + 'zero padded' => ['USD', '0.000', '0'], + 'no minor units, padded' => ['JPY', '1234.000', '1234'], + 'more decimals than the unit' => ['USD', '1234.567', '123457'], +]); + +// The scale used to come from the configuration alone, so a column the macro gave eight decimals to +// still refused a satoshi while the configured scale was three, and a column given fewer decimals +// than the configuration accepted amounts the database would round away. +it('refuses an amount by the scale the cast was given', function (): void { + config([ + 'larapara.store.format' => 'decimal', + 'larapara.store.decimal_scale' => 3, + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', 'BTC'], + ]); + + $model = new TestModel; + $money = new Money('123456789', new Currency('BTC')); + + expect((new MoneyCast(8))->set($model, 'price', $money, [])['price'])->toBe('1.23456789') + ->and(fn (): array => (new MoneyCast)->set($model, 'price', $money, [])) + ->toThrow(InvalidAmount::class); +}); + +it('refuses an amount the narrower column of the cast cannot hold', function (): void { + config([ + 'larapara.store.format' => 'decimal', + 'larapara.store.decimal_scale' => 8, + 'larapara.available_currencies' => ['USD', 'BHD'], + ]); + + $model = new TestModel; + $cast = new MoneyCast(2); + + expect($cast->set($model, 'price', new Money('1234560', new Currency('BHD')), [])['price'])->toBe('1234.56') + ->and(fn (): array => $cast->set($model, 'price', new Money('1234567', new Currency('BHD')), [])) + ->toThrow(InvalidAmount::class); +}); + +// Eloquent hands a cast its parameters as strings, so the scale arrives as '8' rather than as 8. +it('takes the scale as a cast parameter', function (): void { + config([ + 'larapara.store.format' => 'decimal', + 'larapara.store.decimal_scale' => 3, + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', 'BTC'], + ]); + + $model = new FineScaleModel; + $model->price = new Money('123456789', new Currency('BTC')); + + expect($model->getAttributes()['price'])->toBe('1.23456789'); +}); + +// A driver is free to hand back a decimal column as a float in exponent notation, and the reading +// written for that notation was unreachable: the guard above it asked is_numeric(), which exponent +// notation satisfies, so "1E+25" was padded to the digit string "1E+2500" and handed to Money — +// which refused it a character at a time rather than reading the amount. +it('reads a column handed back in exponent notation', function (string $column, string $expectedAmount): void { + config([ + 'larapara.store.format' => 'decimal', + 'larapara.available_currencies' => ['USD'], + ]); + + $model = new TestModel; + $model->price_currency = 'USD'; + + expect((new MoneyCast)->get($model, 'price', $column, [])->getAmount())->toBe($expectedAmount); +})->with([ + 'a whole number of units' => ['1E+3', '100000'], + 'lower case, with a fraction' => ['1.5e3', '150000'], + 'a negative exponent' => ['1.2E-1', '12'], + 'negative' => ['-1E+3', '-100000'], +]); + +// Read through a float, an amount past the integer range wrapped to an unrelated negative one, so a +// column holding more than the cast can read handed back a plausible-looking wrong amount. +it('refuses a column holding more minor units than an integer', function (): void { + config([ + 'larapara.store.format' => 'decimal', + 'larapara.available_currencies' => ['USD'], + ]); + + $model = new TestModel; + $model->price_currency = 'USD'; + + expect(fn (): ?Money => (new MoneyCast)->get($model, 'price', '1.0E+25', [])) + ->toThrow(InvalidAmount::class); +}); + +// An amount is whole minor units, and a decimal string was cast to an int instead: '1234.56' was +// read as 1234 and stored as $12.34, the amount nobody meant, while the formatter refuses the same +// string outright. The two are given the same amounts, so they answer the same way. +it('refuses an amount that is not whole minor units', function (mixed $value): void { + $model = new TestModel; + + expect(fn (): array => (new MoneyCast)->set($model, 'price', $value, [])) + ->toThrow(InvalidAmount::class); +})->with([ + 'a decimal string' => ['1234.56'], + 'a decimal string, minor' => ['0.5'], + 'exponent notation' => ['1.2E+3'], + 'not a number at all' => ['twelve'], + 'in the array form' => [['amount' => '1234.56', 'currency' => 'USD']], +]); + +it('takes an amount in minor units however it is written', function (mixed $value, int $expected): void { + $model = new TestModel; + + expect((new MoneyCast)->set($model, 'price', $value, [])['price'])->toBe($expected); +})->with([ + 'an int' => [123456, 123456], + 'a string' => ['123456', 123456], + 'a padded string' => [' 123456 ', 123456], + 'a negative string' => ['-123456', -123456], + 'zero' => ['0', 0], +]); + +// The cast moved the point the wrong way for a negative scale instead of refusing it, and only for +// the amounts it could: $1,230.00 was stored as 1.23, a thousandth of the amount, while $1,234.56 +// threw. A scale is a count of decimals, so neither side of the column takes a negative one. +it('refuses a negative scale', function (): void { + config([ + 'larapara.store.format' => 'decimal', + 'larapara.available_currencies' => ['USD'], + ]); + + $model = new TestModel; + $money = new Money('123000', new Currency('USD')); + + expect(fn (): array => (new MoneyCast(-1))->set($model, 'price', $money, [])) + ->toThrow(InvalidColumnScale::class); + + config(['larapara.store.decimal_scale' => -1]); + + expect(fn (): array => (new MoneyCast)->set($model, 'price', $money, [])) + ->toThrow(InvalidColumnScale::class); +}); + +// A Money holds its amount as a string and the money library counts in arbitrary precision, so an +// amount can be larger than the integer a column stores. Cast to one, it clamped to the largest +// integer there is: 99999999999999999999 was stored as 9223372036854775807 in an integer column and +// as 92233720368547758.07 in a decimal one, both silently, and both a different amount. +it('refuses an amount larger than the integer a column stores', function (string $format): void { + config(['larapara.store.format' => $format]); + + $model = new TestModel; + $money = new Money('99999999999999999999', new Currency('USD')); + + expect(fn (): array => (new MoneyCast)->set($model, 'price', $money, [])) + ->toThrow(InvalidAmount::class, '99999999999999999999'); +})->with(['int', 'decimal']); + +it('refuses an amount past the integer range whichever way it is written', function (mixed $value): void { + expect(fn (): array => (new MoneyCast)->set(new TestModel, 'price', $value, [])) + ->toThrow(InvalidAmount::class); +})->with([ + 'one past the largest' => ['9223372036854775808'], + 'one past the smallest' => ['-9223372036854775809'], + 'far past it' => ['99999999999999999999'], + 'in the array form' => [['amount' => '99999999999999999999', 'currency' => 'USD']], +]); + +it('stores the largest and smallest amounts an integer holds', function (string $amount): void { + expect((new MoneyCast)->set(new TestModel, 'price', new Money($amount, new Currency('USD')), [])['price']) + ->toBe((int) $amount); +})->with([ + 'the largest' => [(string) PHP_INT_MAX], + 'the smallest' => [(string) PHP_INT_MIN], +]); + +// The array form names the amount and the currency, either by key or by position. Without the +// currency the default one is written, the same as for a currency assigned as null: the column is +// not nullable, so an amount always records the unit it is counted in. +it('takes the amount and the currency of an array whichever way it names them', function (array $value, array $expected): void { + config(['larapara.available_currencies' => ['USD', 'JPY']]); + + $model = new TestModel; + $model->price = $value; + + expect($model->getAttributes())->toMatchArray($expected); +})->with([ + 'by key' => [['amount' => '98765', 'currency' => 'JPY'], ['price' => 98765, 'price_currency' => 'JPY']], + 'by position' => [['98765', 'JPY'], ['price' => 98765, 'price_currency' => 'JPY']], + 'no currency at all' => [['amount' => '98765'], ['price' => 98765, 'price_currency' => 'USD']], +]); + +// Read with (int) alone, a column carrying more minor units than an integer holds clamped to +// PHP_INT_MAX — an amount that is not the one stored, and one the cast refuses to write back. A +// bigint column cannot hold such a value, but the text and decimal columns a hand-written migration +// leaves behind can, and the exponent-notation reading already refused it. +it('refuses to read a column holding more minor units than an integer', function (string $format, string $column): void { + config(['larapara.store.format' => $format]); + + $model = (new Post)->newFromBuilder(['price_currency' => 'USD']); + + expect(fn (): ?Money => (new MoneyCast)->get($model, 'price', $column, [])) + ->toThrow(InvalidAmount::class, $column); +})->with([ + 'integer storage' => ['int', '99999999999999999999'], + 'integer storage, negative' => ['int', '-99999999999999999999'], + 'decimal storage' => ['decimal', '99999999999999999999.00'], +]); + +// abs() has no integer to return for PHP_INT_MIN, so it handed back a float and the point was placed +// in its exponent notation: the column was given "-9.2233720368548E+.18", which a strict database +// refuses outright and SQLite stores as text. +it('writes the smallest amount an integer holds to a decimal column', function (): void { + config(['larapara.store.format' => 'decimal', 'larapara.store.decimal_scale' => 2]); + + $model = (new Post)->newFromBuilder(['price_currency' => 'USD']); + $cast = new MoneyCast; + + $stored = $cast->set($model, 'price', new Money((string) PHP_INT_MIN, new Currency('USD')), [])['price']; + + expect($stored)->toBe('-92233720368547758.08') + ->and($cast->get($model, 'price', $stored, [])->getAmount())->toBe((string) PHP_INT_MIN); }); diff --git a/tests/Unit/Commands/CacheCommandsTest.php b/tests/Unit/Commands/CacheCommandsTest.php index f4091a8..e06baa5 100644 --- a/tests/Unit/Commands/CacheCommandsTest.php +++ b/tests/Unit/Commands/CacheCommandsTest.php @@ -30,6 +30,10 @@ config(['larapara.currency_cache.type' => 'remember']); config(['larapara.currency_cache.ttl' => '500']); + // Cleared before it is filled, since the currencies are memoized for the life of the process: + // a read only reaches the cache store when the memo has nothing for this configuration. + CurrencyRepository::clearCache(); + $currencies = CurrencyRepository::getAvailableCurrencies(); expect(Cache::has('larapara_currencies'))->toBeTrue(); @@ -75,3 +79,26 @@ ) ->assertExitCode(0); }); + +// The command only read the currencies, and a read writes through the cache on a miss alone: a +// `flexible` entry that was still fresh came back as it stood, so `php artisan optimize` after a +// configuration change reported the currencies from before it as the ones it had just cached. +test('cache command replaces an entry that is still fresh', function (): void { + config([ + 'larapara.currency_cache.type' => 'flexible', + 'larapara.currency_cache.ttl' => [2592000, 31556926], + 'larapara.available_currencies' => ['USD', 'EUR'], + ]); + + CurrencyRepository::clearCache(); + + expect(CurrencyRepository::getAvailableCurrencies())->toHaveCount(2); + + config(['larapara.available_currencies' => ['USD', 'EUR', 'SEK']]); + + test()->artisan('money:cache') + ->expectsOutputToContain('3 Currencies cached.') + ->assertExitCode(0); + + expect(Cache::get(CurrencyRepository::CACHE_KEY))->toHaveCount(3); +}); diff --git a/tests/Unit/Currency/CurrencyMemoTest.php b/tests/Unit/Currency/CurrencyMemoTest.php new file mode 100644 index 0000000..4b35177 --- /dev/null +++ b/tests/Unit/Currency/CurrencyMemoTest.php @@ -0,0 +1,105 @@ + ['alphabeticCode' => 'USD', 'currency' => 'US Dollar', 'minorUnit' => 2, 'numericCode' => 840], + 'EUR' => ['alphabeticCode' => 'EUR', 'currency' => 'Euro', 'minorUnit' => 2, 'numericCode' => 978], + ]; + } +} + +beforeEach(function (): void { + CurrencyRepository::clearCache(); + CountingCurrenciesProvider::$calls = 0; + + Config::set('larapara.currency_cache.type', false); + Config::set('larapara.currency_provider', CountingCurrenciesProvider::class); + Config::set('larapara.available_currencies', ['USD', 'EUR']); +}); + +// The read path resolves the scale of every amount through the registry, so rendering a page of rows +// asked for the currencies once or twice per row: with the cache off that rebuilt the ISO list — and +// the crypto one, where it is enabled — every time, and with it on it was a round trip to the cache +// store per row. +it('builds the currency list once for a configuration', function (): void { + Currency::fromCode('USD'); + Currency::fromCode('EUR'); + CurrencyRepository::getAvailableCurrencies(); + + expect(CountingCurrenciesProvider::$calls)->toBe(1); +}); + +it('builds it again once the configuration it was built from changes', function (string $key, mixed $value): void { + CurrencyRepository::getAvailableCurrencies(); + + Config::set($key, $value); + + CurrencyRepository::getAvailableCurrencies(); + + expect(CountingCurrenciesProvider::$calls)->toBe(2); +})->with([ + 'available currencies' => ['larapara.available_currencies', ['USD']], + 'excluded currencies' => ['larapara.excluded_currencies', ['EUR']], + 'crypto currencies' => ['larapara.load_crypto_currencies', true], +]); + +it('builds it again once the provider itself changes', function (): void { + expect(CurrencyRepository::getAvailableCurrencies()->pluck('name')->all()) + ->toBe(['US Dollar', 'Euro']); + + Config::set('larapara.currency_provider', RenamingCurrenciesProvider::class); + + expect(CurrencyRepository::getAvailableCurrencies()->pluck('name')->all()) + ->toBe(['Dollar of the United States', 'Euro']); +}); + +it('builds it again after the cache is cleared', function (): void { + CurrencyRepository::getAvailableCurrencies(); + + CurrencyRepository::clearCache(); + + CurrencyRepository::getAvailableCurrencies(); + + expect(CountingCurrenciesProvider::$calls)->toBe(2); +}); + +// The list a changed configuration asks for is the list it gets, rather than the one memoized for +// the configuration before it. +it('hands back the currencies of the configuration in force', function (): void { + expect(CurrencyRepository::getAvailableCurrencies()->pluck('code')->all())->toBe(['USD', 'EUR']); + + Config::set('larapara.available_currencies', ['USD']); + + expect(CurrencyRepository::getAvailableCurrencies()->pluck('code')->all())->toBe(['USD']); +}); + +/** + * A second provider, naming a currency differently so a switch to it is visible in the collection. + */ +class RenamingCurrenciesProvider implements CurrenciesProvider +{ + public function loadCurrencies(): array + { + return [ + 'USD' => ['alphabeticCode' => 'USD', 'currency' => 'Dollar of the United States', 'minorUnit' => 2, 'numericCode' => 840], + 'EUR' => ['alphabeticCode' => 'EUR', 'currency' => 'Euro', 'minorUnit' => 2, 'numericCode' => 978], + ]; + } +} diff --git a/tests/Unit/Currency/CurrencyRepositoryTest.php b/tests/Unit/Currency/CurrencyRepositoryTest.php index 6f61cf3..a70ae59 100644 --- a/tests/Unit/Currency/CurrencyRepositoryTest.php +++ b/tests/Unit/Currency/CurrencyRepositoryTest.php @@ -8,7 +8,9 @@ use Pelmered\LaraPara\Currencies\CurrencyRepository; use Pelmered\LaraPara\Currencies\Providers\CurrenciesProvider; use Pelmered\LaraPara\Currencies\Providers\ISOCurrenciesProvider; +use Pelmered\LaraPara\Exceptions\InvalidConfiguration; use Pelmered\LaraPara\Exceptions\UnsupportedCurrency; +use Pelmered\LaraPara\Rules\SupportedCurrency; class LowerCasedCurrenciesProvider implements CurrenciesProvider { @@ -227,12 +229,31 @@ public function loadCurrencies(): array Config::set('larapara.available_currencies', $availableCurrencies); expect(fn (): CurrencyCollection => CurrencyRepository::getAvailableCurrencies()) - ->toThrow(UnsupportedCurrency::class); + ->toThrow(InvalidConfiguration::class); })->with([ 'unknown code' => [['USD', 'XXY']], 'crypto without crypto' => [['USD', 'BTC']], ]); +// A misconfigured entry used to raise UnsupportedCurrency, which is what "this code is not one of +// the configured currencies" means and what every caller asking that question catches to answer no: +// one typo in available_currencies reported every currency in the registry as invalid, USD included, +// with nothing anywhere naming the entry that was wrong. +it('does not report every currency as invalid for one misconfigured entry', function (): void { + Config::set('larapara.available_currencies', ['USD', 'EUR', 'XYZ']); + + expect(fn (): bool => CurrencyRepository::isValidCode('USD')) + ->toThrow(InvalidConfiguration::class, 'XYZ'); +}); + +it('names the misconfigured entry when a rule validates a currency', function (): void { + Config::set('larapara.available_currencies', ['USD', 'EUR', 'XYZ']); + + expect(function (): void { + (new SupportedCurrency)->validate('currency', 'USD', function (): void {}); + })->toThrow(InvalidConfiguration::class, 'XYZ'); +}); + it('takes the currency codes of a provider that keys them differently', function (): void { Config::set('larapara.currency_provider', LowerCasedCurrenciesProvider::class); Config::set('larapara.available_currencies', ['USD']); @@ -241,3 +262,27 @@ public function loadCurrencies(): array ->getCode()->toBe('USD') ->minorUnit->toBe(2); }); + +// The exclusion was diffed against the provider's own keys and the codes were upper-cased after it, +// so a provider that keys its currencies in lower case kept every currency the configuration +// excluded: the code survived the diff, was upper-cased two lines later, and appeared in the +// collection as if nothing had asked for it to be gone. +it('excludes a currency from a provider that keys them differently', function (): void { + Config::set('larapara.currency_provider', LowerCasedCurrenciesProvider::class); + Config::set('larapara.available_currencies', []); + Config::set('larapara.excluded_currencies', ['USD']); + + expect(CurrencyRepository::getAvailableCurrencies())->toHaveCount(0); +}); + +it('excludes a currency written the way the configuration happens to spell it', function (string $excluded): void { + Config::set('larapara.available_currencies', []); + Config::set('larapara.excluded_currencies', [$excluded]); + + expect(CurrencyRepository::isValidCode('USD'))->toBeFalse() + ->and(CurrencyRepository::isValidCode('EUR'))->toBeTrue(); +})->with([ + 'the code itself' => ['USD'], + 'lower case' => ['usd'], + 'padded' => [' USD '], +]); diff --git a/tests/Unit/Currency/CurrencyTest.php b/tests/Unit/Currency/CurrencyTest.php index 01a43bc..7c16e81 100644 --- a/tests/Unit/Currency/CurrencyTest.php +++ b/tests/Unit/Currency/CurrencyTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Money\Currency as MoneyCurrency; +use Money\Money; use Pelmered\LaraPara\Currencies\Currency; use Pelmered\LaraPara\Exceptions\UnsupportedCurrency; @@ -52,3 +54,54 @@ expect((string) $currency)->toBe('USD'); }); + +// A Money carries a currency that is a code and nothing else, so the name and the minor unit of the +// currency an amount is counted in are read back out of the registry through that code. +it('resolves a currency from a Money currency and from a Money', function (): void { + config(['larapara.available_currencies' => ['USD', 'SEK']]); + + $money = new Money('123456', new MoneyCurrency('SEK')); + + expect(Currency::fromMoneyCurrency(new MoneyCurrency('SEK'))) + ->toBeInstanceOf(Currency::class) + ->getCode()->toBe('SEK') + ->and(Currency::fromMoney($money)) + ->toBeInstanceOf(Currency::class) + ->getCode()->toBe('SEK'); +}); + +// A Money can be built in any currency the money library knows, which is every ISO code — this +// configuration is the narrower list, and the code is checked against it here as everywhere else. +it('refuses a Money in a currency this configuration does not know', function (): void { + config(['larapara.available_currencies' => ['USD', 'SEK']]); + + $money = new Money('123456', new MoneyCurrency('GBP')); + + expect(fn (): Currency => Currency::fromMoney($money))->toThrow(UnsupportedCurrency::class); +}); + +// Two currencies are the same currency when they are the same code: the registry hands out an object +// per lookup, so comparing the objects themselves would make a currency unequal to itself. +it('compares currencies by their code', function (): void { + config(['larapara.available_currencies' => ['USD', 'SEK']]); + + expect(Currency::fromCode('SEK')->equals(Currency::fromCode('sek')))->toBeTrue() + ->and(Currency::fromCode('SEK')->equals(Currency::fromCode('USD')))->toBeFalse(); +}); + +it('converts to a Money currency of the same code', function (): void { + config(['larapara.available_currencies' => ['USD', 'SEK']]); + + expect(Currency::fromCode('SEK')->toMoneyCurrency()) + ->toBeInstanceOf(MoneyCurrency::class) + ->getCode()->toBe('SEK'); +}); + +// A code is the whole of what a Money currency is, and an empty one names no currency at all, so USD +// stands in rather than an empty code travelling into the money library — the assumption to know +// about if a provider ever supplies a currency with no code. +it('converts a currency with no code to the fallback', function (): void { + expect((new Currency('', 'Nothing'))->toMoneyCurrency()) + ->toBeInstanceOf(MoneyCurrency::class) + ->getCode()->toBe('USD'); +}); diff --git a/tests/Unit/MoneyFormatter/CurrencyFormattingRulesTest.php b/tests/Unit/MoneyFormatter/CurrencyFormattingRulesTest.php index 1f4f43c..f8b3fcf 100644 --- a/tests/Unit/MoneyFormatter/CurrencyFormattingRulesTest.php +++ b/tests/Unit/MoneyFormatter/CurrencyFormattingRulesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Pelmered\LaraPara\MoneyFormatter\CurrencyFormattingRules; +use Pelmered\LaraPara\Currencies\CurrencyFormattingRules; it('creates a correct default rules instance', function (): void { $rules = new CurrencyFormattingRules( diff --git a/tests/Unit/MoneyFormatter/FormatShortTest.php b/tests/Unit/MoneyFormatter/FormatShortTest.php index 77b3628..396bd45 100644 --- a/tests/Unit/MoneyFormatter/FormatShortTest.php +++ b/tests/Unit/MoneyFormatter/FormatShortTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Number; use Pelmered\LaraPara\Currencies\Currency; +use Pelmered\LaraPara\Exceptions\InvalidNumber; use Pelmered\LaraPara\MoneyFormatter\MoneyFormatter; beforeEach(function (): void { @@ -142,3 +143,40 @@ 'one' => [1, '$1M'], 'three' => [3, '$1.23M'], ]); + +// $999,600 is 999.6 thousand, which every precision below four digits writes as 1000 — and 1,000K is +// not an abbreviation of anything, so the magnitude has to carry with the rounding that caused it. +it('carries into the next magnitude when the mantissa rounds up', function (array $precision, string $expectedOutput): void { + expect(MoneyFormatter::formatShortFromMinor(99960000, Currency::fromCode('USD'), 'en_US', ...$precision)) + ->toBe($expectedOutput); +})->with([ + 'default' => [[], '$999.60K'], + 'two decimals' => [['decimals' => 2], '$999.60K'], + 'one decimal' => [['decimals' => 1], '$999.6K'], + 'no decimals' => [['decimals' => 0], '$1M'], + 'one significant digit' => [['significantDigits' => 1], '$1M'], + 'two significant' => [['significantDigits' => 2], '$1M'], + 'three significant' => [['significantDigits' => 3], '$1M'], + 'four significant' => [['significantDigits' => 4], '$999.6K'], +]); + +// An empty amount is an empty field, the way it is everywhere else in the formatter: a column that +// holds no amount abbreviates to nothing rather than to the abbreviation of zero. +it('abbreviates nothing as nothing', function (null|int|string $value): void { + expect(MoneyFormatter::formatShortFromMinor($value, Currency::fromCode('USD'), 'en_US'))->toBe(''); +})->with([ + 'null' => [null], + 'empty string' => [''], +]); + +// Significant digits count digits, so the count starts at one: zero of them says nothing about the +// number, and ICU renders it as though none had been asked for. +it('refuses fewer than one significant digit', function (int $significantDigits): void { + expect(fn (): string => MoneyFormatter::formatShortFromMinor(123456789, Currency::fromCode('USD'), 'en_US', significantDigits: $significantDigits)) + ->toThrow(InvalidNumber::class, (string) $significantDigits) + ->and(fn (): string => MoneyFormatter::formatNumber(1234.56, 'en_US', significantDigits: $significantDigits)) + ->toThrow(InvalidNumber::class, (string) $significantDigits); +})->with([ + 'none' => [0], + 'negative' => [-2], +]); diff --git a/tests/Unit/MoneyFormatter/MinorUnitTest.php b/tests/Unit/MoneyFormatter/MinorUnitTest.php new file mode 100644 index 0000000..1c3a9e8 --- /dev/null +++ b/tests/Unit/MoneyFormatter/MinorUnitTest.php @@ -0,0 +1,89 @@ + [ + 'alphabeticCode' => 'USD', + 'currency' => 'US Dollar', + 'minorUnit' => 4, + 'numericCode' => 840, + ], + ]; + } +} + +beforeEach(function (): void { + CurrencyRepository::clearCache(); + Config::set('larapara.currency_cache.type', false); + Config::set('larapara.currency_provider', FineGrainedDollarProvider::class); + Config::set('larapara.available_currencies', ['USD']); +}); + +// ISO 4217 was consulted before the configured provider, so a provider that gives an ISO currency a +// scale of its own was honoured for the currency's existence and its name but not for its scale: +// amounts were rendered, parsed and stored two decimals wide whatever it said. +it('takes the minor unit of a currency from its provider', function (): void { + expect(MoneyFormatter::getMinorUnit(Currency::fromCode('USD')))->toBe(4); +}); + +it('reads the minor unit of a provider through a bare money currency too', function (): void { + expect(MoneyFormatter::getMinorUnit(new MoneyCurrency('USD')))->toBe(4); +}); + +it('formats an amount with the decimals its provider gives it', function (): void { + expect(MoneyFormatter::formatFromMinor(12345678, Currency::fromCode('USD'), 'en_US', showCurrencySymbol: false)) + ->toBe('1,234.5678'); +}); + +it('parses an amount into the decimals its provider gives it', function (): void { + expect(MoneyFormatter::parseToMinor('1,234.5678', Currency::fromCode('USD'), 'en_US')) + ->toBe('12345678'); +}); + +// The symbol-ful path places the point through the money library rather than through ICU alone, and +// read its scale from a currency list ISO 4217 sat in front of. +it('formats an amount with its symbol and the decimals of its provider', function (): void { + expect(MoneyFormatter::formatFromMinor(12345678, Currency::fromCode('USD'), 'en_US')) + ->toBe('$1,234.5678'); +}); + +// The cast reads its scale through the same resolver, so the column holds the decimals the provider +// declares rather than the two ISO would have written. +it('stores an amount with the decimals its provider gives it', function (): void { + Config::set('larapara.store.format', 'decimal'); + Config::set('larapara.store.decimal_scale', 4); + + $model = new Post; + $money = new Money('12345678', new MoneyCurrency('USD')); + + expect((new MoneyCast)->set($model, 'price', $money, [])['price'])->toBe('1234.5678'); +}); + +// Nothing names a minor unit for a currency built by hand, so ISO still answers for it. +it('falls back to ISO for a currency that carries no minor unit', function (string $code, int $expected): void { + expect(MoneyFormatter::getMinorUnit(new Currency($code, '')))->toBe($expected); +})->with([ + 'two decimals' => ['EUR', 2], + 'no decimals' => ['JPY', 0], + 'three' => ['BHD', 3], + 'outside ISO' => ['XYZ', 2], +]); diff --git a/tests/Unit/MoneyFormatter/MoneyFormatterEdgeCasesTest.php b/tests/Unit/MoneyFormatter/MoneyFormatterEdgeCasesTest.php index e9addcd..37a04e9 100644 --- a/tests/Unit/MoneyFormatter/MoneyFormatterEdgeCasesTest.php +++ b/tests/Unit/MoneyFormatter/MoneyFormatterEdgeCasesTest.php @@ -2,7 +2,8 @@ declare(strict_types=1); -use Money\Exception\UnknownCurrencyException; +use Money\Exception\ParserException; +use Money\Money; use Pelmered\LaraPara\Currencies\Currency; use Pelmered\LaraPara\Exceptions\InvalidAmount; use Pelmered\LaraPara\MoneyFormatter\MoneyFormatter; @@ -132,17 +133,36 @@ ->and(MoneyFormatter::parseToMinor('0.00000001', $btc, 'en_US'))->toBe('1'); }); -// A currency symbol is the one thing ICU cannot supply for a currency it has no data for. -it('still refuses to put a symbol on a currency outside ISO 4217', function (): void { +// ICU writes the code where it has no symbol, so the only thing that ever stood in the way was the +// formatter being handed ISO 4217 alone to place the decimal point by. +it('puts the code on a currency outside ISO 4217', function (): void { config([ 'larapara.load_crypto_currencies' => true, 'larapara.available_currencies' => ['USD', 'BTC'], ]); - expect(fn (): string => MoneyFormatter::formatFromMinor(100000000, Currency::fromCode('BTC'), 'en_US')) - ->toThrow(UnknownCurrencyException::class); + $btc = Currency::fromCode('BTC'); + + expect(replaceNonBreakingSpaces(MoneyFormatter::formatFromMinor(100000000, $btc, 'en_US'))) + ->toBe('BTC 1.00000000') + ->and(replaceNonBreakingSpaces(MoneyFormatter::format(new Money('100000000', $btc->toMoneyCurrency()), 'en_US'))) + ->toBe('BTC 1.00000000') + ->and(replaceNonBreakingSpaces(MoneyFormatter::formatShortFromMinor(123456789000, $btc, 'en_US'))) + ->toBe('BTC 1.23K'); }); +// The aggregate is ISO first, so a currency ISO covers is placed by ISO's data either way. +it('places an ISO currency by ISO data', function (string $currency, int $value, string $expectedOutput): void { + config(['larapara.available_currencies' => ['USD', 'JPY', 'BHD']]); + + expect(replaceNonBreakingSpaces(MoneyFormatter::formatFromMinor($value, Currency::fromCode($currency), 'en_US'))) + ->toBe($expectedOutput); +})->with([ + 'two minor units' => ['USD', 123456, '$1,234.56'], + 'no minor units' => ['JPY', 1234, '¥1,234'], + 'three minor units' => ['BHD', 1234567, 'BHD 1,234.567'], +]); + // Strict parsing accepts only what the locale itself writes, which is what the formatter writes, so // the round trip holds in strict mode too — including the locales whose separators are not typeable. it('parses back what it formats in strict mode', function (string $currency, string $locale): void { @@ -157,3 +177,224 @@ 'yen, no minor unit' => ['JPY', 'ja_JP'], 'dinar, three minor units' => ['BHD', 'ar_BH'], ]); + +// ICU carries a currency as a three-character code: it truncates a longer one to its first three +// characters and refuses a shorter one outright. The bundled crypto list has 181 of them, so +// 1000SATS came out as "100" — an amount labelled as a currency it is not counted in. +it('writes a currency code ICU cannot carry as it is', function (string $currency, string $expectedOutput): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS', 'AUCTION', '1INCH', 'AI', 'BTC'], + ]); + + expect(replaceNonBreakingSpaces(MoneyFormatter::formatFromMinor(100000000, Currency::fromCode($currency), 'en_US'))) + ->toBe($expectedOutput); +})->with([ + 'digits at the front' => ['1000SATS', '1000SATS 1.00000000'], + 'seven characters' => ['AUCTION', 'AUCTION 1.00000000'], + 'five characters' => ['1INCH', '1INCH 1.00000000'], + 'shorter than a code' => ['AI', 'AI 1.00000000'], + 'three characters, as is' => ['BTC', 'BTC 1.00000000'], +]); + +// The code stands in for the symbol, so ICU still decides where it goes, what space it is separated +// by and which digits and directional marks the locale writes. +it('places a currency code ICU cannot carry the way the locale places a symbol', function (string $locale, string $expectedOutput): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + $sats = Currency::fromCode('1000SATS'); + + expect(replaceNonBreakingSpaces(MoneyFormatter::formatFromMinor(-123456789, $sats, $locale))) + ->toBe($expectedOutput); +})->with([ + 'symbol in front' => ['en_US', '-1000SATS 1.23456789'], + 'symbol behind' => ['de_DE', '-1,23456789 1000SATS'], + 'minus of its own' => ['sv_SE', '−1,23456789 1000SATS'], +]); + +// Every entry point that writes a currency, since each reaches ICU by a different route: a Money +// through the formatter of the money library, an abbreviation through a pattern of its own, and the +// ISO code where the configuration asks for codes rather than symbols. +it('writes a currency code ICU cannot carry from every entry point', function (): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + $sats = Currency::fromCode('1000SATS'); + + expect(replaceNonBreakingSpaces(MoneyFormatter::format(new Money('100000000', $sats->toMoneyCurrency()), 'en_US'))) + ->toBe('1000SATS 1.00000000') + ->and(replaceNonBreakingSpaces(MoneyFormatter::formatShortFromMinor(123456789000, $sats, 'en_US'))) + ->toBe('1000SATS 1.23K') + ->and(replaceNonBreakingSpaces(MoneyFormatter::formatShort(new Money('123456789000', $sats->toMoneyCurrency()), 'en_US'))) + ->toBe('1000SATS 1.23K'); + + config(['larapara.intl_currency_symbol' => true]); + + expect(replaceNonBreakingSpaces(MoneyFormatter::formatFromMinor(100000000, $sats, 'en_US'))) + ->toBe('1000SATS 1.00000000'); +}); + +// A parser that refuses its own output is a trap, and ICU has no reading of these codes to be strict +// about, so the notation the formatter writes is read back in both modes. +it('parses back a currency code ICU cannot carry', function (bool $strict): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + $sats = Currency::fromCode('1000SATS'); + $formatted = MoneyFormatter::formatFromMinor(123456789, $sats, 'en_US'); + + expect(MoneyFormatter::parseToMinor($formatted, $sats, 'en_US', strict: $strict)) + ->toBe('123456789') + ->and(MoneyFormatter::parseToMinor(MoneyFormatter::formatFromMinor(123456789, $sats, 'sv_SE'), $sats, 'sv_SE', strict: $strict)) + ->toBe('123456789'); +})->with([ + 'strict' => [true], + 'lenient' => [false], +]); + +// Only the currency being read, in strict mode as well: the code beside the number is read where it +// is the code of the currency asked for, and nothing else is. +it('refuses another currency beside the number in strict mode', function (): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS', 'AUCTION'], + ]); + + expect(fn (): string => MoneyFormatter::parseToMinor('AUCTION 1.00000000', Currency::fromCode('1000SATS'), 'en_US', strict: true)) + ->toThrow(ParserException::class); +}); + +// The amount reaches ICU as a double, here and through the money library both, so an amount above +// 2**53 minor units was rendered as a neighbouring one: 900719925474099301 came out as +// $9,007,199,254,740,994.00, a dollar off an amount the casts store and read back exactly. +it('refuses an amount a double cannot carry digit for digit', function (): void { + config(['larapara.available_currencies' => ['USD']]); + + $usd = Currency::fromCode('USD'); + + expect(fn (): string => MoneyFormatter::formatFromMinor('900719925474099301', $usd, 'en_US')) + ->toThrow(InvalidAmount::class) + ->and(fn (): string => MoneyFormatter::format(new Money('900719925474099301', $usd->toMoneyCurrency()), 'en_US')) + ->toThrow(InvalidAmount::class) + ->and(fn (): string => MoneyFormatter::formatFromMinor('900719925474099301', $usd, 'en_US', showCurrencySymbol: false)) + ->toThrow(InvalidAmount::class); +}); + +it('formats the largest amount a double carries exactly', function (): void { + config(['larapara.available_currencies' => ['USD']]); + + expect(MoneyFormatter::formatFromMinor('9007199254740992', Currency::fromCode('USD'), 'en_US')) + ->toBe('$90,071,992,547,409.92'); +}); + +// An abbreviation is an approximation by construction — $9.01Q says nothing about its last digit — so +// it is the one place an amount too large to render exactly is still rendered. +it('abbreviates an amount too large to format exactly', function (): void { + config(['larapara.available_currencies' => ['USD']]); + + expect(replaceNonBreakingSpaces(MoneyFormatter::formatShortFromMinor('900719925474099301', Currency::fromCode('USD'), 'en_US'))) + ->toBe('$9.01Q'); +}); + +// Strict mode accepts what the locale writes, and for a code ICU carries it is ICU that decides what +// that means: the exact space of the locale (a plain one is refused), the code where the symbol goes +// and nowhere else, and any number of decimals. A code ICU cannot carry is held to the same rules +// rather than to none, which is what stripping it from either end amounted to. +it('refuses a code ICU cannot carry out of its place in strict mode', function (string $input): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + expect(fn (): string => MoneyFormatter::parseToMinor($input, Currency::fromCode('1000SATS'), 'en_US', strict: true)) + ->toThrow(ParserException::class); +})->with([ + 'suffix where the locale writes a prefix' => ['1.00000000 1000SATS'], + 'a space the locale does not write' => ['1000SATS 1.00000000'], + 'two of the space it does write' => ["1000SATS\u{a0}\u{a0}1.00000000"], + 'suffix with no separator' => ['1.000000001000SATS'], +]); + +// ICU reads its own code with no space between it and the number, so this reads that too. +it('accepts a code ICU cannot carry with no separator, as ICU does', function (): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + expect(MoneyFormatter::parseToMinor('1000SATS1.00000000', Currency::fromCode('1000SATS'), 'en_US', strict: true)) + ->toBe('100000000'); +}); + +// Both signs and both placements: en_US writes the minus before the code, which is neither end of the +// string, so a negative amount in such a currency did not read back at all — in either mode. +it('parses back what it writes for a code ICU cannot carry, either sign', function (string $locale, int $amount, bool $strict): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + $sats = Currency::fromCode('1000SATS'); + $formatted = MoneyFormatter::formatFromMinor($amount, $sats, $locale); + + expect(MoneyFormatter::parseToMinor($formatted, $sats, $locale, strict: $strict))->toBe((string) $amount); +})->with([ + 'code in front, strict' => ['en_US', 123456789, true], + 'code in front, negative' => ['en_US', -123456789, true], + 'code behind, negative' => ['sv_SE', -123456789, true], + 'dot grouping, negative' => ['de_DE', -123456789, true], + 'lenient, negative' => ['en_US', -123456789, false], +]); + +// Lenient parsing is where the space a keyboard produces and a code written on the wrong side are +// forgiven, since that is the difference between the two modes. +it('forgives the placement of a code ICU cannot carry when lenient', function (string $input): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + expect(MoneyFormatter::parseToMinor($input, Currency::fromCode('1000SATS'), 'en_US'))->toBe('100000000'); +})->with([ + 'a plain space' => ['1000SATS 1.00000000'], + 'the suffix' => ['1.00000000 1000SATS'], +]); + +// The same rules where the locale writes the code behind the number and the minus in front of it, so +// neither end of the string is where the code goes: reading it from the end it happens to be at +// would accept "1000SATS 1,00" in a locale that writes "1,00 1000SATS". +it('refuses a code ICU cannot carry out of its place in a locale that writes it behind', function (string $input): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + expect(fn (): string => MoneyFormatter::parseToMinor($input, Currency::fromCode('1000SATS'), 'de_DE', strict: true)) + ->toThrow(ParserException::class); +})->with([ + 'prefix where the locale writes a suffix' => ['1000SATS 1,00000000'], + 'the same with the minus of the locale' => ['-1000SATS 1,00000000'], +]); + +// ICU carries no symbol for a code it cannot carry, so the notations to look for beside the number +// are the code and nothing else — and nothing is not a notation a string can be read without. +it('refuses a string that is not a number for a code ICU cannot carry', function (string $input): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + expect(fn (): string => MoneyFormatter::parseToMinor($input, Currency::fromCode('1000SATS'), 'en_US')) + ->toThrow(ParserException::class); +})->with([ + 'a word' => ['not a number'], + 'another code' => ['AUCTION 1.00000000'], + 'the code alone' => ['1000SATS'], +]); diff --git a/tests/Unit/MoneyFormatter/ParseToMinorTest.php b/tests/Unit/MoneyFormatter/ParseToMinorTest.php index 1a13132..f177c07 100644 --- a/tests/Unit/MoneyFormatter/ParseToMinorTest.php +++ b/tests/Unit/MoneyFormatter/ParseToMinorTest.php @@ -548,15 +548,29 @@ function localizedNumber(string $template, string $locale, string $currency): st ->toThrow(UnsupportedCurrency::class); }); -// A code carries the minor unit of a currency ICU knows nothing about, where a Money currency does not. -it('reads a currency outside ISO 4217 from its code', function (): void { +// The minor unit of a currency ICU knows nothing about comes from the registry, which is reached by +// the code — so every way of naming the same currency reads the same amount. A Money currency used +// to be read at two decimals here, which is the same amount a factor of a million out. +it('reads a currency outside ISO 4217 the same however it is named', function (string $shape): void { config([ 'larapara.load_crypto_currencies' => true, 'larapara.available_currencies' => ['USD', 'BTC'], ]); - expect(MoneyFormatter::parseToMoney('1.00000000', 'BTC', 'en_US')?->getAmount())->toBe('100000000') - ->and(MoneyFormatter::parseToMoney('1.00000000', new MoneyCurrency('BTC'), 'en_US')?->getAmount())->toBe('100'); + // Built here rather than in the dataset, which is resolved before the currency is available. + $currency = match ($shape) { + 'a code' => 'BTC', + 'a currency' => Currency::fromCode('BTC'), + 'a money currency' => new MoneyCurrency('BTC'), + }; + + expect(MoneyFormatter::parseToMoney('1.00000000', $currency, 'en_US')?->getAmount())->toBe('100000000'); +})->with(['a code', 'a currency', 'a money currency']); + +// Nothing knows the minor unit of a code no currency list has, so two decimals is the only guess +// left — and guessing is better than refusing an amount the caller built a Money for deliberately. +it('reads a currency no list knows at two decimals', function (): void { + expect(MoneyFormatter::parseToMoney('1.00', new MoneyCurrency('XBT'), 'en_US')?->getAmount())->toBe('100'); }); it('reads nothing as nothing', function (?string $value): void { @@ -571,3 +585,36 @@ function localizedNumber(string $template, string $locale, string $currency): st ->and(fn (): ?Money => MoneyFormatter::parseToMoney('1.5', 'SEK', 'sv_SE', strict: true)) ->toThrow(ParserException::class); }); + +// ICU can neither write nor read a code longer than three characters, so the notation the formatter +// writes for one — the code beside the number — is read here rather than by ICU. Strict mode accepts +// that notation, since it is what this configuration writes, and nothing else: the placement and the +// separator ICU chose are the whole of what strict means for a code ICU has no reading of. +it('accepts only the notation it writes for a code ICU cannot carry', function (string $template, bool $accepted): void { + config([ + 'larapara.load_crypto_currencies' => true, + 'larapara.available_currencies' => ['USD', '1000SATS'], + ]); + + $currency = Currency::fromCode('1000SATS'); + $formatted = MoneyFormatter::formatFromMinor(100000000, $currency, 'en_US'); + + // The separator ICU put between the code and the number, whichever space it chose. + $separator = str_replace(['1000SATS', '1.00000000'], '', $formatted); + $input = strtr($template, ['CODE' => '1000SATS', 'NUM' => '1.00000000', '_' => $separator]); + + $parse = fn (): string => MoneyFormatter::parseToMinor($input, $currency, 'en_US', strict: true); + + $accepted + ? expect($parse())->toBe('100000000') + : expect($parse)->toThrow(ParserException::class); + + // Lenient parsing takes every one of them: a person filling in a form is not a formatter. + expect(MoneyFormatter::parseToMinor($input, $currency, 'en_US', strict: false))->toBe('100000000'); +})->with([ + 'as the formatter writes it' => ['CODE_NUM', true], + 'the code last' => ['NUM_CODE', false], + 'a plain space' => ['CODE NUM', false], + 'the code last, plain space' => ['NUM CODE', false], + 'no separator at all' => ['NUMCODE', false], +]); diff --git a/tests/Unit/MoneyFormatterTest.php b/tests/Unit/MoneyFormatterTest.php index bf4301c..5824af7 100644 --- a/tests/Unit/MoneyFormatterTest.php +++ b/tests/Unit/MoneyFormatterTest.php @@ -347,6 +347,26 @@ function provideDecimalDataUsd(): array ->toEqual(MoneyFormatter::getFormattingRules(Locale::getDefault(), $currency)); }); +// Formatters are kept under the locale they were built for, so an empty locale has to be resolved +// before it becomes that key: otherwise the first call freezes whatever the default was then, which +// a long-running process is free to change between calls. +it('follows the default locale when it changes, for an empty locale', function (): void { + $currency = Currency::fromCode('USD'); + $default = Locale::getDefault(); + + try { + Locale::setDefault('en_US'); + expect(MoneyFormatter::formatFromMinor(123456, $currency, '')) + ->toBe(MoneyFormatter::formatFromMinor(123456, $currency, 'en_US')); + + Locale::setDefault('sv_SE'); + expect(MoneyFormatter::formatFromMinor(123456, $currency, '')) + ->toBe(MoneyFormatter::formatFromMinor(123456, $currency, 'sv_SE')); + } finally { + Locale::setDefault($default); + } +}); + // ICU locale keywords only accept 3 character currency codes, so longer ones fall back to the // currency of the locale's region unless we short circuit them. it('gets the formatting rules of a currency ICU does not know', function (): void { @@ -425,6 +445,16 @@ function provideDecimalDataUsd(): array 'a localized one' => ['1.234,56'], ]); +// A value that is not a number is usually input that was never validated, and the message it lands in +// goes to the log, so the message names the type it was given rather than repeating the value. +it('keeps the rejected value out of the exception message', function (): void { + $value = 'not-a-number'; + + expect(InvalidNumber::notNumeric($value)->getMessage()) + ->not->toContain($value) + ->toContain('string'); +}); + it('formats nothing as nothing', function (mixed $value): void { expect(MoneyFormatter::formatNumber($value, 'en_US'))->toBe(''); })->with([ @@ -461,6 +491,22 @@ function provideDecimalDataUsd(): array 'more than three' => [1234.56789, 5, '1,234.56789'], ]); +// ICU stops at three fraction digits of its own accord, so the decimals of the value were kept only +// as long as it had no more than three of them: nothing asked for 1234.5678 to be written as +// 1,234.568, or for a tenth of a cent to be written as 0. +it('keeps the decimals of the number past the third', function (mixed $value, string $expectedOutput): void { + expect(MoneyFormatter::formatNumber($value, 'en_US'))->toBe($expectedOutput); +})->with([ + 'four decimals' => [1234.5678, '1,234.5678'], + 'four decimals as text' => ['1234.5678', '1,234.5678'], + 'eight, a crypto amount' => [1.23456789, '1.23456789'], + 'below the third' => [0.00001234, '0.00001234'], + // The noise of the binary representation is absorbed rather than written out: 1.005 is held as + // 1.00499999999999989, and a tenth plus two tenths as 0.30000000000000004. + 'a value a double rounds' => [1.005, '1.005'], + 'a sum a double rounds' => [0.1 + 0.2, '0.3'], +]); + // A Money carries its own currency, and its amount is the minor units of that currency — the two // entry points are the same amount either way round. it('formats a Money object and its amount alike', function (): void { @@ -481,3 +527,37 @@ function provideDecimalDataUsd(): array expect(MoneyFormatter::formatShort($yen, 'en_US'))->toBe('¥123.46M'); }); + +// A double carries fifteen significant decimal digits and ICU takes a double, so a value written with +// more of them was rendered as a different number with nothing said about it: '9007199254740993' came +// out as 9,007,199,254,740,992. Refused rather than deformed, as everything else here is. +it('refuses a number a double cannot carry digit for digit', function (mixed $value): void { + expect(fn (): string => MoneyFormatter::formatNumber($value, 'en_US'))->toThrow(InvalidNumber::class); +})->with([ + 'past what a double holds' => ['9007199254740993'], + 'the same as an int' => [9007199254740993], + 'seventeen decimals' => ['0.12345678901234567'], + 'nineteen digits' => ['1234567890123456789'], +]); + +// The value rather than the count of its digits: a sixteen-digit number below 2**53 is carried +// exactly, and a float is a double already, so nothing was lost on its way in to refuse it for. +it('formats a number a double carries digit for digit', function (mixed $value, string $expectedOutput): void { + expect(MoneyFormatter::formatNumber($value, 'en_US'))->toBe($expectedOutput); +})->with([ + 'fifteen digits' => ['999999999999999', '999,999,999,999,999'], + 'sixteen, still exact' => ['1234567890123456', '1,234,567,890,123,456'], + 'zeros past the range' => ['1000000000000000000', '1,000,000,000,000,000,000'], + 'a float past the range' => [1.0e20, '100,000,000,000,000,000,000'], + 'exponent notation' => ['1.5e3', '1,500'], +]); + +// A numeric string carries a known number of decimals, so all of them are kept — the fourteen places +// that absorb the noise of a binary representation are what a float gets, which is what needs them. +it('keeps every decimal a numeric string carries', function (mixed $value, string $expectedOutput): void { + expect(MoneyFormatter::formatNumber($value, 'en_US'))->toBe($expectedOutput); +})->with([ + 'fifteen decimals' => ['0.000000000000001', '0.000000000000001'], + 'seventeen places' => ['0.00000000000000123', '0.00000000000000123'], + 'a float, absorbed' => [0.1 + 0.2, '0.3'], +]); diff --git a/tests/Unit/Rules/ValidationRulesTest.php b/tests/Unit/Rules/ValidationRulesTest.php index 4fe57e1..c50de32 100644 --- a/tests/Unit/Rules/ValidationRulesTest.php +++ b/tests/Unit/Rules/ValidationRulesTest.php @@ -110,6 +110,20 @@ function localizedAmount(mixed $value, string $locale, string $currency): mixed ->and(validateValue('nonsense', new MoneyString('GBP', 'en_US'))->passes())->toBeFalse(); }); +// The idiomatic call passes a request value straight in, and a client is free to send an array or a +// number there, so the constructor takes whatever arrives: the amount is judged on the default +// currency rather than the request 500ing before validation has run. +it('takes a currency of any shape a request can carry', function (mixed $currency): void { + expect(validateValue('1234.56', new MoneyString($currency, 'en_US'))->passes())->toBeTrue() + ->and(validateValue('nonsense', new MoneyString($currency, 'en_US'))->passes())->toBeFalse(); +})->with([ + 'an array' => [['USD']], + 'a nested one' => [['code' => 'USD']], + 'a number' => [840], + 'a boolean' => [true], + 'null' => [null], +]); + it('shows the shape it expects in the message', function (string $locale, string $currency, string $expectedExample): void { $validator = validateValue('nonsense', new MoneyString($currency, $locale));