sugarcommerce / currency
Money formatting and parsing for integer minor units. 136 currencies with correct decimal exponents — including the twenty where dividing by 100 is wrong. Zero dependencies.
Requires
- php: >=8.5
- ext-mbstring: *
Requires (Dev)
- phpunit/phpunit: ^13.0
Suggests
- ext-intl: Locale-aware formatting via format_localized().
Provides
None
Conflicts
None
Replaces
None
README
Money formatting and parsing for integer minor units. 136 currencies with correct decimal exponents — including the twenty where dividing by 100 is wrong. Zero dependencies.
Why
Store money as an integer number of minor units, always. Floats cannot represent 0.10, and the error compounds through every total, tax line and refund until a report is out by a penny nobody can find.
That leaves one question the integer does not answer: how many minor units are in a major one? Almost every implementation assumes 100. It is 1 for yen, and 1000 for Bahraini dinar — so ¥1000 sent as 100000 charges a hundred times too much, and BD 1.500 sent as 150 charges a tenth.
Twenty of the 136 currencies here are not two-decimal. Getting that wrong is not a rounding bug, it is a two-orders-of-magnitude billing error.
Features
- 💰 Integer arithmetic throughout — no float ever touches an amount.
- 🔢 Correct exponents — 0 for JPY and the CFA francs, 3 for BHD, KWD, OMR, TND.
- 🏦 Stripe's special cases — ISK, HUF, TWD and UGX, where the API's expectation differs from the ISO exponent.
- 📉 Minimum and maximum charge amounts — per currency, so you can reject an uncharageable total before the gateway does.
- 🧮 Fair allocation — split an amount into parts with the remainder distributed, never lost.
- 🖊️ Forgiving parsing —
€1.234,56and$1,234.56both come back as123456. - 🌐 Locale formatting — via ext-intl when present, with a sensible fallback when not.
Requirements
PHP 8.5+ (ext-intl optional, for locale-aware formatting)
Installation
composer require sugarcommerce/currency
Usage
use SugarCommerce\Currency\Money; Money::format( 4999, 'USD' ); // '$49.99' Money::format_compact( 2900, 'GBP' ); // '£29' — for a price being advertised Money::format_localized_compact( 2900, 'EUR', 'de' ); // '29 €' — the same, in a reader's language Money::format( 1000, 'JPY' ); // '¥1,000' Money::format( 1500, 'BHD' ); // 'BD 1.500' Money::format_with_code( 4999, 'USD' ); // '49.99 USD' Money::decimal( 4999, 'USD' ); // '49.99' Money::input_value( 4999, 'USD' ); // '49.99' — no separators, for a form field
Parsing
Money::parse( '$1,234.56', 'USD' ); // 123456 Money::parse( '€1.234,56', 'EUR' ); // 123456 Money::parse( '1000', 'JPY' ); // 1000 Money::parse( 'nonsense', 'USD' ); // 0 Money::parse( '1.230', 'USD' ); // 123 — trailing zeros are fine Money::parse( '1.234', 'USD' ); // throws — finer than a cent Money::parse( '100.5', 'JPY' ); // throws — JPY has no minor unit
Throws InvalidArgumentException for an amount too large to hold in an integer — a twenty-digit figure typed into a price field — and for one with more decimal places than the currency has minor units. 1.234 is not a dollar amount, and truncating it to $1.23 (or rounding ¥100.5 to ¥101) would store a price nobody typed. Clamping or rounding silently would be worse.
Validating an amount
Money::is_valid_amount( 150, 'JPY' ); // false — JPY has no minor unit Money::round_to_valid( 150, 'JPY' ); // 150 → nearest representable Money::is_chargeable( 20, 'USD' ); // false — under Stripe's minimum Money::why_not_chargeable( 20, 'USD' ); // a sentence you can show the buyer
Splitting
Money::percentage( 4999, 20.0 ); // 1000 — VAT, rounded once Money::allocate( 1000, 3 ); // [334, 333, 333] — remainder distributed, total preserved
allocate() is the one to reach for when splitting a discount across line items or a payout across parties. Dividing and rounding each part independently loses or invents money.
Metadata
use SugarCommerce\Currency\Currencies; Currencies::decimals( 'BHD' ); // 3 Currencies::symbol( 'GBP' ); // '£' Currencies::name( 'JPY' ); // 'Japanese Yen' Currencies::is_zero_decimal( 'KRW' ); // true Currencies::for_country( 'CH' ); // ['CHF'] Currencies::options(); // code => label, for a select
The currencies that are not two-decimal
| Exponent | Currencies |
|---|---|
| 0 | BIF, CLP, DJF, GNF, JPY, KMF, KRW, MGA, PYG, RWF, UGX, VND, VUV, XAF, XOF, XPF |
| 3 | BHD, IQD, JOD, KWD, LYD, OMR, TND |
Four more are worth knowing about because Stripe treats them differently from ISO 4217: ISK and HUF are zero-decimal at ISO but must be sent as multiples of 100; TWD likewise; UGX is zero-decimal but historically was sent otherwise. The tables here follow what the API actually expects.
Security
Currency codes arrive from query strings more often than people expect — ?currency= on a headless checkout. An unrecognised code is never echoed back into formatted output; see SECURITY.md.
Testing
composer install
composer test
110 tests, 895 assertions — every exponent verified against ISO 4217 and Stripe's published tables, plus allocation invariants (the parts always sum to the whole) and the hostile-input cases, and localised advertised prices in four locales.
License
GPL-2.0-or-later