Skip to content

Commit d2acc4c

Browse files
committed
Add MoneyCast built-in cast (closes #4)
Casts to/from a minor-units Money value object instead of floats. Decimal-string parsing is done without float arithmetic so equally-exact half-cent amounts (e.g. "1.005" and "2.005") round deterministically instead of drifting apart depending on binary float representation.
1 parent 2264b04 commit d2acc4c

8 files changed

Lines changed: 531 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,37 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.
88
## [Unreleased]
99

1010
### Added
11-
- `LowercaseValuePipe` and `UppercaseValuePipe` — case normalization for
12-
`#[Pipe]`-attributed properties (e.g. emails, currency/country codes).
13-
Non-string values pass through untouched, matching `TrimValuePipe`'s contract.
14-
- `UuidCast` — validates RFC 4122 UUID strings on hydration and normalizes
15-
to lowercase on both hydration and serialization. Invalid input throws
16-
`InvalidArgumentException`.
11+
- `MoneyCast` and `ValueObjects\Money` — a small immutable money value
12+
object (minor units + currency) instead of floats. Accepts int minor
13+
units, a decimal string, an `['amount' => ..., 'currency' => ...]` array,
14+
or an existing `Money` on hydration; serializes back to int minor units.
15+
Currency is fixed per field via the cast constructor and validated on
16+
both directions; raw `float` input is rejected. Decimal-string parsing
17+
is done without float arithmetic, so equally-precise half-cent amounts
18+
round consistently (e.g. `"1.005"` and `"2.005"` no longer drift apart
19+
depending on binary float representation).
20+
21+
## [1.11.0] — 2026-07-15
22+
23+
### Added
1724
- `CommaSeparatedCast` — splits a delimited string into an array on
1825
hydration and joins it back on serialization (`separator` and `trim`
1926
are configurable; default `,` and `true`).
2027

28+
## [1.10.0] — 2026-07-15
29+
30+
### Added
31+
- `UuidCast` — validates RFC 4122 UUID strings on hydration and normalizes
32+
to lowercase on both hydration and serialization. Invalid input throws
33+
`InvalidArgumentException`.
34+
35+
## [1.9.0] — 2026-07-15
36+
37+
### Added
38+
- `LowercaseValuePipe` and `UppercaseValuePipe` — case normalization for
39+
`#[Pipe]`-attributed properties (e.g. emails, currency/country codes).
40+
Non-string values pass through untouched, matching `TrimValuePipe`'s contract.
41+
2142
### Changed
2243
- Documentation site: custom VitePress theme with breadcrumb navigation.
2344

‎docs/.vitepress/config.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export default defineConfig({
105105
{ text: 'EncryptedCast', link: '/casts/encrypted' },
106106
{ text: 'UuidCast', link: '/casts/uuid' },
107107
{ text: 'CommaSeparatedCast', link: '/casts/comma-separated' },
108+
{ text: 'MoneyCast', link: '/casts/money' },
108109
{ text: 'Custom Casts', link: '/casts/custom' },
109110
],
110111
},

‎docs/casts/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Casts implement `CastsValue` with two methods:
1919
| [`EncryptedCast`](./encrypted.md) | plaintext `string` | encrypted ciphertext |
2020
| [`UuidCast`](./uuid.md) | validated, lowercase `string` | lowercase `string` |
2121
| [`CommaSeparatedCast`](./comma-separated.md) | `array` | delimited `string` |
22+
| [`MoneyCast`](./money.md) | `Money` (minor units) | `int` minor units |
2223

2324
## Applying Casts
2425

‎docs/casts/money.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# MoneyCast
2+
3+
Casts to/from `StdOut\SimpleDataObjects\ValueObjects\Money`, an immutable value object storing the amount in **minor units** (integer cents) instead of a float — floats can't represent decimal currency amounts exactly, which silently corrupts money math over time.
4+
5+
The core package stays dependency-light, so `Money` is a small value object shipped in the package rather than an integration with a library like `moneyphp/money`. It covers amount + currency, `toDecimal()`, `equals()`, and currency-checked `add()`/`subtract()` — reach for a dedicated money library if you need more (allocation, exchange rates, ISO 4217-aware decimal places, ...).
6+
7+
## Usage
8+
9+
```php
10+
use StdOut\SimpleDataObjects\Attributes\Cast;
11+
use StdOut\SimpleDataObjects\Casts\MoneyCast;
12+
use StdOut\SimpleDataObjects\ValueObjects\Money;
13+
14+
class OrderData extends BaseData
15+
{
16+
public function __construct(
17+
#[Cast(new MoneyCast(currency: 'USD'))]
18+
public readonly Money $price,
19+
) {}
20+
}
21+
22+
$order = OrderData::from(['price' => '10.99']);
23+
24+
$order->price->amount; // 1099 (cents)
25+
$order->price->currency; // 'USD'
26+
$order->price->toDecimal(); // '10.99'
27+
28+
$order->toArray()['price']; // 1099 — round-trips through from()
29+
```
30+
31+
The currency is fixed per field via the constructor, so it isn't repeated in the serialized payload — only the minor-units integer round-trips through `toArray()`/`from()`.
32+
33+
## Accepted Input on Hydration
34+
35+
- **Int minor units** — `1099` → `$1099` treated as already-scaled cents, used as-is.
36+
- **Decimal string** — `'10.99'` → `1099` cents (parsed and rounded using `decimals`, 2 by default).
37+
- **Array** — `['amount' => 1099, 'currency' => 'USD']`. If `currency` is omitted it defaults to the field's configured currency; if present it must match, or an `InvalidArgumentException` is thrown.
38+
- **`Money` instance** — passed through as-is (currency must match).
39+
40+
Raw `float` input is rejected (`InvalidArgumentException`) — that's the whole point of the cast.
41+
42+
## Custom Decimal Places
43+
44+
```php
45+
#[Cast(new MoneyCast(currency: 'JPY', decimals: 0))]
46+
public readonly Money $total,
47+
```
48+
49+
`decimals` controls how decimal-string input is scaled into minor units; it has no bearing on stored data (`Money::$amount` is always a plain int).
50+
51+
## Currency Mismatches
52+
53+
Both directions validate the currency against the cast's configured value:
54+
55+
```php
56+
OrderData::from(['price' => ['amount' => 1099, 'currency' => 'EUR']]);
57+
// throws InvalidArgumentException — field is configured for USD
58+
```
59+
60+
## Null Handling
61+
62+
Returns `null` for `null` input on both hydration and serialization.

‎src/Casts/MoneyCast.php‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace StdOut\SimpleDataObjects\Casts;
6+
7+
use InvalidArgumentException;
8+
use StdOut\SimpleDataObjects\Contracts\CastsValue;
9+
use StdOut\SimpleDataObjects\ValueObjects\Money;
10+
11+
/**
12+
* Casts to/from `Money`, storing the amount in minor units (integer cents)
13+
* to avoid float rounding error. Accepts int minor units, a numeric decimal
14+
* string, or an `['amount' => ..., 'currency' => ...]` array on hydration;
15+
* serializes back to int minor units — the currency is fixed per field via
16+
* the constructor, so it isn't repeated in the serialized payload.
17+
*/
18+
final class MoneyCast implements CastsValue
19+
{
20+
public function __construct(
21+
private readonly string $currency,
22+
private readonly int $decimals = 2,
23+
) {}
24+
25+
public static function __set_state(array $state): self
26+
{
27+
return new self($state['currency'], $state['decimals']);
28+
}
29+
30+
public function get(mixed $value): ?Money
31+
{
32+
if ($value === null) {
33+
return null;
34+
}
35+
36+
if ($value instanceof Money) {
37+
$this->assertCurrency($value->currency);
38+
39+
return $value;
40+
}
41+
42+
if (is_array($value)) {
43+
$currency = $value['currency'] ?? $this->currency;
44+
$this->assertCurrency($currency);
45+
46+
return new Money($this->resolveAmount($value['amount']), $currency);
47+
}
48+
49+
return new Money($this->resolveAmount($value), $this->currency);
50+
}
51+
52+
public function set(mixed $value): ?int
53+
{
54+
if ($value === null) {
55+
return null;
56+
}
57+
58+
$this->assertCurrency($value->currency);
59+
60+
return $value->amount;
61+
}
62+
63+
private function resolveAmount(mixed $amount): int
64+
{
65+
if (is_int($amount)) {
66+
return $amount;
67+
}
68+
69+
if (is_string($amount)) {
70+
return $this->parseDecimalString($amount);
71+
}
72+
73+
throw new InvalidArgumentException(
74+
'MoneyCast amount must be an int (minor units) or a numeric decimal string, got '.get_debug_type($amount).'.',
75+
);
76+
}
77+
78+
/**
79+
* Parses without ever going through float, so amounts like "1.005"
80+
* round deterministically instead of depending on binary float
81+
* representation (float gives 100 for "1.005" but 201 for "2.005",
82+
* despite both being an equally "exact" half-cent).
83+
*/
84+
private function parseDecimalString(string $amount): int
85+
{
86+
$amount = trim($amount);
87+
88+
if (! preg_match('/^[+-]?(?:\d+\.?\d*|\.\d+)$/', $amount)) {
89+
throw new InvalidArgumentException("\"{$amount}\" is not a valid decimal amount.");
90+
}
91+
92+
$negative = $amount[0] === '-';
93+
$amount = ltrim($amount, '+-');
94+
95+
[$whole, $fraction] = array_pad(explode('.', $amount, 2), 2, '');
96+
97+
if (strlen($fraction) > $this->decimals) {
98+
$roundUp = $fraction[$this->decimals] >= '5';
99+
$fraction = substr($fraction, 0, $this->decimals);
100+
} else {
101+
$roundUp = false;
102+
$fraction = str_pad($fraction, $this->decimals, '0');
103+
}
104+
105+
$minorUnits = (int) ($whole.$fraction) + ($roundUp ? 1 : 0);
106+
107+
return $negative ? -$minorUnits : $minorUnits;
108+
}
109+
110+
private function assertCurrency(string $currency): void
111+
{
112+
if ($currency !== $this->currency) {
113+
throw new InvalidArgumentException(
114+
"Expected currency \"{$this->currency}\", got \"{$currency}\".",
115+
);
116+
}
117+
}
118+
}

‎src/ValueObjects/Money.php‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace StdOut\SimpleDataObjects\ValueObjects;
6+
7+
use InvalidArgumentException;
8+
use Stringable;
9+
10+
/**
11+
* A minimal immutable money value, storing the amount in minor units
12+
* (e.g. cents) to avoid float rounding error. Assumes a fixed number of
13+
* decimal places per operation (2 by default) rather than an ISO 4217
14+
* currency-decimals table — for currencies that don't use 2 decimal
15+
* places (JPY, BHD, ...), pass `$decimals` explicitly to `toDecimal()`.
16+
*/
17+
final class Money implements Stringable
18+
{
19+
public function __construct(
20+
public readonly int $amount,
21+
public readonly string $currency,
22+
) {}
23+
24+
public function toDecimal(int $decimals = 2): string
25+
{
26+
return number_format($this->amount / (10 ** $decimals), $decimals, '.', '');
27+
}
28+
29+
public function equals(self $other): bool
30+
{
31+
return $this->amount === $other->amount && $this->currency === $other->currency;
32+
}
33+
34+
public function add(self $other): self
35+
{
36+
$this->assertSameCurrency($other);
37+
38+
return new self($this->amount + $other->amount, $this->currency);
39+
}
40+
41+
public function subtract(self $other): self
42+
{
43+
$this->assertSameCurrency($other);
44+
45+
return new self($this->amount - $other->amount, $this->currency);
46+
}
47+
48+
public function __toString(): string
49+
{
50+
return "{$this->toDecimal()} {$this->currency}";
51+
}
52+
53+
private function assertSameCurrency(self $other): void
54+
{
55+
if ($this->currency !== $other->currency) {
56+
throw new InvalidArgumentException(
57+
"Cannot combine {$this->currency} with {$other->currency}.",
58+
);
59+
}
60+
}
61+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace StdOut\SimpleDataObjects\Tests\Fixtures;
6+
7+
use StdOut\SimpleDataObjects\Attributes\Cast;
8+
use StdOut\SimpleDataObjects\BaseData;
9+
use StdOut\SimpleDataObjects\Casts\MoneyCast;
10+
use StdOut\SimpleDataObjects\ValueObjects\Money;
11+
12+
class OrderWithMoneyData extends BaseData
13+
{
14+
public function __construct(
15+
#[Cast(new MoneyCast(currency: 'USD'))]
16+
public readonly Money $price,
17+
) {}
18+
}

0 commit comments

Comments
 (0)