1
0
Fork 0
mirror of https://github.com/archtechx/money.git synced 2025-12-13 03:34:04 +00:00

Initial commit

This commit is contained in:
Samuel Štancl 2021-11-16 19:06:57 +01:00
commit 8847454577
33 changed files with 2435 additions and 0 deletions

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money\Concerns;
use Closure;
trait PersistsCurrency
{
protected Closure $resolveCurrentUsing;
protected Closure $storeCurrentUsing;
protected function resolveCurrent(): string|null
{
return isset($this->resolveCurrentUsing)
? ($this->resolveCurrentUsing)()
: null;
}
/** Set the handler for resolving the current currency. */
public function resolveCurrentUsing(Closure $callback): static
{
$this->resolveCurrentUsing = $callback;
return $this;
}
protected function storeCurrent(string $currency): static
{
if (isset($this->storeCurrentUsing)) {
($this->storeCurrentUsing)($currency);
}
return $this;
}
/** Set the handler for storing the current currency. */
public function storeCurrentUsing(Closure $callback): static
{
$this->storeCurrentUsing = $callback;
return $this;
}
}

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money\Concerns;
use ArchTech\Money\Currency;
use ArchTech\Money\Exceptions\CurrencyDoesNotExistException;
use ArchTech\Money\Exceptions\InvalidCurrencyException;
use Closure;
use ReflectionClass;
trait RegistersCurrencies
{
/**
* Registered currencies.
*
* @var array<string, Currency>
*/
protected array $currencies = [];
/** Register a currency. */
public function add(string|Currency|Closure|array $currencies): static
{
// $currencies can be:
// new Currency(...)
// [new Currency(..), new Currency(...)]
// USD::class
// new USD
// ['code' => 'GBP', 'rate' => 0.8, 'name' => 'British Pound']
// Or a Closure returning any of the above
// Invoke Closures
$currencies = value($currencies);
// Make sure we're working with an array
$currencies = is_array($currencies) ? $currencies : [$currencies];
// If we're working with a single currency as an
// array, we'll manually wrap it again in [].
if (isset($currencies['code'])) {
$currencies = [$currencies];
}
foreach ($currencies as $currency) {
// ['code' => 'GBP', 'rate' => 0.8, 'name' => 'British Pound']
if (is_array($currency)) {
$currency = Currency::fromArray($currency);
}
// USD::class
if (is_string($currency)) {
$currency = new $currency;
}
/** @var Currency $currency */
$this->currencies[$currency->code()] = $currency;
}
return $this;
}
/** Unregister a currency. */
public function remove(string $currency): static
{
$code = $this->getCode($currency);
if ($this->has($code)) {
unset($this->currencies[$code]);
}
return $this;
}
/** List all registered currencies */
public function all(): array
{
return $this->currencies;
}
/** Unregister all currencies. */
public function clear(): static
{
$this->currencies = [];
return $this;
}
/** Fetch a currency by its code. */
public function get(string $currency): Currency
{
// Converting this to the code in case a class string is passed
$code = $this->getCode($currency);
$this->ensureCurrencyExists($code);
return $this->currencies[$code];
}
/** Check if a currency is registered. */
public function has(string $currency): bool
{
// Converting this to the code in case a class string is passed
$code = $this->getCode($currency);
return isset($this->currencies[$code]);
}
/** Abort execution if a currency doesn't exist. */
public function ensureCurrencyExists(string $currency): static
{
if (! $this->has($currency)) {
throw new CurrencyDoesNotExistException($currency);
}
return $this;
}
/** Get a currency's code. */
public function getCode(Currency|string $currency): string
{
if (is_string($currency) && isset($this->currencies[$currency])) {
return $currency;
}
if ($currency instanceof Currency) {
return $currency->code();
}
if (class_exists($currency) && (new ReflectionClass($currency))->isSubclassOf(Currency::class)) {
return (new $currency)->code();
}
throw new InvalidCurrencyException(
"{$currency} is not a valid currency.",
);
}
}

18
src/Currencies/USD.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money\Currencies;
use ArchTech\Money\Currency;
class USD extends Currency
{
protected string $code = 'USD';
protected string $name = 'United States Dollar';
protected float $rate = 1.0;
protected int $mathDecimals = 2;
protected int $displayDecimals = 2;
protected int $rounding = 2;
protected string $prefix = '$';
}

