Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@
"description": "Monetary extension for Axiom - provides schema types, parsers, and operators for monetary values with strong type safety and currency validation",
"type": "library",
"license": "MIT",
"keywords": ["money", "currency", "schema", "validation", "monetary", "interval"],
"keywords": [
"money",
"currency",
"schema",
"validation",
"monetary",
"interval"
],
"homepage": "https://github.com/gosuperscript/axiom-money",
"require": {
"php": "^8.4",
"ext-intl": "*",
"brick/money": "^0.10.1",
"gosuperscript/axiom": "^0.6.0",
"superscript/interval": "^1.0",
"superscript/monetary-interval": "^0.1.2"
"brick/money": "^0.14.1",
"gosuperscript/axiom": "^0.6.9",
"superscript/interval": "^2.0",
"superscript/monetary-interval": "^0.2.0"
},
"require-dev": {
"infection/infection": "^0.29.14",
Expand Down
12 changes: 7 additions & 5 deletions src/MoneyExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Brick\Money\Currency;
use Brick\Money\Money;
use Superscript\Axiom\Extension;
use Superscript\Axiom\Money\MoneyParser;
use Superscript\Axiom\Money\Types\MonetaryIntervalType;
use Superscript\Axiom\Money\Types\MonetaryType;
use Superscript\Axiom\Operators\Operator;
Expand Down Expand Up @@ -51,7 +52,7 @@ final class MoneyExtension extends Extension
*/
public function __construct(
private readonly array $currencies,
private readonly RoundingMode $roundingMode = RoundingMode::HALF_UP,
private readonly RoundingMode $roundingMode = RoundingMode::HalfUp,
) {}

public function operators(): array
Expand All @@ -78,7 +79,7 @@ public function operators(): array
->evaluatesWith(fn(int|float $left, Money $right) => $this->scale($right, $left));
$rules[] = Operator::infix('/')->takes($money, $number)->returns($money)
->evaluatesWith(fn(Money $left, int|float $right) => attempt(
fn() => $left->toRational()->dividedBy($right)->to(new DefaultContext(), $this->roundingMode),
fn() => $left->toRational()->dividedBy(MoneyParser::exact($right))->toContext(new DefaultContext(), $this->roundingMode),
));

// Ordering: same currency.
Expand All @@ -94,11 +95,11 @@ public function operators(): array
// Equality: Brick's amount-and-currency comparison, not identity.
foreach (['=', '==', '==='] as $operator) {
$rules[] = Operator::infix($operator)->takes($money, $money)->returns($boolean)
->evaluatesWith(fn(Money $left, Money $right) => $left->isAmountAndCurrencyEqualTo($right));
->evaluatesWith(fn(Money $left, Money $right) => $left->isSameValueAs($right));
}
foreach (['!=', '!=='] as $operator) {
$rules[] = Operator::infix($operator)->takes($money, $money)->returns($boolean)
->evaluatesWith(fn(Money $left, Money $right) => !$left->isAmountAndCurrencyEqualTo($right));
->evaluatesWith(fn(Money $left, Money $right) => !$left->isSameValueAs($right));
}

// A monetary interval compared against a money of its currency.
Expand Down Expand Up @@ -138,6 +139,7 @@ public function literals(): array

private function scale(Money $money, int|float $scalar): Money
{
return $money->toRational()->multipliedBy($scalar)->to(new DefaultContext(), $this->roundingMode);
return $money->toRational()->multipliedBy(MoneyParser::exact($scalar))->toContext(new DefaultContext(), $this->roundingMode);
}

}
33 changes: 32 additions & 1 deletion src/MoneyParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public static function parse(mixed $money): Result

if ($amount !== false) {
try {
return Ok(Money::of($amount, non_empty_string()->assert($currency)));
return Ok(Money::of(self::exact($amount), non_empty_string()->assert($currency)));
} catch (RoundingNecessaryException $exception) {
return Err(new InvalidArgumentException(sprintf('Could not parse [%s] as money', new Exporter()->shortenedExport($money)), previous: $exception));
}
Expand All @@ -58,4 +58,35 @@ public static function parse(mixed $money): Result
message: sprintf('Could not parse [%s] as money', new Exporter()->shortenedExport($money)),
));
}

/**
* Renders a raw scalar amount in the form brick accepts.
*
* brick rejects floats throughout its monetary and arithmetic APIs, because
* a binary float carries no exact decimal value to compute with: the literal
* 0.1 is not one tenth. A float that reaches here is therefore spelled as
* the shortest decimal that round-trips back to it, so brick receives the
* number the caller actually holds.
*
* That spelling is `json_encode`'s, because it honours `serialize_precision`
* (-1). A `(string)` cast honours `precision` instead — 14 significant
* digits by default — so it truncates and can emit exponent notation:
* `(string) 123456789012345.67` gives `1.2345678901235E+14`, a different
* amount. Ints and numeric strings are already exact and pass through
* untouched.
*
* @throws InvalidArgumentException if the float is not finite, and so names no decimal at all
*/
public static function exact(string|float|int $amount): string|int
{
if (! is_float($amount)) {
return $amount;
}

if (! is_finite($amount)) {
throw new InvalidArgumentException(sprintf('[%s] is not a monetary amount.', $amount));
}

return json_encode($amount, JSON_THROW_ON_ERROR);
}
}
8 changes: 5 additions & 3 deletions src/Types/DynamicMonetaryType.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Superscript\Axiom\Types\Shapes\Shape;
use Superscript\Axiom\Types\Type;

