Money — Immutable Monetary Value Object¶
Nene\Func\Money is an immutable value object for monetary amounts. It stores
amounts as integers in the smallest currency unit (e.g. ¥1,000 = 1000,
$10.00 = 1000 cents) to avoid floating-point rounding errors in financial
calculations.
Why integer storage?¶
Floating-point numbers (float, double) cannot represent most decimal
fractions exactly. The classic example:
In financial code this causes invisible rounding errors. A 10% tax on ¥999 calculated with floats may produce ¥99.89999… which rounds incorrectly.
By storing amounts as plain int (smallest unit), all arithmetic uses exact
integer operations. The only rounding decision is explicit, at the moment you
call round().
API reference¶
Constructors¶
| Method | Description |
|---|---|
Money::of(int $amount, string $currency = 'JPY'): self |
Named constructor. $amount in smallest unit. |
Money::zero(string $currency = 'JPY'): self |
Zero-amount money in the given currency. |
Accessors¶
| Method | Return type | Description |
|---|---|---|
amount() |
int |
Raw integer amount. |
currency() |
string |
ISO 4217 currency code. |
Arithmetic (return new instances — original is never mutated)¶
| Method | Description |
|---|---|
add(Money $other): self |
Add. Throws on currency mismatch. |
subtract(Money $other): self |
Subtract. Throws on currency mismatch. |
multiply(float\|int $factor): self |
Multiply by scalar; truncates to int. |
round(int $mode = PHP_ROUND_HALF_UP): self |
Round the current amount (chain after multiply()). |
abs(): self |
Absolute value. |
negate(): self |
Flip sign. |
Predicates¶
| Method | Return | Description |
|---|---|---|
isZero(): bool |
— | True when amount === 0. |
isPositive(): bool |
— | True when amount > 0. |
isNegative(): bool |
— | True when amount < 0. |
equals(Money $other): bool |
— | True when both amount AND currency match. |
compareTo(Money $other): int |
-1 / 0 / 1 | Spaceship comparison. Throws on currency mismatch. |
Serialization / display¶
| Method | Return | Description |
|---|---|---|
toArray(): array{amount: int, currency: string} |
array | Suitable for JSON encoding. |
format(): string |
— | Human-readable string (see below). |
Common patterns¶
Price + tax¶
$price = Money::of(1000, 'JPY'); // ¥1,000
$tax = $price->multiply(0.1)->round(); // ¥100 (not 99 — round() after multiply())
$total = $price->add($tax); // ¥1,100
echo $total->format(); // ¥1,100
Discount calculation¶
$price = Money::of(5000, 'JPY');
$discount = $price->multiply(0.2)->round(); // ¥1,000 (20% off)
$final = $price->subtract($discount); // ¥4,000
Comparing prices¶
$a = Money::of(1200, 'JPY');
$b = Money::of(980, 'JPY');
if ($a->compareTo($b) > 0) {
echo 'a is more expensive';
}
Accumulating a total¶
Currency mismatch safety¶
add(), subtract(), and compareTo() throw \InvalidArgumentException when
the currencies differ. There is no implicit conversion — multi-currency
arithmetic must be handled explicitly by the application layer.
Currency conversion (out of scope)¶
Money does not perform currency conversion. Exchange rates are volatile and
belong in a dedicated service (e.g. ExchangeRateService). To convert, obtain
the rate externally and multiply:
$jpy = Money::of(1000, 'JPY');
$rate = 0.0067; // 1 JPY = 0.0067 USD (from external service)
$cents = (int) round($jpy->amount() * $rate * 100);
$usd = Money::of($cents, 'USD');
JSON serialization with toArray()¶
toArray() returns ['amount' => int, 'currency' => string], suitable for
json_encode() directly or as part of a larger response array.
To reconstruct from JSON:
format() behaviour per currency¶
format() requires no intl extension. It uses a built-in symbol table:
| Currency | Storage unit | Example input | Output |
|---|---|---|---|
| JPY | yen (integer) | Money::of(1500, 'JPY') |
¥1,500 |
| KRW | won (integer) | Money::of(5000, 'KRW') |
₩5,000 |
| USD | cents | Money::of(150, 'USD') |
$1.50 |
| EUR | euro-cents | Money::of(1099, 'EUR') |
€10.99 |
| GBP | pence | Money::of(999, 'GBP') |
£9.99 |
| CNY | fen (1/100 yuan) | Money::of(3000, 'CNY') |
¥30.00 |
| Other | — | Money::of(100, 'XYZ') |
100 XYZ |
Note: USD, EUR, GBP, and CNY amounts are divided by 100 before formatting. Store 150 cents to represent $1.50, not 1.50.
Using in Mapper (store as INT in DB)¶
Store monetary values as INT (not DECIMAL) in your schema. Retrieve and
wrap in Money in the Mapper:
// Schema (SchemaCompiler yaml):
// price: {type: INT, nullable: false}
// Mapper
public function mapRow(array $row): Product
{
return new Product(
id: (int) $row['id'],
price: Money::of((int) $row['price'], 'JPY'),
);
}
// Insert
$stmt->bindValue(':price', $product->price()->amount(), PDO::PARAM_INT);
Related¶
- FT26 soft-delete (
docs/development/soft-delete.md) — refund/reversal workflows typically need to soft-delete an order line and insert a compensating Money entry withnegate(). class/func/Money.php— implementationtests/Unit/Func/MoneyTest.php— 28 unit tests