186
src/Currency.php Normal file
View file

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money;
use ArchTech\Money\Exceptions\InvalidCurrencyException;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class Currency implements Arrayable, JsonSerializable
{
/** Code of the currency (e.g. 'CZK'). */
protected string $code;
/** Name of the currency (e.g. 'Czech Crown'). */
protected string $name;
/** Rate of this currency relative to the default currency. */
protected float $rate;
/** Prefix placed at the beginning of the formatted value. */
protected string $prefix;
/** Suffix placed at the end of the formatted value. */
protected string $suffix;
/** Number of decimals used in money calculations. */
protected int $mathDecimals;
/** Number of decimals used in the formatted value. */
protected int $displayDecimals;
/** The character used to separate the decimal values. */
protected string $decimalSeparator;
/** The character used to separate groups of thousands. */
protected string $thousandsSeparator;
/** How many decimals of the currency's values should get rounded. */
protected int $rounding;
/** Create a new Currency instance. */
public function __construct(
string $code = null,
string $name = null,
float $rate = null,
string $prefix = null,
string $suffix = null,
int $mathDecimals = null,
int $displayDecimals = null,
int $rounding = null,
string $decimalSeparator = null,
string $thousandsSeparator = null,
) {
$this->code = $code ?? $this->code ?? '';
$this->name = $name ?? $this->name ?? '';
$this->rate = $rate ?? $this->rate ?? 1;
$this->prefix = $prefix ?? $this->prefix ?? '';
$this->suffix = $suffix ?? $this->suffix ?? '';
$this->mathDecimals = $mathDecimals ?? $this->mathDecimals ?? 2;
$this->displayDecimals = $displayDecimals ?? $this->displayDecimals ?? 2;
$this->decimalSeparator = $decimalSeparator ?? $this->decimalSeparator ?? '.';
$this->thousandsSeparator = $thousandsSeparator ?? $this->thousandsSeparator ?? ',';
$this->rounding = $rounding ?? $this->rounding ?? $this->mathDecimals;
$this->check();
}
/** Create an anonymous Currency instance from an array. */
public static function fromArray(array $currency): static
{
return new static(...$currency);
}
/** Get the currency's code. */
public function code(): string
{
return $this->code;
}
/** Get the currency's name. */
public function name(): string
{
return $this->name;
}
/** Get the currency's rate. */
public function rate(): float
{
return $this->rate;
}
/** Get the currency's prefix. */
public function prefix(): string
{
return $this->prefix;
}
/** Get the currency's suffix. */
public function suffix(): string
{
return $this->suffix;
}
/** Get the currency's math decimal count. */
public function mathDecimals(): int
{
return $this->mathDecimals;
}
/** Get the currency's math decimal count. */
public function displayDecimals(): int
{
return $this->displayDecimals;
}
/** Get the currency's decimal separator. */
public function decimalSeparator(): string
{
return $this->decimalSeparator;
}
/** Get the currency's thousands separator. */
public function thousandsSeparator(): string
{
return $this->thousandsSeparator;
}
/** Get the currency's rounding. */
public function rounding(): int
{
return $this->rounding;
}
/** Convert the currency to a string (returns the code). */
public function __toString()
{
return $this->code();
}
/** Convert the currency to an array. */
public function toArray(): array
{
return [
'code' => $this->code,
'name' => $this->name,
'rate' => $this->rate,
'prefix' => $this->prefix,
'suffix' => $this->suffix,
'mathDecimals' => $this->mathDecimals,
'displayDecimals' => $this->displayDecimals,
'rounding' => $this->rounding,
'decimalSeparator' => $this->decimalSeparator,
'thousandsSeparator' => $this->thousandsSeparator,
];
}
/** Get the data used for JSON serialization. */
public function jsonSerialize(): array
{
return $this->toArray();
}
/** Create a currency from JSON. */
public static function fromJson(string|array $json): self
{
if (is_string($json)) {
$json = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
}
return static::fromArray($json);
}
/**
* Ensure that the currency has all required values.
*
* @throws InvalidCurrencyException
*/
protected function check(): void
{
if (! $this->code() || ! $this->name()) {
throw new InvalidCurrencyException('This currency does not have a code or a name.');
}
}
}

