mirror of
https://github.com/archtechx/money.git
synced 2025-12-12 03:14:03 +00:00
Initial commit
This commit is contained in:
commit
8847454577
33 changed files with 2435 additions and 0 deletions
13
.gitattributes
vendored
Normal file
13
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/.github export-ignore
|
||||||
|
/.gitattributes export-ignore
|
||||||
|
/.gitignore export-ignore
|
||||||
|
|
||||||
|
/docker-compose.yml export-ignore
|
||||||
|
/tests export-ignore
|
||||||
|
|
||||||
|
/phpstan.neon export-ignore
|
||||||
|
/.php_cs.php export-ignore
|
||||||
|
/psalm.xml export-ignore
|
||||||
|
/phpunit.xml export-ignore
|
||||||
|
/check export-ignore
|
||||||
|
/coverage export-ignore
|
||||||
53
.github/workflows/ci.yml
vendored
Normal file
53
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
env:
|
||||||
|
COMPOSE_INTERACTIVE_NO_CLI: 1
|
||||||
|
PHP_CS_FIXER_IGNORE_ENV: 1
|
||||||
|
MYSQL_PORT: 3307
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pest:
|
||||||
|
name: Tests (Pest)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Start docker containers
|
||||||
|
run: docker-compose up -d
|
||||||
|
- name: Install composer dependencies
|
||||||
|
run: composer install
|
||||||
|
- name: Run tests
|
||||||
|
run: vendor/bin/pest
|
||||||
|
|
||||||
|
phpstan:
|
||||||
|
name: Static analysis (PHPStan)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Install composer dependencies
|
||||||
|
run: composer install
|
||||||
|
- name: Run phpstan
|
||||||
|
run: vendor/bin/phpstan analyse
|
||||||
|
|
||||||
|
php-cs-fixer:
|
||||||
|
name: Code style (php-cs-fixer)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Install php-cs-fixer
|
||||||
|
run: composer global require friendsofphp/php-cs-fixer
|
||||||
|
- name: Run php-cs-fixer
|
||||||
|
run: $HOME/.composer/vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php
|
||||||
|
- name: Commit changes from php-cs-fixer
|
||||||
|
uses: EndBug/add-and-commit@v5
|
||||||
|
with:
|
||||||
|
author_name: "PHP CS Fixer"
|
||||||
|
author_email: "phpcsfixer@example.com"
|
||||||
|
message: Fix code style (php-cs-fixer)
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.phpunit.result.cache
|
||||||
|
package-lock.json
|
||||||
|
composer.lock
|
||||||
|
vendor/
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.vscode/
|
||||||
|
coverage/
|
||||||
|
node_modules
|
||||||
141
.php-cs-fixer.php
Normal file
141
.php-cs-fixer.php
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use PhpCsFixer\Config;
|
||||||
|
use PhpCsFixer\Finder;
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'array_syntax' => ['syntax' => 'short'],
|
||||||
|
'binary_operator_spaces' => [
|
||||||
|
'default' => 'single_space',
|
||||||
|
'operators' => [
|
||||||
|
'=>' => null,
|
||||||
|
'|' => 'no_space',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'blank_line_after_namespace' => true,
|
||||||
|
'blank_line_after_opening_tag' => true,
|
||||||
|
'no_superfluous_phpdoc_tags' => true,
|
||||||
|
'blank_line_before_statement' => [
|
||||||
|
'statements' => ['return']
|
||||||
|
],
|
||||||
|
'braces' => true,
|
||||||
|
'cast_spaces' => true,
|
||||||
|
'class_definition' => true,
|
||||||
|
'concat_space' => [
|
||||||
|
'spacing' => 'one'
|
||||||
|
],
|
||||||
|
'declare_equal_normalize' => true,
|
||||||
|
'elseif' => true,
|
||||||
|
'encoding' => true,
|
||||||
|
'full_opening_tag' => true,
|
||||||
|
'declare_strict_types' => true,
|
||||||
|
'fully_qualified_strict_types' => true, // added by Shift
|
||||||
|
'function_declaration' => true,
|
||||||
|
'function_typehint_space' => true,
|
||||||
|
'heredoc_to_nowdoc' => true,
|
||||||
|
'include' => true,
|
||||||
|
'increment_style' => ['style' => 'post'],
|
||||||
|
'indentation_type' => true,
|
||||||
|
'linebreak_after_opening_tag' => true,
|
||||||
|
'line_ending' => true,
|
||||||
|
'lowercase_cast' => true,
|
||||||
|
'constant_case' => true,
|
||||||
|
'lowercase_keywords' => true,
|
||||||
|
'lowercase_static_reference' => true, // added from Symfony
|
||||||
|
'magic_method_casing' => true, // added from Symfony
|
||||||
|
'magic_constant_casing' => true,
|
||||||
|
'method_argument_space' => true,
|
||||||
|
'native_function_casing' => true,
|
||||||
|
'no_alias_functions' => true,
|
||||||
|
'no_extra_blank_lines' => [
|
||||||
|
'tokens' => [
|
||||||
|
'extra',
|
||||||
|
'throw',
|
||||||
|
'use',
|
||||||
|
'use_trait',
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'no_blank_lines_after_class_opening' => true,
|
||||||
|
'no_blank_lines_after_phpdoc' => true,
|
||||||
|
'no_closing_tag' => true,
|
||||||
|
'no_empty_phpdoc' => true,
|
||||||
|
'no_empty_statement' => true,
|
||||||
|
'no_leading_import_slash' => true,
|
||||||
|
'no_leading_namespace_whitespace' => true,
|
||||||
|
'no_mixed_echo_print' => [
|
||||||
|
'use' => 'echo'
|
||||||
|
],
|
||||||
|
'no_multiline_whitespace_around_double_arrow' => true,
|
||||||
|
'multiline_whitespace_before_semicolons' => [
|
||||||
|
'strategy' => 'no_multi_line'
|
||||||
|
],
|
||||||
|
'no_short_bool_cast' => true,
|
||||||
|
'no_singleline_whitespace_before_semicolons' => true,
|
||||||
|
'no_spaces_after_function_name' => true,
|
||||||
|
'no_spaces_around_offset' => true,
|
||||||
|
'no_spaces_inside_parenthesis' => true,
|
||||||
|
'no_trailing_comma_in_list_call' => true,
|
||||||
|
'no_trailing_comma_in_singleline_array' => true,
|
||||||
|
'no_trailing_whitespace' => true,
|
||||||
|
'no_trailing_whitespace_in_comment' => true,
|
||||||
|
'no_unneeded_control_parentheses' => true,
|
||||||
|
'no_unreachable_default_argument_value' => true,
|
||||||
|
'no_useless_return' => true,
|
||||||
|
'no_whitespace_before_comma_in_array' => true,
|
||||||
|
'no_whitespace_in_blank_line' => true,
|
||||||
|
'normalize_index_brace' => true,
|
||||||
|
'not_operator_with_successor_space' => true,
|
||||||
|
'object_operator_without_whitespace' => true,
|
||||||
|
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||||
|
'phpdoc_indent' => true,
|
||||||
|
'general_phpdoc_tag_rename' => true,
|
||||||
|
'phpdoc_no_access' => true,
|
||||||
|
'phpdoc_no_package' => true,
|
||||||
|
'phpdoc_no_useless_inheritdoc' => true,
|
||||||
|
'phpdoc_scalar' => true,
|
||||||
|
'phpdoc_single_line_var_spacing' => true,
|
||||||
|
'phpdoc_summary' => true,
|
||||||
|
'phpdoc_to_comment' => false,
|
||||||
|
'phpdoc_trim' => true,
|
||||||
|
'phpdoc_types' => true,
|
||||||
|
'phpdoc_var_without_name' => true,
|
||||||
|
'psr_autoloading' => true,
|
||||||
|
'self_accessor' => true,
|
||||||
|
'short_scalar_cast' => true,
|
||||||
|
'simplified_null_return' => false, // disabled by Shift
|
||||||
|
'single_blank_line_at_eof' => true,
|
||||||
|
'single_blank_line_before_namespace' => true,
|
||||||
|
'single_class_element_per_statement' => true,
|
||||||
|
'single_import_per_statement' => true,
|
||||||
|
'single_line_after_imports' => true,
|
||||||
|
'no_unused_imports' => true,
|
||||||
|
'single_line_comment_style' => [
|
||||||
|
'comment_types' => ['hash']
|
||||||
|
],
|
||||||
|
'single_quote' => true,
|
||||||
|
'space_after_semicolon' => true,
|
||||||
|
'standardize_not_equals' => true,
|
||||||
|
'switch_case_semicolon_to_colon' => true,
|
||||||
|
'switch_case_space' => true,
|
||||||
|
'ternary_operator_spaces' => true,
|
||||||
|
'trailing_comma_in_multiline' => true,
|
||||||
|
'trim_array_spaces' => true,
|
||||||
|
'unary_operator_spaces' => true,
|
||||||
|
'whitespace_after_comma_in_array' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
$project_path = getcwd();
|
||||||
|
$finder = Finder::create()
|
||||||
|
->in([
|
||||||
|
$project_path . '/src',
|
||||||
|
])
|
||||||
|
->name('*.php')
|
||||||
|
->notName('*.blade.php')
|
||||||
|
->ignoreDotFiles(true)
|
||||||
|
->ignoreVCS(true);
|
||||||
|
|
||||||
|
return (new Config())
|
||||||
|
->setFinder($finder)
|
||||||
|
->setRules($rules)
|
||||||
|
->setRiskyAllowed(true)
|
||||||
|
->setUsingCache(true);
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 ArchTech Development, Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
512
README.md
Normal file
512
README.md
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
# Money
|
||||||
|
|
||||||
|
A simple package for working with money.
|
||||||
|
|
||||||
|
Main features:
|
||||||
|
- Simple API
|
||||||
|
- Custom currency support
|
||||||
|
- Highly customizable currencies
|
||||||
|
- Rounding logic for compliant accounting
|
||||||
|
|
||||||
|
This package is our implementation of the [Money pattern](https://martinfowler.com/eaaCatalog/money.html).
|
||||||
|
|
||||||
|
You can read more about why we built it and how it works on our forum: [New package: archtechx/money](https://forum.archte.ch/archtech/t/new-package-archtechxmoney).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Require the package via composer:
|
||||||
|
```sh
|
||||||
|
composer require archtechx/money
|
||||||
|
```
|
||||||
|
|
||||||
|
## Main concepts
|
||||||
|
|
||||||
|
The package has two main classes:
|
||||||
|
- `Money` which represents monetary values
|
||||||
|
- `Currency` which is extended by the currencies that you're using
|
||||||
|
|
||||||
|
This document uses the terms [decimal value](#decimal-value), [base value](#base-value), [default currency](#default-currency), [current currency](#current-currency), [rounding](#rounding), [math decimals](#math-decimals), [display decimals](#display-decimals), and a few others. Refer to the [Terminology](#terminology) section for definitions.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
## Money
|
||||||
|
|
||||||
|
**Important**: As an implementation of the [Money pattern](https://martinfowler.com/eaaCatalog/money.html), the `Money` object creates a new instance after each operation. Meaning, **all `Money` instances are immutable**. To modify the value of a variable, re-initialize it with a new value:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
$money = money(1500);
|
||||||
|
$money->times(3); // ❌
|
||||||
|
$money->value(); // 1500
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
$money = money(1500);
|
||||||
|
$money = $money->times(3); // ✅
|
||||||
|
$money->value(); // 4500
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating `Money` instances
|
||||||
|
```php
|
||||||
|
// Using cents
|
||||||
|
$money = money(1500); // $15.00; default currency
|
||||||
|
$money = money(1500, 'EUR'); // 15.00 €
|
||||||
|
$money = money(2000, new USD); // $20.00
|
||||||
|
$money = money(3000, CZK::class); // 20 Kč
|
||||||
|
|
||||||
|
// Using decimals
|
||||||
|
$money = Money::fromDecimals(15.00, 'EUR'); // 15.00 €
|
||||||
|
$money = Money::fromDecimals(20.00, new USD); // $20.00
|
||||||
|
$money = Money::fromDecimals(30.00, CZK::class); // 20 Kč
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arithmetics
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Addition
|
||||||
|
$money = money(1000);
|
||||||
|
$money = $money->add(500);
|
||||||
|
$money->value(); // 1500
|
||||||
|
|
||||||
|
// Subtraction
|
||||||
|
$money = money(1000);
|
||||||
|
$money = $money->subtract(500);
|
||||||
|
$money->value(); // 500
|
||||||
|
|
||||||
|
// Multiplication
|
||||||
|
$money = money(1000);
|
||||||
|
$money = $money->multiplyBy(2); // alias: ->times()
|
||||||
|
$money->value(); // 2000
|
||||||
|
|
||||||
|
// Division
|
||||||
|
$money = money(1000);
|
||||||
|
$money = $money->divideBy(2);
|
||||||
|
$money->value(); // 500
|
||||||
|
```
|
||||||
|
|
||||||
|
### Converting money to a different currency
|
||||||
|
|
||||||
|
```php
|
||||||
|
$money = money(2200);
|
||||||
|
$money->convertTo(CZK::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comparing money instances
|
||||||
|
|
||||||
|
**Equality of monetary value**
|
||||||
|
```php
|
||||||
|
// Assuming CZK is 25:1 USD
|
||||||
|
|
||||||
|
// ✅ true
|
||||||
|
money(100, USD::class)->equals(money(100, USD::class));
|
||||||
|
|
||||||
|
// ❌ false
|
||||||
|
money(100, USD::class)->equals(money(200, USD::class));
|
||||||
|
|
||||||
|
// ✅ true
|
||||||
|
money(100, USD::class)->equals(money(2500, CZK::class));
|
||||||
|
|
||||||
|
// ❌ false
|
||||||
|
money(100, USD::class)->equals(money(200, CZK::class));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Equality of monetary value AND currency**
|
||||||
|
```php
|
||||||
|
// Assuming CZK is 25:1 USD
|
||||||
|
|
||||||
|
// ✅ true
|
||||||
|
money(100, USD::class)->equals(money(100, USD::class));
|
||||||
|
|
||||||
|
// ❌ false: different monetary value
|
||||||
|
money(100, USD::class)->equals(money(200, USD::class));
|
||||||
|
|
||||||
|
// ❌ false: different currency
|
||||||
|
money(100, USD::class)->equals(money(2500, CZK::class));
|
||||||
|
|
||||||
|
// ❌ false: different currency AND monetary value
|
||||||
|
money(100, USD::class)->equals(money(200, CZK::class));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding fees
|
||||||
|
|
||||||
|
You can use the `addFee()` or `addTax()` methods to add a % fee to the money:
|
||||||
|
```php
|
||||||
|
$money = money(1000);
|
||||||
|
$money = $money->addTax(20.0); // 20%
|
||||||
|
$money->value(); // 1200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing the decimal value
|
||||||
|
|
||||||
|
```php
|
||||||
|
$money = Money::fromDecimals(100.0, new USD);
|
||||||
|
$money->value(); // 10000
|
||||||
|
$money->decimals(); // 100.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formatting money
|
||||||
|
|
||||||
|
You can format money using the `->formatted()` method:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$money = Money::fromDecimals(40.25, USD::class);
|
||||||
|
$money->formatted(); // $40.25
|
||||||
|
```
|
||||||
|
|
||||||
|
The method optionally accepts overrides for the [currency specification](#currency-logic):
|
||||||
|
```php
|
||||||
|
$money = Money::fromDecimals(40.25, USD::class);
|
||||||
|
|
||||||
|
// $ 40.25 USD
|
||||||
|
$money->formatted(decimalSeparator: ',', prefix: '$ ', suffix: ' USD');
|
||||||
|
```
|
||||||
|
|
||||||
|
The overrides can also be passed as an array:
|
||||||
|
```php
|
||||||
|
$money = Money::fromDecimals(40.25, USD::class);
|
||||||
|
|
||||||
|
// $ 40.25 USD
|
||||||
|
$money->formatted(['decimalSeparator' => ',', 'prefix' => '$ ', 'suffix' => ' USD']);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rounding money
|
||||||
|
|
||||||
|
Some currencies, such as the Czech Crown (CZK), generally display final prices in full crowns, but use cents for the intermediate math operations. For example:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$money = Money::fromDecimals(3.30, CZK::class);
|
||||||
|
$money->value(); // 330
|
||||||
|
$money->formatted(); // 3 Kč
|
||||||
|
|
||||||
|
$money = $money->times(3);
|
||||||
|
$money->value(); // 990
|
||||||
|
$money->formatted(); // 10 Kč
|
||||||
|
```
|
||||||
|
|
||||||
|
If the customer purchases a single `3.30` item, he pays `3 CZK`, but if he purchases three `3.30` items, he pays `10 CZK`.
|
||||||
|
|
||||||
|
This rounding (to full crowns) is standard and legal per the accounting legislation, since it makes payments easier. However, the law requires you to keep track of the rounding difference for tax purposes.
|
||||||
|
|
||||||
|
#### Getting the used rounding
|
||||||
|
|
||||||
|
For that use case, our package lets you get the rounding difference using a simple method call:
|
||||||
|
```php
|
||||||
|
$money = Money::fromDecimals(9.90, CZK::class);
|
||||||
|
$money->decimals(); // 9.90
|
||||||
|
$money->formatted(); // 10 Kč
|
||||||
|
$money->rounding(); // +0.10 Kč = 10
|
||||||
|
|
||||||
|
$money = Money::fromDecimals(3.30, CZK::class);
|
||||||
|
$money->decimals(); // 3.30
|
||||||
|
$money->formatted(); // 3 Kč
|
||||||
|
$money->rounding(); // -0.30 Kč = -30
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Applying rounding to money
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Using the currency rounding
|
||||||
|
$money = Money::fromDecimals(9.90, CZK::class);
|
||||||
|
$money->decimals(); // 9.90
|
||||||
|
$money = $money->rounded(); // currency rounding
|
||||||
|
$money->decimals(); // 10.0
|
||||||
|
|
||||||
|
// Using custom rounding
|
||||||
|
$money = Money::fromDecimals(2.22, USD::class);
|
||||||
|
$money->decimals(); // 2.22
|
||||||
|
$money = $money->rounded(1); // custom rounding: 1 decimal
|
||||||
|
$money->decimals(); // 2.20
|
||||||
|
```
|
||||||
|
|
||||||
|
## Currencies
|
||||||
|
|
||||||
|
To work with the registered currencies, use the bound `CurrencyManager` instance, accessible using the `currencies()` helper.
|
||||||
|
|
||||||
|
### Creating a currency
|
||||||
|
|
||||||
|
You can create a currency using one of the multiple supported syntaxes.
|
||||||
|
```php
|
||||||
|
// anonymous Currency object
|
||||||
|
$currency = new Currency(
|
||||||
|
code: 'FOO',
|
||||||
|
name: 'Foo currency',
|
||||||
|
rate: 1.8,
|
||||||
|
prefix: '# ',
|
||||||
|
suffix: ' FOO',
|
||||||
|
);
|
||||||
|
|
||||||
|
// array
|
||||||
|
$currency = [
|
||||||
|
code: 'FOO',
|
||||||
|
name: 'Foo currency',
|
||||||
|
rate: 1.8,
|
||||||
|
prefix: '# ',
|
||||||
|
suffix: ' FOO',
|
||||||
|
];
|
||||||
|
|
||||||
|
// class
|
||||||
|
class FOO extends Currency
|
||||||
|
{
|
||||||
|
protected string $code = 'FOO';
|
||||||
|
protected string $name = 'Foo currency';
|
||||||
|
protected float $rate = 1.8;
|
||||||
|
protected string $prefix = '# ';
|
||||||
|
protected string $suffix = ' FOO';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Currency logic](#currency-logic) section for a list of available properties to configure. Note that when registering a currency, two values **must** be specified:
|
||||||
|
1. The code of the currency (e.g. `USD`)
|
||||||
|
2. The name of the currency (e.g. `United States Dollar`)
|
||||||
|
|
||||||
|
### Adding a currency
|
||||||
|
|
||||||
|
Register a new currency:
|
||||||
|
```php
|
||||||
|
currencies()->add(new USD);
|
||||||
|
currencies()->add(USD::class);
|
||||||
|
currencies()->add($currency); // object or array
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing a specific currency
|
||||||
|
|
||||||
|
To remove a specific currency, you can use the `remove()` method:
|
||||||
|
```php
|
||||||
|
currencies()->remove('USD');
|
||||||
|
currencies()->remove(USD::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing all currencies
|
||||||
|
|
||||||
|
To remove all currencies, you can use the `clear()` method:
|
||||||
|
```php
|
||||||
|
currencies()->clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resetting currencies
|
||||||
|
|
||||||
|
Can be useful in tests. This reverts all your changes and makes the `CurrencyManager` use `USD` as the default currency.
|
||||||
|
|
||||||
|
```php
|
||||||
|
currencies()->reset();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Currency logic
|
||||||
|
|
||||||
|
Currencies can have the following properties:
|
||||||
|
```php
|
||||||
|
protected string $code = null;
|
||||||
|
protected string $name = null;
|
||||||
|
protected float $rate = null;
|
||||||
|
protected string $prefix = null;
|
||||||
|
protected string $suffix = null;
|
||||||
|
protected int $mathDecimals = null;
|
||||||
|
protected int $displayDecimals = null;
|
||||||
|
protected int $rounding = null;
|
||||||
|
protected string $decimalSeparator = null;
|
||||||
|
protected string $thousandsSeparator = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
For each one, there's also a `public` method. Specifying a method can be useful when your currency config is dynamic, e.g. when the currency rate is taken from some API:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function rate(): float
|
||||||
|
{
|
||||||
|
return cache()->remember("{$this->code}.rate", 3600, function () {
|
||||||
|
return Http::get("https://api.currency.service/rate/USD/{$this->code}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persisting a selected currency across requests
|
||||||
|
|
||||||
|
If your users can select the currency they want to see the app in, the package can automatically write the current currency to a persistent store of your choice, and read from that store on subsequent requests.
|
||||||
|
|
||||||
|
For example, say we want to use the `currency` session key to keep track of the user's selected session. To implement that, we only need to do this:
|
||||||
|
```php
|
||||||
|
currencies()
|
||||||
|
->storeCurrentUsing(fn (string $code) => session()->put('currency', $code))
|
||||||
|
->resolveCurrentUsing(fn () => session()->get('currency));
|
||||||
|
```
|
||||||
|
You can add this code to your AppServiceProvider's `boot()` method.
|
||||||
|
|
||||||
|
Now, whenever the current currency is changed using `currencies()->setCurrent()`, perhaps in a route like this:
|
||||||
|
```php
|
||||||
|
Route::get('/currency/change/{currency}', function (string $currency) {
|
||||||
|
currencies()->setCurrent($currency);
|
||||||
|
|
||||||
|
return redirect()->back();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
it will also be written to the `currency` session key. The route can be used by a `<form>` in your navbar, or any other UI element.
|
||||||
|
|
||||||
|
# Terminology
|
||||||
|
|
||||||
|
This section explains the terminology used in the package.
|
||||||
|
|
||||||
|
## Values
|
||||||
|
|
||||||
|
Multiple different things can be meant by the "value" of a `Money` object. For that reason, we use separate terms.
|
||||||
|
|
||||||
|
### Base value
|
||||||
|
|
||||||
|
The base value is the value passed to the `money()` helper:
|
||||||
|
```php
|
||||||
|
$money = money(1000);
|
||||||
|
```
|
||||||
|
and returned from the `->value()` method:
|
||||||
|
```php
|
||||||
|
$money->value(); // 1000
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the actual integer value of the money. In most currencies this will be the cents.
|
||||||
|
|
||||||
|
The package uses the base value for all money calculations.
|
||||||
|
|
||||||
|
### Decimal value
|
||||||
|
|
||||||
|
The decimal value isn't used for calculations, but it is the human-readable one. It's typically used in the formatted value.
|
||||||
|
```php
|
||||||
|
$money = Money::fromDecimals(100.0); // $100 USD
|
||||||
|
$money->value(); // 10000
|
||||||
|
$money->decimal(); // 100.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Value in default currency
|
||||||
|
|
||||||
|
This is the value of a `Money` object converted to the default currency.
|
||||||
|
|
||||||
|
For example, you may want to let administrators enter the price of a product in any currency, but still store it in the default currency.
|
||||||
|
|
||||||
|
It's generally recommended to use the default currency in the "code land". And only use other currencies for displaying prices to the user (e.g. customer) or letting the administrators enter prices of things in a currency that works for them.
|
||||||
|
|
||||||
|
Of course, there are exceptions, and sometimes you may want to store both the currency and the value of an item. For that, the package has [JSON encoding features](#json-serialization) if you wish to store the entire `Money` object in a single database column.
|
||||||
|
|
||||||
|
Storing the integer price and the string currency as separate columns is, of course, perfectly fine as well.
|
||||||
|
|
||||||
|
### Formatted value
|
||||||
|
|
||||||
|
The formatted value is the Money value displayed per its currency spec. It may use the prefix, suffix, decimal separator, thousands separator, and the [display decimals](#display-decimals).
|
||||||
|
|
||||||
|
For example:
|
||||||
|
```php
|
||||||
|
money(123456, new CZK)->formatted(); // 1 235 Kč
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the [display decimals](#display-decimals) can be different from the [math decimals](#math-decimals).
|
||||||
|
|
||||||
|
For the Czech Crown (CZK), the display decimals will be `0`, but the math decimals will be `2`. Meaning, cents are used for money calculations, and the `decimal()` method will return the base value divided by `100`, but the display decimals don't include any cents.
|
||||||
|
|
||||||
|
### Raw formatted value
|
||||||
|
|
||||||
|
For the inverse of what was just explained above, you can use the `rawFormatted()` method. This returns the formatted value, **but uses the math decimals for the display decimals**. Meaning, the value in the example above will be displayed including cents:
|
||||||
|
```php
|
||||||
|
money(123456, new CZK)->formatted(); // 1 235,56 Kč
|
||||||
|
```
|
||||||
|
|
||||||
|
This is mostly useful for currencies like the Czech Crown which generally don't use cents, but **can** use them in specific cases.
|
||||||
|
|
||||||
|
## Currencies
|
||||||
|
|
||||||
|
### Current currency
|
||||||
|
|
||||||
|
The current currency refers to the currently used currency.
|
||||||
|
|
||||||
|
By default, the pacakge doesn't use it anywhere. All calls such as `money()` will use the provided currency, or the default currency.
|
||||||
|
|
||||||
|
The current currency is something you can convert money to in the final step of calculations, right before displaying it to the user in the browser.
|
||||||
|
|
||||||
|
### Default currency
|
||||||
|
|
||||||
|
The default currency is the currency that Money defaults to in the context of your codebase.
|
||||||
|
|
||||||
|
The `money()` helper, `Money::fromDecimals()` method, and `new Money()` all use this currency (unless a specific one is provided).
|
||||||
|
|
||||||
|
It can be a good idea to use the default currency for data storage. See more about this in the [Value in default currency](#value-in-default-currency) section.
|
||||||
|
|
||||||
|
### Math decimals
|
||||||
|
|
||||||
|
The math decimals refer to the amount of decimal points the currency has in a math context.
|
||||||
|
|
||||||
|
All math operations are still done in floats, using the [base value](#base-value), but the math decimals are used for knowing how to round the money after each operation, how to instantiate it with the `Money::fromDecimals()` method, and more.
|
||||||
|
|
||||||
|
### Display decimals
|
||||||
|
|
||||||
|
The display decimals refer to the amount of decimals used in the [formatted value](#formatted-value).
|
||||||
|
|
||||||
|
# Extra features
|
||||||
|
|
||||||
|
## Livewire support
|
||||||
|
|
||||||
|
The package supports Livewire out of the box. You can typehint any Livewire property as `Money` and the monetary value & currency will be stored in the component's state.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class EditProduct extends Component
|
||||||
|
{
|
||||||
|
public Money $price;
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Livewire's custom type support isn't advanced yet, so this is a bit harder to use in the Blade view — a wrapper Alpine component is recommended. In a future release, `wire:model` will be supported for `currency` and `value` directly.
|
||||||
|
|
||||||
|
The component can look roughly like this:
|
||||||
|
```html
|
||||||
|
<div x-data="{
|
||||||
|
money: {
|
||||||
|
value: {{ $price->decimal() }},
|
||||||
|
currency: {{ $price->currency()->code() }},
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
$watch('money', () => $wire.set('money', {
|
||||||
|
value: Math.round(this.value / 100),
|
||||||
|
currency: this.currency.
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}" x-init="init">
|
||||||
|
Currency: <select x-model="currency">...</select>
|
||||||
|
Price: <input x-model="value" type="number" step="0.01">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON serialization
|
||||||
|
|
||||||
|
Both currencies and `Money` instances can be converted to JSON, and instantiated from JSON.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$currency = new CZK;
|
||||||
|
$json = json_encode($currency);
|
||||||
|
$currency = Currency::fromJson($json);
|
||||||
|
|
||||||
|
$foo = money(100, 'CZK');
|
||||||
|
$bar = Money::fromJson($money->toJson());
|
||||||
|
$money->is($bar); // true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
### 💡 Accepted currency formats
|
||||||
|
|
||||||
|
Most methods which accept a currency accept it in any format:
|
||||||
|
```php
|
||||||
|
currency(USD::class);
|
||||||
|
currency(new USD);
|
||||||
|
currency('USD');
|
||||||
|
|
||||||
|
money(1000, USD::class)->convertTo('CZK');
|
||||||
|
money(1000, 'USD')->convertTo(new CZK);
|
||||||
|
money(1000, new USD)->convertTo(CZK::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development & contributing
|
||||||
|
|
||||||
|
Run all checks locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./check
|
||||||
|
```
|
||||||
|
|
||||||
|
Code style will be automatically fixed by php-cs-fixer.
|
||||||
|
|
||||||
|
No database is needed to run the tests.
|
||||||
56
check
Executable file
56
check
Executable file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
offer_run() {
|
||||||
|
read -p "For more output, run $1. Run it now (Y/n)? " run
|
||||||
|
|
||||||
|
case ${run:0:1} in
|
||||||
|
n|N )
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
* )
|
||||||
|
$1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (php-cs-fixer fix --dry-run --config=.php-cs-fixer.php > /dev/null 2>/dev/null); then
|
||||||
|
echo '✅ php-cs-fixer OK'
|
||||||
|
else
|
||||||
|
read -p "⚠️ php-cs-fixer found issues. Fix (Y/n)? " fix
|
||||||
|
case ${fix:0:1} in
|
||||||
|
n|N )
|
||||||
|
echo '❌ php-cs-fixer FAIL'
|
||||||
|
offer_run 'php-cs-fixer fix --config=.php-cs-fixer.php'
|
||||||
|
;;
|
||||||
|
* )
|
||||||
|
if (php-cs-fixer fix --config=.php-cs-fixer.php > /dev/null 2>/dev/null); then
|
||||||
|
echo '✅ php-cs-fixer OK'
|
||||||
|
else
|
||||||
|
echo '❌ php-cs-fixer FAIL'
|
||||||
|
offer_run 'php-cs-fixer fix --config=.php-cs-fixer.php'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (./vendor/bin/phpstan analyse > /dev/null 2>/dev/null); then
|
||||||
|
echo '✅ PHPStan OK'
|
||||||
|
else
|
||||||
|
echo '❌ PHPStan FAIL'
|
||||||
|
offer_run './vendor/bin/phpstan analyse'
|
||||||
|
fi
|
||||||
|
|
||||||
|
(MYSQL_PORT=3307 docker-compose up -d > /dev/null 2>/dev/null) || true
|
||||||
|
|
||||||
|
if (./vendor/bin/pest > /dev/null 2>/dev/null); then
|
||||||
|
echo '✅ PEST OK'
|
||||||
|
else
|
||||||
|
echo '❌ PEST FAIL'
|
||||||
|
offer_run './vendor/bin/pest'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo '=================='
|
||||||
|
echo '✅ Everything OK'
|
||||||
45
composer.json
Normal file
45
composer.json
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"name": "archtechx/money",
|
||||||
|
"description": "A lightweight package for handling money math in PHP.",
|
||||||
|
"type": "library",
|
||||||
|
"license": "MIT",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Samuel Štancl",
|
||||||
|
"email": "samuel@archte.ch"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ArchTech\\Money\\": "src/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php",
|
||||||
|
"src/Wireable.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"ArchTech\\Money\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.0",
|
||||||
|
"illuminate/support": "^8.24",
|
||||||
|
"archtechx/helpers": "^0.1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"orchestra/testbench": "^6.9",
|
||||||
|
"pestphp/pest": "^1.10",
|
||||||
|
"phpstan/phpstan": "^0.12.92",
|
||||||
|
"pestphp/pest-plugin-laravel": "^1.1",
|
||||||
|
"nunomaduro/larastan": "^0.7.10"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"ArchTech\\Money\\MoneyServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:5.7
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: password
|
||||||
|
MYSQL_DATABASE: main
|
||||||
|
MYSQL_USER: user
|
||||||
|
MYSQL_PASSWORD: password
|
||||||
|
MYSQL_TCP_PORT: ${MYSQL_PORT}
|
||||||
|
ports:
|
||||||
|
- "${MYSQL_PORT}:${MYSQL_PORT}"
|
||||||
26
phpstan.neon
Normal file
26
phpstan.neon
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
includes:
|
||||||
|
- ./vendor/nunomaduro/larastan/extension.neon
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
|
||||||
|
excludePaths:
|
||||||
|
- src/Wireable.php
|
||||||
|
|
||||||
|
level: 8
|
||||||
|
|
||||||
|
universalObjectCratesClasses:
|
||||||
|
- Illuminate\Routing\Route
|
||||||
|
|
||||||
|
ignoreErrors:
|
||||||
|
-
|
||||||
|
message: '#Livewire#'
|
||||||
|
paths:
|
||||||
|
- src/*
|
||||||
|
-
|
||||||
|
message: '#Unsafe usage of new static#'
|
||||||
|
paths:
|
||||||
|
- src/Currency.php
|
||||||
|
|
||||||
|
checkMissingIterableValueType: false
|
||||||
33
phpunit.xml
Normal file
33
phpunit.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" backupStaticAttributes="false" bootstrap="vendor/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
|
||||||
|
<coverage processUncoveredFiles="true">
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">./src</directory>
|
||||||
|
</include>
|
||||||
|
<report>
|
||||||
|
<clover outputFile="coverage/phpunit/clover.xml"/>
|
||||||
|
<html outputDirectory="coverage/phpunit/html" lowUpperBound="35" highLowerBound="70"/>
|
||||||
|
</report>
|
||||||
|
</coverage>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory suffix="Test.php">./tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<php>
|
||||||
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
<env name="APP_KEY" value="base64:uYlmYxcuuO7dC34yUn2hQcPu8PnlC98LTyOZg4fNAZU="/>
|
||||||
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
<env name="CACHE_DRIVER" value="redis"/>
|
||||||
|
<env name="MAIL_DRIVER" value="array"/>
|
||||||
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
|
||||||
|
<env name="DB_CONNECTION" value="testbench"/>
|
||||||
|
<env name="DB_DATABASE" value="main"/>
|
||||||
|
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
|
||||||
|
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
|
||||||
|
|
||||||
|
<env name="AWS_DEFAULT_REGION" value="us-west-2"/>
|
||||||
|
</php>
|
||||||
|
</phpunit>
|
||||||
45
src/Concerns/PersistsCurrency.php
Normal file
45
src/Concerns/PersistsCurrency.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/Concerns/RegistersCurrencies.php
Normal file
138
src/Concerns/RegistersCurrencies.php
Normal 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
18
src/Currencies/USD.php
Normal 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
186
src/Currency.php
Normal 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
82
src/CurrencyManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Exceptions/CurrencyDoesNotExistException.php
Normal file
15
src/Exceptions/CurrencyDoesNotExistException.php
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Exceptions/InvalidCurrencyException.php
Normal file
15
src/Exceptions/InvalidCurrencyException.php
Normal 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
261
src/Money.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/MoneyServiceProvider.php
Normal file
15
src/MoneyServiceProvider.php
Normal 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
25
src/PriceFormatter.php
Normal 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
13
src/Wireable.php
Normal 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
31
src/helpers.php
Normal 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);
|
||||||
|
}
|
||||||
18
tests/Currencies/CZK.php
Normal file
18
tests/Currencies/CZK.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace ArchTech\Money\Tests\Currencies;
|
||||||
|
|
||||||
|
use ArchTech\Money\Currency;
|
||||||
|
|
||||||
|
class CZK extends Currency
|
||||||
|
{
|
||||||
|
protected string $code = 'CZK';
|
||||||
|
protected string $name = 'Czech Crown';
|
||||||
|
protected float $rate = 25;
|
||||||
|
protected int $mathDecimals = 2;
|
||||||
|
protected int $displayDecimals = 0;
|
||||||
|
protected string $decimalSeparator = ',';
|
||||||
|
protected string $thousandsSeparator = '.';
|
||||||
|
protected int $rounding = 2;
|
||||||
|
protected string $suffix = ' Kč';
|
||||||
|
}
|
||||||
16
tests/Currencies/EUR.php
Normal file
16
tests/Currencies/EUR.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace ArchTech\Money\Tests\Currencies;
|
||||||
|
|
||||||
|
use ArchTech\Money\Currency;
|
||||||
|
|
||||||
|
class EUR extends Currency
|
||||||
|
{
|
||||||
|
protected string $code = 'EUR';
|
||||||
|
protected string $name = 'Euro';
|
||||||
|
protected float $rate = 0.9;
|
||||||
|
protected int $mathDecimals = 4;
|
||||||
|
protected int $displayDecimals = 2;
|
||||||
|
protected int $rounding = 0;
|
||||||
|
protected string $suffix = ' €';
|
||||||
|
}
|
||||||
11
tests/Pest.php
Normal file
11
tests/Pest.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use ArchTech\Money\Tests\TestCase;
|
||||||
|
use Pest\TestSuite;
|
||||||
|
|
||||||
|
uses(ArchTech\Money\Tests\TestCase::class)->in('Pest');
|
||||||
|
|
||||||
|
function pest(): TestCase
|
||||||
|
{
|
||||||
|
return TestSuite::getInstance()->test;
|
||||||
|
}
|
||||||
192
tests/Pest/CurrencyManagerTest.php
Normal file
192
tests/Pest/CurrencyManagerTest.php
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use ArchTech\Money\Currencies\USD;
|
||||||
|
use ArchTech\Money\Currency;
|
||||||
|
use ArchTech\Money\Tests\Currencies\CZK;
|
||||||
|
use ArchTech\Money\Tests\Currencies\EUR;
|
||||||
|
|
||||||
|
beforeEach(fn () => currencies()->reset());
|
||||||
|
|
||||||
|
test('only USD is loaded by default', function () {
|
||||||
|
expect(currencies()->all())
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toHaveKey('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('USD is the default currency', function () {
|
||||||
|
expect(currencies()->getDefault())->toBeInstanceOf(USD::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('USD is the current currency', function () {
|
||||||
|
expect(currencies()->getCurrent())->toBeInstanceOf(USD::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the default currency can be changed', function () {
|
||||||
|
currencies()->add([CZK::class, EUR::class]);
|
||||||
|
|
||||||
|
expect(currencies()->getDefault())->toBeInstanceOf(USD::class);
|
||||||
|
|
||||||
|
// Using class
|
||||||
|
currencies()->setDefault(CZK::class);
|
||||||
|
expect(currencies()->getDefault())->toBeInstanceOf(CZK::class);
|
||||||
|
|
||||||
|
// Using code
|
||||||
|
currencies()->setDefault('EUR');
|
||||||
|
expect(currencies()->getDefault())->toBeInstanceOf(EUR::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the current currency can be changed', function () {
|
||||||
|
currencies()->add([CZK::class, EUR::class]);
|
||||||
|
|
||||||
|
expect(currencies()->getCurrent())->toBeInstanceOf(USD::class);
|
||||||
|
|
||||||
|
// Using class
|
||||||
|
currencies()->setCurrent(CZK::class);
|
||||||
|
expect(currencies()->getCurrent())->toBeInstanceOf(CZK::class);
|
||||||
|
|
||||||
|
// Using code
|
||||||
|
currencies()->setCurrent('EUR');
|
||||||
|
expect(currencies()->getCurrent())->toBeInstanceOf(EUR::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the current currency can be persisted', function () {
|
||||||
|
currencies()->add([CZK::class, EUR::class]);
|
||||||
|
|
||||||
|
$store = [];
|
||||||
|
$resolverCalled = false;
|
||||||
|
|
||||||
|
currencies()->storeCurrentUsing(function ($currency) use (&$store) {
|
||||||
|
$store[] = $currency;
|
||||||
|
});
|
||||||
|
|
||||||
|
currencies()->resolveCurrentUsing(function () use (&$resolverCalled, &$store) {
|
||||||
|
$resolverCalled = true;
|
||||||
|
|
||||||
|
return last($store);
|
||||||
|
});
|
||||||
|
|
||||||
|
currencies()->setCurrent('CZK');
|
||||||
|
expect($store)->toBe(['CZK']);
|
||||||
|
expect(currencies()->getCurrent()->code())->toBe('CZK');
|
||||||
|
expect($resolverCalled)->toBeFalse();
|
||||||
|
|
||||||
|
currencies()->setCurrent('EUR');
|
||||||
|
expect($store)->toBe(['CZK', 'EUR']);
|
||||||
|
expect(currencies()->getCurrent()->code())->toBe('EUR');
|
||||||
|
expect($resolverCalled)->toBeFalse();
|
||||||
|
|
||||||
|
currencies()->forgetCurrent();
|
||||||
|
|
||||||
|
expect(currencies()->getCurrent()->code())->toBe('EUR');
|
||||||
|
expect($resolverCalled)->toBeTrue(); // got called
|
||||||
|
});
|
||||||
|
|
||||||
|
test('individual currencies can be removed', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveCount(2);
|
||||||
|
|
||||||
|
currencies()->remove('USD');
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('currencies can be cleared', function () {
|
||||||
|
currencies()->clear();
|
||||||
|
expect(currencies()->all())->toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('more currencies can be provided', function () {
|
||||||
|
currencies()->add([CZK::class, EUR::class]);
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate currencies get overriden', function () {
|
||||||
|
$customUSD = new USD(rate: 20);
|
||||||
|
|
||||||
|
currencies()->add($customUSD);
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveCount(1);
|
||||||
|
expect(currencies()->all())->toHaveKey('USD', $customUSD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the default currency can have any rate', function () {
|
||||||
|
// This tests that the default currency doesn't have to have a rate of 1
|
||||||
|
currencies()->add([CZK::class, EUR::class]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
money(1500, 'CZK')->convertTo(EUR::class)->formatted()
|
||||||
|
)->toBe('0.54 €');
|
||||||
|
|
||||||
|
currencies()->setDefault(CZK::class);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
money(1500, 'CZK')->convertTo(EUR::class)->formatted()
|
||||||
|
)->toBe('0.54 €');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the getCode method accepts any currency format', function () {
|
||||||
|
expect(currencies()->getCode(USD::class))->toBe('USD');
|
||||||
|
expect(currencies()->getCode(new USD))->toBe('USD');
|
||||||
|
expect(currencies()->getCode('USD'))->toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('array currencies get converted to anonymous Currency objects', function () {
|
||||||
|
currencies()->add((new CZK)->toArray());
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveKey('CZK');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add accepts any currency format', function () {
|
||||||
|
currencies()->clear();
|
||||||
|
|
||||||
|
// Class instances
|
||||||
|
currencies()->add($instance = new CZK);
|
||||||
|
expect(currencies()->all())->toHaveKey('CZK', $instance);
|
||||||
|
|
||||||
|
// Anonymous class instances
|
||||||
|
currencies()->add($gbp = new Currency(code: 'GBP', rate: 0.8, name: 'British Pound'));
|
||||||
|
expect(currencies()->all())->toHaveKey('GBP', $gbp);
|
||||||
|
|
||||||
|
// Class names
|
||||||
|
currencies()->add(USD::class);
|
||||||
|
expect(currencies()->all())->toHaveKey('USD');
|
||||||
|
|
||||||
|
// Arrays
|
||||||
|
currencies()->add((new CZK)->toArray());
|
||||||
|
expect(currencies()->all())->toHaveKey('CZK');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the add method accepts an array of named Currency instances', function () {
|
||||||
|
currencies()->add([
|
||||||
|
new CZK,
|
||||||
|
new EUR,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveKeys(['CZK', 'EUR']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the add method accepts an array of anonymous Currency instances', function () {
|
||||||
|
currencies()->add([
|
||||||
|
new Currency(code: 'USD', rate: 1, name: 'United States Dollar'),
|
||||||
|
new Currency(code: 'EUR', rate: 0.8, name: 'Euro'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveKeys(['USD', 'EUR']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the add method accepts an array of Currency class strings', function () {
|
||||||
|
currencies()->add([USD::class, EUR::class]);
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveKeys(['USD', 'EUR']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the add method accepts an array of currency arrays', function () {
|
||||||
|
currencies()->add([
|
||||||
|
(new CZK)->toArray(),
|
||||||
|
(new EUR)->toArray(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(currencies()->all())->toHaveKeys(['USD', 'CZK', 'EUR']);
|
||||||
|
});
|
||||||
42
tests/Pest/CurrencyTest.php
Normal file
42
tests/Pest/CurrencyTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use ArchTech\Money\Currency;
|
||||||
|
use ArchTech\Money\Exceptions\InvalidCurrencyException;
|
||||||
|
use ArchTech\Money\Tests\Currencies\CZK;
|
||||||
|
|
||||||
|
test("a currency is invalid if it doesn't have a name", function () {
|
||||||
|
pest()->expectException(InvalidCurrencyException::class);
|
||||||
|
|
||||||
|
new Currency(rate: 2.0, code: 'CZK');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a currency is invalid if it doesn't have a code", function () {
|
||||||
|
pest()->expectException(InvalidCurrencyException::class);
|
||||||
|
|
||||||
|
new Currency(rate: 2.0, name: 'Czech Crown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('currencies can be serialized to JSON', function () {
|
||||||
|
expect(json_encode(new CZK))->json()->toBe([
|
||||||
|
'code' => 'CZK',
|
||||||
|
'name' => 'Czech Crown',
|
||||||
|
'rate' => 25,
|
||||||
|
'prefix' => '',
|
||||||
|
'suffix' => ' Kč',
|
||||||
|
'mathDecimals' => 2,
|
||||||
|
'displayDecimals' => 0,
|
||||||
|
'rounding' => 2,
|
||||||
|
'decimalSeparator' => ',',
|
||||||
|
'thousandsSeparator' => '.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('currencies can be created from JSON', function () {
|
||||||
|
$original = new Currency(code: 'GBP', rate: 0.8, name: 'British Pound');
|
||||||
|
|
||||||
|
$json = json_encode($original);
|
||||||
|
|
||||||
|
$new = Currency::fromJson($json);
|
||||||
|
|
||||||
|
expect($original->toArray())->toBe($new->toArray());
|
||||||
|
});
|
||||||
48
tests/Pest/FormattingTest.php
Normal file
48
tests/Pest/FormattingTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use ArchTech\Money\CurrencyManager;
|
||||||
|
use ArchTech\Money\Currencies\USD;
|
||||||
|
use ArchTech\Money\Currency;
|
||||||
|
use ArchTech\Money\Money;
|
||||||
|
use ArchTech\Money\Tests\Currencies\CZK;
|
||||||
|
use ArchTech\Money\Tests\Currencies\EUR;
|
||||||
|
|
||||||
|
beforeEach(fn () => currencies()->add([CZK::class, EUR::class]));
|
||||||
|
|
||||||
|
test('prefixes are applied', function () {
|
||||||
|
expect(Money::fromDecimal(10.00, USD::class)->formatted())->toBe('$10.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suffixes are applied', function () {
|
||||||
|
expect(Money::fromDecimal(10.00, CZK::class)->formatted())->toBe('10 Kč');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decimals can be applied even if the decimal points are zero', function () {
|
||||||
|
expect(Money::fromDecimal(10.00, CZK::class)->formatted())->toBe('10 Kč');
|
||||||
|
|
||||||
|
expect(Money::fromDecimal(10.00, EUR::class)->formatted())->toBe('10.00 €');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decimals have a separator', function () {
|
||||||
|
expect(Money::fromDecimal(10.34, EUR::class)->formatted())->toBe('10.34 €');
|
||||||
|
|
||||||
|
expect(Money::fromDecimal(10.34, CZK::class)->rawFormatted())->toBe('10,34 Kč');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thousands have a separator', function () {
|
||||||
|
currencies()->add(new Currency(
|
||||||
|
code: 'FOO',
|
||||||
|
name: 'Foo Currency',
|
||||||
|
thousandsSeparator: ' ',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(Money::fromDecimal(1234567.89, 'USD')->formatted())->toBe('$1,234,567.89');
|
||||||
|
expect(Money::fromDecimal(1234567.89, 'EUR')->formatted())->toBe('1,234,567.89 €');
|
||||||
|
expect(Money::fromDecimal(1234567.89, 'CZK')->formatted())->toBe('1.234.568 Kč');
|
||||||
|
expect(Money::fromDecimal(1234567.89, 'FOO')->formatted())->toBe('1 234 567.89');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the format method accepts overrides', function () {
|
||||||
|
expect(Money::fromDecimal(10.45)->formatted(['decimalSeparator' => ',', 'prefix' => '$$$']))->toBe('$$$10,45');
|
||||||
|
expect(Money::fromDecimal(10.45)->formatted(decimalSeparator: ',', suffix: ' USD'))->toBe('$10,45 USD');
|
||||||
|
});
|
||||||
53
tests/Pest/HelperTest.php
Normal file
53
tests/Pest/HelperTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use ArchTech\Money\Currencies\USD;
|
||||||
|
use ArchTech\Money\Money;
|
||||||
|
use ArchTech\Money\Tests\Currencies\CZK;
|
||||||
|
use ArchTech\Money\Tests\Currencies\EUR;
|
||||||
|
|
||||||
|
test('the currency helper can be used to fetch the current currency', function () {
|
||||||
|
expect(currency())->toBe(currencies()->getCurrent());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the currency helper can be used to fetch a specific currency using its code', function () {
|
||||||
|
currencies()->add(EUR::class);
|
||||||
|
|
||||||
|
expect(currency('EUR'))->toBeInstanceOf(EUR::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the currency helper can be used to fetch a specific currency using its class', function () {
|
||||||
|
currencies()->add(EUR::class);
|
||||||
|
|
||||||
|
expect(currency(EUR::class))->toBeInstanceOf(EUR::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the money helper creates a new Money instance', function () {
|
||||||
|
expect(money(200))->toBeInstanceOf(Money::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the money helper accepts a string currency', function () {
|
||||||
|
currencies()->add([EUR::class, CZK::class]);
|
||||||
|
|
||||||
|
expect(money(200, 'EUR')->currency())->toBeInstanceOf(EUR::class);
|
||||||
|
expect(money(200, 'CZK')->currency())->toBeInstanceOf(CZK::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the money helper accepts a class currency', function () {
|
||||||
|
currencies()->add([EUR::class, CZK::class]);
|
||||||
|
|
||||||
|
expect(money(200, EUR::class)->currency())->toBeInstanceOf(EUR::class);
|
||||||
|
expect(money(200, CZK::class)->currency())->toBeInstanceOf(CZK::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the money helper accepts a currency object', function () {
|
||||||
|
currencies()->add([EUR::class, CZK::class]);
|
||||||
|
|
||||||
|
expect(money(200, new EUR)->currency())->toBeInstanceOf(EUR::class);
|
||||||
|
expect(money(200, new CZK)->currency())->toBeInstanceOf(CZK::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the money helper falls back to the default currency', function () {
|
||||||
|
currencies()->add(EUR::class);
|
||||||
|
|
||||||
|
expect(money(200)->currency())->toBeInstanceOf(USD::class);
|
||||||
|
});
|
||||||
44
tests/Pest/MathTest.php
Normal file
44
tests/Pest/MathTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use ArchTech\Money\CurrencyManager;
|
||||||
|
use ArchTech\Money\Tests\Currencies\CZK;
|
||||||
|
|
||||||
|
test('money is rounded to its math decimals after each operation', function () {
|
||||||
|
expect(
|
||||||
|
money(33)->times(3.03)->value() // 0.9999
|
||||||
|
)->toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('operations use the math decimals even if the display decimals are different', function () {
|
||||||
|
currencies()->add(CZK::class); // uses 0 display decimals
|
||||||
|
|
||||||
|
$money = money(33, 'CZK')->times(3); // 0.99
|
||||||
|
expect($money->value())->toBe(99);
|
||||||
|
expect($money->formatted())->toBe('1 Kč');
|
||||||
|
|
||||||
|
$money = money(33, 'CZK')->times(3.03); // 0.9999
|
||||||
|
expect($money->value())->toBe(100);
|
||||||
|
expect($money->formatted())->toBe('1 Kč');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rounding is not applied between operations', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
// 99
|
||||||
|
$money = money(33, 'CZK')->times(3);
|
||||||
|
expect($money->value())->toBe(99);
|
||||||
|
expect($money->rounded()->value())->toBe(100);
|
||||||
|
expect($money->rounding())->toBe(1);
|
||||||
|
|
||||||
|
// 139
|
||||||
|
$money = $money->add(40);
|
||||||
|
expect($money->value())->toBe(139);
|
||||||
|
expect($money->rounded()->value())->toBe(100);
|
||||||
|
expect($money->rounding())->toBe(-39);
|
||||||
|
|
||||||
|
// 151
|
||||||
|
$money = $money->add(12);
|
||||||
|
expect($money->value())->toBe(151);
|
||||||
|
expect($money->rounded()->value())->toBe(200);
|
||||||
|
expect($money->rounding())->toBe(49);
|
||||||
|
});
|
||||||
232
tests/Pest/MoneyTest.php
Normal file
232
tests/Pest/MoneyTest.php
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use ArchTech\Money\CurrencyManager;
|
||||||
|
use ArchTech\Money\Currencies\USD;
|
||||||
|
use ArchTech\Money\Currency;
|
||||||
|
use ArchTech\Money\Money;
|
||||||
|
use ArchTech\Money\Tests\Currencies\CZK;
|
||||||
|
use ArchTech\Money\Tests\Currencies\EUR;
|
||||||
|
|
||||||
|
test('Money value is immutable', function () {
|
||||||
|
pest()->expectError();
|
||||||
|
|
||||||
|
$money = money(100);
|
||||||
|
|
||||||
|
$money->value = 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Money currency is immutable', function () {
|
||||||
|
pest()->expectError();
|
||||||
|
|
||||||
|
$money = money(100);
|
||||||
|
|
||||||
|
$money->currency = 'EUR';
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be created from a decimal value', function () {
|
||||||
|
$money = Money::fromDecimal(10.0, 'USD');
|
||||||
|
|
||||||
|
expect($money->value())->toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be converted to decimals', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
$money = Money::fromDecimal(10.0, 'USD');
|
||||||
|
expect($money->value())->toBe(1000);
|
||||||
|
expect($money->decimal())->toBe(10.0);
|
||||||
|
|
||||||
|
$money = Money::fromDecimal(15.0, 'CZK');
|
||||||
|
expect($money->value())->toBe(1500);
|
||||||
|
expect($money->decimal())->toBe(15.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be added in base value', function () {
|
||||||
|
$money = money(100);
|
||||||
|
$money = $money->add(200);
|
||||||
|
|
||||||
|
expect($money->value())->toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be added from another Money instance', function () {
|
||||||
|
$money = money(100);
|
||||||
|
$money = $money->addMoney(money(500));
|
||||||
|
|
||||||
|
expect($money->value())->toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be added from a Money instance with a different currency', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
$usd = Money::fromDecimal(10.0, 'USD');
|
||||||
|
$czk = Money::fromDecimal(100.0, 'CZK'); // 4 USD
|
||||||
|
|
||||||
|
$usd = $usd->addMoney($czk);
|
||||||
|
|
||||||
|
expect($usd->decimal())->toBe(14.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be subtracted in base value', function () {
|
||||||
|
$money = money(300);
|
||||||
|
$money = $money->subtract(200);
|
||||||
|
|
||||||
|
expect($money->value())->toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be subtracted by another Money instance', function () {
|
||||||
|
$money = money(500);
|
||||||
|
$money = $money->subtractMoney(money(100));
|
||||||
|
|
||||||
|
expect($money->value())->toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be subtracted by a Money instance with a different currency', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
$usd = Money::fromDecimal(10.0, 'USD');
|
||||||
|
$czk = Money::fromDecimal(100.0, 'CZK'); // 4 USD
|
||||||
|
|
||||||
|
$usd = $usd->subtractMoney($czk);
|
||||||
|
|
||||||
|
expect($usd->decimal())->toBe(6.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be multiplied', function () {
|
||||||
|
expect(money(100)->multiplyBy(2.5)->value())->toBe(250);
|
||||||
|
|
||||||
|
expect(money(100)->times(2.5)->value())->toBe(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be divided', function () {
|
||||||
|
expect(money(100)->divideBy(10)->value())->toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fees can be added to and subtracted from money', function () {
|
||||||
|
$money = Money::fromDecimal(10.0);
|
||||||
|
|
||||||
|
expect($money->addFee(0.1)->decimal())->toBe(11.0);
|
||||||
|
expect($money->subtractFee(0.1)->decimal())->toBe(9.09); // 10/1.1
|
||||||
|
});
|
||||||
|
|
||||||
|
test('taxes can be added and subtracted from money', function () {
|
||||||
|
currencies()->add([CZK::class]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Money::fromDecimal(100.0, 'CZK')->addTax(0.21)->decimal()
|
||||||
|
)->toBe(121.0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Money::fromDecimal(121.0, 'CZK')->subtractTax(0.21)->decimal()
|
||||||
|
)->toBe(100.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be converted to a different currency', function () {
|
||||||
|
currencies()->add([CZK::class]);
|
||||||
|
|
||||||
|
$money = Money::fromDecimal(100.0);
|
||||||
|
|
||||||
|
expect($money->currency())->toBeInstanceOf(USD::class);
|
||||||
|
expect($money->currency()->code())->toBe('USD');
|
||||||
|
|
||||||
|
$money = $money->convertTo(CZK::class);
|
||||||
|
|
||||||
|
expect($money->decimal())->toBe(2500.0);
|
||||||
|
expect($money->currency())->toBeInstanceOf(CZK::class);
|
||||||
|
expect($money->currency()->code())->toBe('CZK');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be formatted', function () {
|
||||||
|
expect(
|
||||||
|
Money::fromDecimal(10.00, USD::class)->formatted()
|
||||||
|
)->toBe('$10.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be formatted without rounding', function () {
|
||||||
|
currencies()->add([CZK::class]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Money::fromDecimal(10.34, CZK::class)->rawFormatted()
|
||||||
|
)->toBe('10,34 Kč');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converting money to a string returns the formatted string', function () {
|
||||||
|
expect(
|
||||||
|
(string) Money::fromDecimal(10.00, USD::class)
|
||||||
|
)->toBe('$10.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be converted to default currency value', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
$money = money(5000, CZK::class);
|
||||||
|
|
||||||
|
expect($money->valueInDefaultCurrency())->toBe(
|
||||||
|
$money->convertTo(currencies()->getDefault())->value()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can have rounding', function () {
|
||||||
|
currencies()->add([CZK::class, EUR::class]);
|
||||||
|
|
||||||
|
expect(money(12340, 'CZK')->rounding())->toBe(-40);
|
||||||
|
expect(money(12340, 'EUR')->rounding())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be rounded with a custom precision', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
expect(money(12340, 'CZK')->rounding())->toBe(-40);
|
||||||
|
expect(money(12340, 'CZK')->rounded()->value())->toBe(12300);
|
||||||
|
expect(money(12340, 'CZK')->rounded(3)->value())->toBe(12000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be compared', function () {
|
||||||
|
expect(
|
||||||
|
money(123)->equals(money(123))
|
||||||
|
)->toBeTrue();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
money(123)->equals(money(456))
|
||||||
|
)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be compared with different currencies', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
money(123, 'USD')->equals(money(123)->convertTo(CZK::class))
|
||||||
|
)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the is method compares both the value and the currency', function () {
|
||||||
|
currencies()->add([CZK::class, EUR::class]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
money(123, 'EUR')->is(money(123)->convertTo(CZK::class))
|
||||||
|
)->toBeFalse();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
money(123, 'EUR')->is(money(123)->convertTo(CZK::class)->convertTo(EUR::class))
|
||||||
|
)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be serialized to JSON', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
$money = Money::fromDecimal(22, 'CZK');
|
||||||
|
|
||||||
|
expect(json_encode($money))->json()->toBe(['value' => 2200, 'currency' => 'CZK']);
|
||||||
|
expect($money->toJson())->json()->toBe(['value' => 2200, 'currency' => 'CZK']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('money can be instantiated from JSON', function () {
|
||||||
|
currencies()->add(CZK::class);
|
||||||
|
|
||||||
|
$original = Money::fromDecimal(22, 'CZK');
|
||||||
|
|
||||||
|
$json = json_encode($original);
|
||||||
|
|
||||||
|
$new = Money::fromJson($json)->toArray();
|
||||||
|
|
||||||
|
expect($new)->toBe($original->toArray());
|
||||||
|
});
|
||||||
15
tests/TestCase.php
Normal file
15
tests/TestCase.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace ArchTech\Money\Tests;
|
||||||
|
use Orchestra\Testbench\TestCase as TestbenchTestCase;
|
||||||
|
use ArchTech\Money\MoneyServiceProvider;
|
||||||
|
|
||||||
|
class TestCase extends TestbenchTestCase
|
||||||
|
{
|
||||||
|
protected function getPackageProviders($app)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
MoneyServiceProvider::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue