├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── composer.json └── src ├── Concerns ├── PersistsCurrency.php └── RegistersCurrencies.php ├── Currencies └── USD.php ├── Currency.php ├── CurrencyManager.php ├── Exceptions ├── CannotExtractCurrencyException.php ├── CurrencyDoesNotExistException.php └── InvalidCurrencyException.php ├── Money.php ├── MoneyServiceProvider.php ├── PriceFormatter.php ├── Wireable.php └── helpers.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | ['syntax' => 'short'], 8 | 'binary_operator_spaces' => [ 9 | 'default' => 'single_space', 10 | 'operators' => [ 11 | '=>' => null, 12 | '|' => 'no_space', 13 | ] 14 | ], 15 | 'blank_line_after_namespace' => true, 16 | 'blank_line_after_opening_tag' => true, 17 | 'no_superfluous_phpdoc_tags' => true, 18 | 'blank_line_before_statement' => [ 19 | 'statements' => ['return'] 20 | ], 21 | 'braces' => true, 22 | 'cast_spaces' => true, 23 | 'class_definition' => true, 24 | 'concat_space' => [ 25 | 'spacing' => 'one' 26 | ], 27 | 'declare_equal_normalize' => true, 28 | 'elseif' => true, 29 | 'encoding' => true, 30 | 'full_opening_tag' => true, 31 | 'declare_strict_types' => true, 32 | 'fully_qualified_strict_types' => true, // added by Shift 33 | 'function_declaration' => true, 34 | 'function_typehint_space' => true, 35 | 'heredoc_to_nowdoc' => true, 36 | 'include' => true, 37 | 'increment_style' => ['style' => 'post'], 38 | 'indentation_type' => true, 39 | 'linebreak_after_opening_tag' => true, 40 | 'line_ending' => true, 41 | 'lowercase_cast' => true, 42 | 'constant_case' => true, 43 | 'lowercase_keywords' => true, 44 | 'lowercase_static_reference' => true, // added from Symfony 45 | 'magic_method_casing' => true, // added from Symfony 46 | 'magic_constant_casing' => true, 47 | 'method_argument_space' => true, 48 | 'native_function_casing' => true, 49 | 'no_alias_functions' => true, 50 | 'no_extra_blank_lines' => [ 51 | 'tokens' => [ 52 | 'extra', 53 | 'throw', 54 | 'use', 55 | 'use_trait', 56 | ] 57 | ], 58 | 'no_blank_lines_after_class_opening' => true, 59 | 'no_blank_lines_after_phpdoc' => true, 60 | 'no_closing_tag' => true, 61 | 'no_empty_phpdoc' => true, 62 | 'no_empty_statement' => true, 63 | 'no_leading_import_slash' => true, 64 | 'no_leading_namespace_whitespace' => true, 65 | 'no_mixed_echo_print' => [ 66 | 'use' => 'echo' 67 | ], 68 | 'no_multiline_whitespace_around_double_arrow' => true, 69 | 'multiline_whitespace_before_semicolons' => [ 70 | 'strategy' => 'no_multi_line' 71 | ], 72 | 'no_short_bool_cast' => true, 73 | 'no_singleline_whitespace_before_semicolons' => true, 74 | 'no_spaces_after_function_name' => true, 75 | 'no_spaces_around_offset' => true, 76 | 'no_spaces_inside_parenthesis' => true, 77 | 'no_trailing_comma_in_list_call' => true, 78 | 'no_trailing_comma_in_singleline_array' => true, 79 | 'no_trailing_whitespace' => true, 80 | 'no_trailing_whitespace_in_comment' => true, 81 | 'no_unneeded_control_parentheses' => true, 82 | 'no_unreachable_default_argument_value' => true, 83 | 'no_useless_return' => true, 84 | 'no_whitespace_before_comma_in_array' => true, 85 | 'no_whitespace_in_blank_line' => true, 86 | 'normalize_index_brace' => true, 87 | 'not_operator_with_successor_space' => true, 88 | 'object_operator_without_whitespace' => true, 89 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 90 | 'phpdoc_indent' => true, 91 | 'general_phpdoc_tag_rename' => true, 92 | 'phpdoc_no_access' => true, 93 | 'phpdoc_no_package' => true, 94 | 'phpdoc_no_useless_inheritdoc' => true, 95 | 'phpdoc_scalar' => true, 96 | 'phpdoc_single_line_var_spacing' => true, 97 | 'phpdoc_summary' => true, 98 | 'phpdoc_to_comment' => false, 99 | 'phpdoc_trim' => true, 100 | 'phpdoc_types' => true, 101 | 'phpdoc_var_without_name' => true, 102 | 'psr_autoloading' => true, 103 | 'self_accessor' => true, 104 | 'short_scalar_cast' => true, 105 | 'simplified_null_return' => false, // disabled by Shift 106 | 'single_blank_line_at_eof' => true, 107 | 'single_blank_line_before_namespace' => true, 108 | 'single_class_element_per_statement' => true, 109 | 'single_import_per_statement' => true, 110 | 'single_line_after_imports' => true, 111 | 'no_unused_imports' => true, 112 | 'single_line_comment_style' => [ 113 | 'comment_types' => ['hash'] 114 | ], 115 | 'single_quote' => true, 116 | 'space_after_semicolon' => true, 117 | 'standardize_not_equals' => true, 118 | 'switch_case_semicolon_to_colon' => true, 119 | 'switch_case_space' => true, 120 | 'ternary_operator_spaces' => true, 121 | 'trailing_comma_in_multiline' => true, 122 | 'trim_array_spaces' => true, 123 | 'unary_operator_spaces' => true, 124 | 'whitespace_after_comma_in_array' => true, 125 | ]; 126 | 127 | $project_path = getcwd(); 128 | $finder = Finder::create() 129 | ->in([ 130 | $project_path . '/src', 131 | ]) 132 | ->name('*.php') 133 | ->notName('*.blade.php') 134 | ->ignoreDotFiles(true) 135 | ->ignoreVCS(true); 136 | 137 | return (new Config()) 138 | ->setFinder($finder) 139 | ->setRules($rules) 140 | ->setRiskyAllowed(true) 141 | ->setUsingCache(true); 142 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ArchTech Development, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Money 2 | 3 | A simple package for working with money. 4 | 5 | Main features: 6 | - Simple API 7 | - Livewire integration 8 | - Custom currency support 9 | - Highly customizable formatting 10 | - Rounding logic for compliant accounting 11 | 12 | This package is our implementation of the [Money pattern](https://martinfowler.com/eaaCatalog/money.html). 13 | 14 | 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). 15 | 16 | ## Installation 17 | 18 | Require the package via composer: 19 | ```sh 20 | composer require archtechx/money 21 | ``` 22 | # Usage 23 | 24 | The package has two main classes: 25 | - `Money` which represents monetary values 26 | - `Currency` which is extended by the currencies that you're using 27 | 28 | 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. 29 | 30 | ## Money 31 | 32 | **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: 33 | 34 | ```php 35 | // Incorrect 36 | $money = money(1500); 37 | $money->times(3); // ❌ 38 | $money->value(); // 1500 39 | 40 | // Correct 41 | $money = money(1500); 42 | $money = $money->times(3); // ✅ 43 | $money->value(); // 4500 44 | ``` 45 | 46 | ### Creating `Money` instances 47 | ```php 48 | // Using cents 49 | $money = money(1500); // $15.00; default currency 50 | $money = money(1500, 'EUR'); // 15.00 € 51 | $money = money(2000, new USD); // $20.00 52 | $money = money(3000, CZK::class); // 30 Kč 53 | 54 | // Using decimals 55 | $money = Money::fromDecimal(15.00, 'EUR'); // 15.00 € 56 | $money = Money::fromDecimal(20.00, new USD); // $20.00 57 | $money = Money::fromDecimal(30.00, CZK::class); // 30 Kč 58 | ``` 59 | 60 | ### Arithmetics 61 | 62 | ```php 63 | // Addition 64 | $money = money(1000); 65 | $money = $money->add(500); 66 | $money->value(); // 1500 67 | 68 | // Subtraction 69 | $money = money(1000); 70 | $money = $money->subtract(500); 71 | $money->value(); // 500 72 | 73 | // Multiplication 74 | $money = money(1000); 75 | $money = $money->multiplyBy(2); // alias: ->times() 76 | $money->value(); // 2000 77 | 78 | // Division 79 | $money = money(1000); 80 | $money = $money->divideBy(2); 81 | $money->value(); // 500 82 | ``` 83 | 84 | ### Converting money to a different currency 85 | 86 | ```php 87 | $money = money(2200); 88 | $money->convertTo(CZK::class); 89 | ``` 90 | 91 | ### Comparing money instances 92 | 93 | **Equality of monetary value** 94 | ```php 95 | // Assuming CZK is 25:1 USD 96 | 97 | // ✅ true 98 | money(100, USD::class)->equals(money(100, USD::class)); 99 | 100 | // ❌ false 101 | money(100, USD::class)->equals(money(200, USD::class)); 102 | 103 | // ✅ true 104 | money(100, USD::class)->equals(money(2500, CZK::class)); 105 | 106 | // ❌ false 107 | money(100, USD::class)->equals(money(200, CZK::class)); 108 | ``` 109 | 110 | **Equality of monetary value AND currency** 111 | ```php 112 | // Assuming CZK is 25:1 USD 113 | 114 | // ✅ true 115 | money(100, USD::class)->is(money(100, USD::class)); 116 | 117 | // ❌ false: different monetary value 118 | money(100, USD::class)->is(money(200, USD::class)); 119 | 120 | // ❌ false: different currency 121 | money(100, USD::class)->is(money(2500, CZK::class)); 122 | 123 | // ❌ false: different currency AND monetary value 124 | money(100, USD::class)->is(money(200, CZK::class)); 125 | ``` 126 | 127 | ### Adding fees 128 | 129 | You can use the `addFee()` or `addTax()` methods to add a % fee to the money: 130 | ```php 131 | $money = money(1000); 132 | $money = $money->addTax(20.0); // 20% 133 | $money->value(); // 1200 134 | ``` 135 | 136 | ### Accessing the decimal value 137 | 138 | ```php 139 | $money = Money::fromDecimal(100.0, new USD); 140 | $money->value(); // 10000 141 | $money->decimal(); // 100.0 142 | ``` 143 | 144 | ### Formatting money 145 | 146 | You can format money using the `->formatted()` method. It takes [display decimals](#display-decimals) into consideration. 147 | 148 | ```php 149 | $money = Money::fromDecimal(40.25, USD::class); 150 | $money->formatted(); // $40.25 151 | ``` 152 | 153 | The method optionally accepts overrides for the [currency specification](#currency-logic): 154 | ```php 155 | $money = Money::fromDecimal(40.25, USD::class); 156 | 157 | // $ 40.25 USD 158 | $money->formatted(decimalSeparator: ',', prefix: '$ ', suffix: ' USD'); 159 | ``` 160 | 161 | The overrides can also be passed as an array: 162 | ```php 163 | $money = Money::fromDecimal(40.25, USD::class); 164 | 165 | // $ 40.25 USD 166 | $money->formatted(['decimalSeparator' => ',', 'prefix' => '$ ', 'suffix' => ' USD']); 167 | ``` 168 | 169 | There's also `->rawFormatted()` if you wish to use [math decimals](#math-decimals) instead of [display decimals](#display-decimals). 170 | ```php 171 | $money = Money::new(123456, CZK::class); 172 | $money->rawFormatted(); // 1 234,56 Kč 173 | ``` 174 | 175 | Converting the formatted value back to the `Money` instance is also possible. The package tries to extract the currency from the provided string: 176 | ```php 177 | $money = money(1000); 178 | $formatted = $money->formatted(); // $10.00 179 | $fromFormatted = Money::fromFormatted($formatted); 180 | $fromFormatted->is($money); // true 181 | ``` 182 | 183 | If you had passed overrides while [formatting the money instance](#formatting-money), the same can passed to this method. 184 | ```php 185 | $money = money(1000); 186 | $formatted = $money->formatted(['prefix' => '$ ', 'suffix' => ' USD']); // $ 10.00 USD 187 | $fromFormatted = Money::fromFormatted($formatted, USD::class, ['prefix' => '$ ', 'suffix' => ' USD']); 188 | $fromFormatted->is($money); // true 189 | ``` 190 | 191 | Notes: 192 | 1) If currency is not specified and none of the currencies match the prefix and suffix, an exception will be thrown. 193 | 2) If currency is not specified and multiple currencies use the same prefix and suffix, an exception will be thrown. 194 | 3) `fromFormatted()` misses the cents if the [math decimals](#math-decimals) are greater than [display decimals](#display-decimals). 195 | 196 | ### Rounding money 197 | 198 | 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: 199 | 200 | ```php 201 | $money = Money::fromDecimal(3.30, CZK::class); 202 | $money->value(); // 330 203 | $money->formatted(); // 3 Kč 204 | 205 | $money = $money->times(3); 206 | $money->value(); // 990 207 | $money->formatted(); // 10 Kč 208 | ``` 209 | 210 | 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`. 211 | 212 | 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. 213 | 214 | #### Getting the used rounding 215 | 216 | For that use case, our package lets you get the rounding difference using a simple method call: 217 | ```php 218 | $money = Money::fromDecimal(9.90, CZK::class); 219 | $money->decimal(); // 9.90 220 | $money->formatted(); // 10 Kč 221 | $money->rounding(); // +0.10 Kč = 10 222 | 223 | $money = Money::fromDecimal(3.30, CZK::class); 224 | $money->decimal(); // 3.30 225 | $money->formatted(); // 3 Kč 226 | $money->rounding(); // -0.30 Kč = -30 227 | ``` 228 | 229 | #### Applying rounding to money 230 | 231 | ```php 232 | // Using the currency rounding 233 | $money = Money::fromDecimal(9.90, CZK::class); 234 | $money->decimal(); // 9.90 235 | $money = $money->rounded(); // currency rounding 236 | $money->decimal(); // 10.0 237 | 238 | // Using custom rounding 239 | $money = Money::fromDecimal(2.22, USD::class); 240 | $money->decimal(); // 2.22 241 | $money = $money->rounded(1); // custom rounding: 1 decimal 242 | $money->decimal(); // 2.20 243 | ``` 244 | 245 | ## Currencies 246 | 247 | To work with the registered currencies, use the bound `CurrencyManager` instance, accessible using the `currencies()` helper. 248 | 249 | ### Creating a currency 250 | 251 | This package provides only USD currency by default. 252 | 253 | You can create a currency using one of the multiple supported syntaxes. 254 | ```php 255 | // anonymous Currency object 256 | $currency = new Currency( 257 | code: 'FOO', 258 | name: 'Foo currency', 259 | rate: 1.8, 260 | prefix: '# ', 261 | suffix: ' FOO', 262 | ); 263 | 264 | // array 265 | $currency = [ 266 | 'code' => 'FOO', 267 | 'name' => 'Foo currency', 268 | 'rate' => 1.8, 269 | 'prefix' => '# ', 270 | 'suffix' => ' FOO', 271 | ]; 272 | 273 | // class 274 | class FOO extends Currency 275 | { 276 | protected string $code = 'FOO'; 277 | protected string $name = 'Foo currency'; 278 | protected float $rate = 1.8; 279 | protected string $prefix = '# '; 280 | protected string $suffix = ' FOO'; 281 | } 282 | ``` 283 | 284 | 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: 285 | 1. The code of the currency (e.g. `USD`) 286 | 2. The name of the currency (e.g. `United States Dollar`) 287 | 288 | ### Adding a currency 289 | 290 | Register a new currency: 291 | ```php 292 | currencies()->add(new USD); 293 | currencies()->add(USD::class); 294 | currencies()->add($currency); // object or array 295 | ``` 296 | 297 | ### Removing a specific currency 298 | 299 | To remove a specific currency, you can use the `remove()` method: 300 | ```php 301 | currencies()->remove('USD'); 302 | currencies()->remove(USD::class); 303 | ``` 304 | 305 | ### Removing all currencies 306 | 307 | To remove all currencies, you can use the `clear()` method: 308 | ```php 309 | currencies()->clear(); 310 | ``` 311 | 312 | ### Resetting currencies 313 | 314 | Can be useful in tests. This reverts all your changes and makes the `CurrencyManager` use `USD` as the default currency. 315 | 316 | ```php 317 | currencies()->reset(); 318 | ``` 319 | 320 | ### Currency logic 321 | 322 | Currencies can have the following properties: 323 | ```php 324 | protected string $code = null; 325 | protected string $name = null; 326 | protected float $rate = null; 327 | protected string $prefix = null; 328 | protected string $suffix = null; 329 | protected int $mathDecimals = null; 330 | protected int $displayDecimals = null; 331 | protected int $rounding = null; 332 | protected string $decimalSeparator = null; 333 | protected string $thousandsSeparator = null; 334 | ``` 335 | 336 | 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: 337 | 338 | ```php 339 | public function rate(): float 340 | { 341 | return cache()->remember("{$this->code}.rate", 3600, function () { 342 | return Http::get("https://api.currency.service/rate/USD/{$this->code}"); 343 | }); 344 | } 345 | ``` 346 | 347 | ### Setting the default currency 348 | 349 | You can set the [default currency](#default-currency) using the `setDefault()` method: 350 | ```php 351 | currencies()->setDefault('USD'); 352 | ``` 353 | 354 | ### Setting the current currency 355 | 356 | You can set the [current currency](#current-currency) using the `setCurrent()` method: 357 | ```php 358 | currencies()->setCurrent('USD'); 359 | ``` 360 | 361 | ### Persisting a selected currency across requests 362 | 363 | 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. 364 | 365 | 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: 366 | ```php 367 | currencies() 368 | ->storeCurrentUsing(fn (string $code) => session()->put('currency', $code)) 369 | ->resolveCurrentUsing(fn () => session()->get('currency')); 370 | ``` 371 | You can add this code to your AppServiceProvider's `boot()` method. 372 | 373 | Now, whenever the current currency is changed using `currencies()->setCurrent()`, perhaps in a route like this: 374 | ```php 375 | Route::get('/currency/change/{currency}', function (string $currency) { 376 | currencies()->setCurrent($currency); 377 | 378 | return redirect()->back(); 379 | }); 380 | ``` 381 | it will also be written to the `currency` session key. The route can be used by a `