use function Psl\Type\instance_of;
use function Psl\Type\non_empty_string;
use function Superscript\Monads\Option\Some;
use function Superscript\Monads\Result\Err;
Expand All @@ -26,7 +27,7 @@
*/
final readonly class DynamicMonetaryType implements Type
{
public function __construct(public RoundingMode $roundingMode = RoundingMode::HALF_UP) {}
public function __construct(public RoundingMode $roundingMode = RoundingMode::HalfUp) {}

/**
* @return Result<Option<Money>, TransformValueException>
Expand All @@ -46,7 +47,7 @@ public function assert(mixed $value): Result
public function coerce(mixed $value): Result
{
if ($value instanceof RationalMoney) {
return Ok(Some($value->to(new DefaultContext(), $this->roundingMode)));
return Ok(Some($value->toContext(new DefaultContext(), $this->roundingMode)));
}

return MoneyParser::parse($value)->map(fn(Money $money) => Some($money))
Expand All @@ -55,8 +56,9 @@ public function coerce(mixed $value): Result

public function format(mixed $value): string
{
$money = instance_of(Money::class)->assert($value);
$formatter = new \NumberFormatter('en_GB', \NumberFormatter::CURRENCY);
$result = $formatter->formatCurrency($value->getAmount()->toFloat(), $value->getCurrency()->getCurrencyCode());
$result = $formatter->formatCurrency($money->getAmount()->toFloat(), $money->getCurrency()->getCurrencyCode());
return non_empty_string()->assert($result);
}

Expand Down
17 changes: 10 additions & 7 deletions src/Types/MinorMonetaryType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
use Superscript\Monads\Option\Option;
use Superscript\Monads\Result\Result;
use Superscript\Axiom\Exceptions\TransformValueException;
use Superscript\Axiom\Money\MoneyParser;
use Superscript\Axiom\Types\Shapes\LiteralShape;
use Superscript\Axiom\Types\Shapes\OpaqueShape;
use Superscript\Axiom\Types\Shapes\Shape;
use Superscript\Axiom\Types\Type;

use function Psl\Type\float;
use function Psl\Type\int;
use function Psl\Type\instance_of;
use function Psl\Type\non_empty_string;
use function Psl\Type\string;
use function Psl\Type\union;
Expand All @@ -33,7 +35,7 @@
*/
final readonly class MinorMonetaryType implements Type
{
public function __construct(public Currency $currency, public RoundingMode $roundingMode = RoundingMode::HALF_UP) {}
public function __construct(public Currency $currency, public RoundingMode $roundingMode = RoundingMode::HalfUp) {}

/**
* @return Result<Option<Money>, TransformValueException>
Expand All @@ -44,7 +46,7 @@ public function assert(mixed $value): Result
return Err(new TransformValueException(type: 'money', value: $value));
}

if (!$value->getCurrency()->is($this->currency)) {
if (!$value->getCurrency()->isEqualTo($this->currency)) {
return Err(new TransformValueException(type: 'money', value: $value));
}

Expand All @@ -57,16 +59,16 @@ public function assert(mixed $value): Result
public function coerce(mixed $value): Result
{
$candidate = $value instanceof RationalMoney
? $value->to(new DefaultContext(), $this->roundingMode)
? $value->toContext(new DefaultContext(), $this->roundingMode)
: $value;

return (match (true) {
$candidate instanceof Money => $candidate->getCurrency()->is($this->currency)
$candidate instanceof Money => $candidate->getCurrency()->isEqualTo($this->currency)
? Ok($candidate)
: Err(new InvalidArgumentException(sprintf("Mismatching currencies: expected %s, got %s", $this->currency->getCurrencyCode(), $candidate->getCurrency()->getCurrencyCode()))),
default => attempt(function () use ($candidate) {
union(string(), float(), int())->assert($candidate);
return Money::ofMinor($candidate, $this->currency);
$amount = union(string(), float(), int())->assert($candidate);
return Money::ofMinor(MoneyParser::exact($amount), $this->currency);
}),
})
->map(fn(Money $money) => Some($money))
Expand All @@ -75,8 +77,9 @@ public function coerce(mixed $value): Result

public function format(mixed $value): string
{
$money = instance_of(Money::class)->assert($value);
$formatter = new \NumberFormatter('en_GB', \NumberFormatter::CURRENCY);
$result = $formatter->formatCurrency($value->getAmount()->toFloat(), $value->getCurrency()->getCurrencyCode());
$result = $formatter->formatCurrency($money->getAmount()->toFloat(), $money->getCurrency()->getCurrencyCode());
return non_empty_string()->assert($result);
}

Expand Down
7 changes: 4 additions & 3 deletions src/Types/MonetaryIntervalType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Superscript\Axiom\Types\Shapes\Shape;
use Superscript\Axiom\Types\Type;

use function Psl\Type\instance_of;
use function Superscript\Monads\Option\Some;
use function Superscript\Monads\Result\attempt;
use function Superscript\Monads\Result\Err;
Expand All @@ -39,7 +40,7 @@ public function assert(mixed $value): Result
return Err(new TransformValueException(type: 'monetary-interval', value: $value));
}

if (!$value->left->getCurrency()->is($this->currency)) {
if (!$value->left->getCurrency()->isEqualTo($this->currency)) {
return Err(new TransformValueException(type: 'monetary-interval', value: $value));
}

Expand All @@ -52,7 +53,7 @@ public function assert(mixed $value): Result
public function coerce(mixed $value): Result
{
return (match (true) {
$value instanceof MonetaryInterval => $value->left->getCurrency()->is($this->currency)
$value instanceof MonetaryInterval => $value->left->getCurrency()->isEqualTo($this->currency)
? Ok($value)
: Err(new InvalidArgumentException(sprintf("Mismatching currencies: expected %s, got %s", $this->currency->getCurrencyCode(), $value->left->getCurrency()->getCurrencyCode()))),
is_string($value) => attempt(fn() => Interval::fromString($value))
Expand All @@ -69,7 +70,7 @@ public function coerce(mixed $value): Result

public function format(mixed $value): string
{
return (string) $value;
return (string) instance_of(MonetaryInterval::class)->assert($value);
}

/**
Expand Down
17 changes: 10 additions & 7 deletions src/Types/MonetaryType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
use Superscript\Monads\Option\Option;
use Superscript\Monads\Result\Result;
use Superscript\Axiom\Exceptions\TransformValueException;
use Superscript\Axiom\Money\MoneyParser;
use Superscript\Axiom\Types\Shapes\LiteralShape;
use Superscript\Axiom\Types\Shapes\OpaqueShape;
use Superscript\Axiom\Types\Shapes\Shape;
use Superscript\Axiom\Types\Type;

use function Psl\Type\float;
use function Psl\Type\int;
use function Psl\Type\instance_of;
use function Psl\Type\non_empty_string;
use function Psl\Type\string;
use function Psl\Type\union;
Expand All @@ -33,7 +35,7 @@
*/
final readonly class MonetaryType implements Type
{
public function __construct(public Currency $currency, public RoundingMode $roundingMode = RoundingMode::HALF_UP) {}
public function __construct(public Currency $currency, public RoundingMode $roundingMode = RoundingMode::HalfUp) {}

/**
* @return Result<Option<Money>, TransformValueException>
Expand All @@ -44,7 +46,7 @@ public function assert(mixed $value): Result
return Err(new TransformValueException(type: 'money', value: $value));
}

if (!$value->getCurrency()->is($this->currency)) {
if (!$value->getCurrency()->isEqualTo($this->currency)) {
return Err(new TransformValueException(type: 'money', value: $value));
}

Expand All @@ -57,16 +59,16 @@ public function assert(mixed $value): Result
public function coerce(mixed $value): Result
{
$candidate = $value instanceof RationalMoney
? $value->to(new DefaultContext(), $this->roundingMode)
? $value->toContext(new DefaultContext(), $this->roundingMode)
: $value;

return (match (true) {
$candidate instanceof Money => $candidate->getCurrency()->is($this->currency)
$candidate instanceof Money => $candidate->getCurrency()->isEqualTo($this->currency)
? Ok($candidate)
: Err(new InvalidArgumentException(sprintf("Mismatching currencies: expected %s, got %s", $this->currency->getCurrencyCode(), $candidate->getCurrency()->getCurrencyCode()))),
default => attempt(function () use ($candidate) {
union(string(), float(), int())->assert($candidate);
return Money::of($candidate, $this->currency);
$amount = union(string(), float(), int())->assert($candidate);
return Money::of(MoneyParser::exact($amount), $this->currency);
}),
})
->map(fn(Money $money) => Some($money))
Expand All @@ -75,8 +77,9 @@ public function coerce(mixed $value): Result

public function format(mixed $value): string
{
$money = instance_of(Money::class)->assert($value);
$formatter = new \NumberFormatter('en_GB', \NumberFormatter::CURRENCY);
$result = $formatter->formatCurrency($value->getAmount()->toFloat(), $value->getCurrency()->getCurrencyCode());
$result = $formatter->formatCurrency($money->getAmount()->toFloat(), $money->getCurrency()->getCurrencyCode());
return non_empty_string()->assert($result);
}

Expand Down
Loading
Loading