82
src/CurrencyManager.php Normal file
View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money;
use ArchTech\Money\Concerns\PersistsCurrency;
use ArchTech\Money\Concerns\RegistersCurrencies;
use ArchTech\Money\Currencies\USD;
class CurrencyManager
{
use RegistersCurrencies, PersistsCurrency;
/** The default currency's code. */
protected string $default = 'USD';
/** The current currency's code. */
protected string $current;
public function __construct()
{
$this->reset();
}
/** Reset the object to the default state. */
public function reset(): static
{
$this->currencies = [
'USD' => new USD,
];
$this->default = 'USD';
$this->forgetCurrent();
return $this;
}
public function forgetCurrent(): static
{
unset($this->current);
return $this;
}
/** Get the default currency. */
public function getDefault(): Currency
{
return $this->get($this->default);
}
/** Set the default currency. */
public function setDefault(string $currency): static
{
$code = $this->getCode($currency);
$this->ensureCurrencyExists($code);
$this->default = $code;
return $this;
}
/** Get the current currency. */
public function getCurrent(): Currency
{
return $this->get($this->current ??= $this->resolveCurrent() ?? $this->default);
}
/** Set the current currency. */
public function setCurrent(Currency|string $currency): static
{
$code = $this->getCode($currency);
$this->ensureCurrencyExists($code);
$this->storeCurrent($this->current = $code);
return $this;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money\Exceptions;
use Exception;
class CurrencyDoesNotExistException extends Exception
{
public function __construct(string $code)
{
parent::__construct("The $code currency does not exist.");
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money\Exceptions;
use Exception;
class InvalidCurrencyException extends Exception
{
public function __construct(string $message = null)
{
parent::__construct($message ?? 'The currency is invalid');
}
}

261
src/Money.php Normal file
View file

@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Livewire\Wireable;
final class Money implements JsonSerializable, Arrayable, Wireable
{
protected int $value;
protected Currency $currency;
/** Create a new Money instance. */
public function __construct(int $value, Currency|string $currency = null)
{
$this->value = $value;
$this->currency = currency($currency);
}
/** Create a new Money instance with the same currency. */
protected function new(int $value): self
{
return new self($value, $this->currency);
}
/** Create a Money instance from a decimal value. */
public static function fromDecimal(float $decimal, Currency|string $currency = null): self
{
return new static(
(int) round($decimal * 10 ** currency($currency)->mathDecimals()),
currency($currency)
);
}
/** Add money (in base value). */
public function add(int $value): self
{
return $this->new($this->value + $value);
}
/** Add money (from another Money instance). */
public function addMoney(self $money): self
{
return $this->add(
$money->convertTo($this->currency)->value()
);
}
/** Subtract money (in base value). */
public function subtract(int $value): self
{
return $this->new($this->value - $value);
}
/** Subtract money (of another Money instance). */
public function subtractMoney(self $money): self
{
return $this->subtract(
$money->convertTo($this->currency)->value()
);
}
/** Multiply the money by a coefficient. */
public function multiplyBy(float $coefficient): self
{
return $this->new(
(int) round($this->value * $coefficient)
);
}
/** Multiply the money by a coefficient. */
public function times(float $coefficient): self
{
return $this->multiplyBy($coefficient);
}
/** Divide the money by a number. */
public function divideBy(float $number): self
{
if ($number == 0) {
$number = 1;
}
return $this->new(
(int) round($this->value() / $number)
);
}
/** Add a % fee to the money. */
public function addFee(float $rate): self
{
return $this->multiplyBy(
round(1 + $rate, $this->currency->mathDecimals())
);
}
/** Add a % tax to the money. */
public function addTax(float $rate): self
{
return $this->addFee($rate);
}
/** Subtract a % fee from the money. */
public function subtractFee(float $rate): self
{
return $this->divideBy(
round(1 + $rate, $this->currency->mathDecimals())
);
}
/** Subtract a % tax from the money. */
public function subtractTax(float $rate): self
{
return $this->subtractFee($rate);
}
/** Get the base value of the money in the used currency. */
public function value(): int
{
return $this->value;
}
/** Get the used currency. */
public function currency(): Currency
{
return $this->currency;
}
/** Get the decimal representation of the value. */
public function decimal(): float
{
return $this->value / 10 ** $this->currency->mathDecimals();
}
/** Format the value. */
public function formatted(mixed ...$overrides): string
{
return PriceFormatter::format($this->decimal(), $this->currency, variadic_array($overrides));
}
/** Format the raw (unrounded) value. */
public function rawFormatted(mixed ...$overrides): string
{
return $this->formatted(array_merge(variadic_array($overrides), [
'displayDecimals' => $this->currency->mathDecimals(),
]));
}
/** Get the string representation of the Money instance. */
public function __toString(): string
{
return $this->formatted();
}
/** Convert the instance to an array representation. */
public function toArray(): array
{
return [
'value' => $this->value,
'currency' => $this->currency->code(),
];
}
/** Check if the value equals the value of another Money instance, adjusted for currency. */
public function equals(self $money): bool
{
return $this->valueInDefaultCurrency() === $money->valueInDefaultCurrency();
}
/** Check if the value and currency match another Money instance. */
public function is(self $money): bool
{
return $this->currency()->code() === $money->currency()->code()
&& $this->equals($money);
}
/** Get the data used for JSON serializing this object. */
public function jsonSerialize(): array
{
return $this->toArray();
}
/** Convert the instance to JSON */
public function toJson(): string
{
return json_encode($this, JSON_THROW_ON_ERROR);
}
/** Instantiate Money from JSON. */
public static function fromJson(string|array $json): self
{
if (is_string($json)) {
$json = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
}
return new static($json['value'], $json['currency']);
}
/** Value in the default currency. */
public function valueInDefaultCurrency(): int
{
$mathDecimalDifference = $this->currency->mathDecimals() - currencies()->getDefault()->mathDecimals();
return $this
->divideBy($this->currency->rate())
->divideBy(10 ** $mathDecimalDifference)
->value();
}
/** Convert the money to a different currency. */
public function convertTo(Currency|string $currency): self
{
// We're converting from the current currency to the default currency, and then to the intended currency
$newCurrency = currency($currency);
$mathDecimalDifference = $newCurrency->mathDecimals() - currencies()->getDefault()->mathDecimals();
return new static(
(int) round($this->valueInDefaultCurrency() * $newCurrency->rate() * 10 ** $mathDecimalDifference, 0),
$currency
);
}
/** Convert the Money to the current currency. */
public function toCurrent(): self
{
return $this->convertTo(currencies()->getCurrent());
}
/** Convert the Money to the current currency. */
public function toDefault(): self
{
return $this->convertTo(currencies()->getDefault());
}
/** Round the Money to a custom precision. */
public function rounded(int $precision = null): self
{
$precision ??= $this->currency->rounding();
return $this->new(((int) round($this->value, -$precision)));
}
/** Get the money rounding (typically this is the difference between the actual value and the formatted value.) */
public function rounding(): int
{
return $this->rounded()->value() - $this->value();
}
public function toLivewire()
{
return $this->toArray();
}
public static function fromLivewire($value)
{
return static::fromJson($value);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money;
use Illuminate\Support\ServiceProvider;
class MoneyServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(CurrencyManager::class);
}
}

25
src/PriceFormatter.php Normal file
View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace ArchTech\Money;
class PriceFormatter
{
/** Format a decimal per the currency's specifications. */
public static function format(float $decimal, Currency $currency, array $overrides = []): string
{
$currency = Currency::fromArray(
array_merge(currency($currency)->toArray(), $overrides)
);
$decimal = number_format(
$decimal,
$currency->displayDecimals(),
$currency->decimalSeparator(),
$currency->thousandsSeparator(),
);
return $currency->prefix() . $decimal . $currency->suffix();
}
}

13
src/Wireable.php Normal file
View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
// Temporary until Livewire gets the new system for Wireable properties
namespace Livewire {
interface Wireable
{
public function toLivewire();
public static function fromLivewire($value);
}
}

31
src/helpers.php Normal file
View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use ArchTech\Money\Currency;
use ArchTech\Money\CurrencyManager;
use ArchTech\Money\Money;
/** Create a Money instance. */
function money(int $amount, Currency|string $currency = null): Money
{
return new Money($amount, $currency ?? currencies()->getDefault());
}
/** Fetch a currency. If no argument is provided, the current currency will be returned. */
function currency(Currency|string $currency = null): Currency
{
if ($currency) {
return $currency instanceof Currency
? $currency
: currencies()->get($currency);
}
return currencies()->getCurrent();
}
/** Get the CurrencyManager instance. */
function currencies(): CurrencyManager
{
return app(CurrencyManager::class);
}