diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..22aac70 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/tests export-ignore +/vendor export-ignore + +/LICENSE export-ignore +/Makefile export-ignore +/README.md export-ignore +/phpmd.xml export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32850ac..105bc90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,11 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Use PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Install dependencies run: composer update --no-progress --optimize-autoloader @@ -33,6 +38,11 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Use PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Install dependencies run: composer update --no-progress --optimize-autoloader diff --git a/Makefile b/Makefile index 9bf35fe..1b3026e 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,17 @@ DOCKER_RUN = docker run --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.2 -.PHONY: configure test test-no-coverage review show-reports clean +.PHONY: configure test test-file test-no-coverage review show-reports clean configure: @${DOCKER_RUN} composer update --optimize-autoloader -test: review +test: @${DOCKER_RUN} composer tests -test-no-coverage: review +test-file: + @${DOCKER_RUN} composer tests-file-no-coverage ${FILE} + +test-no-coverage: @${DOCKER_RUN} composer tests-no-coverage review: @@ -19,4 +22,4 @@ show-reports: clean: @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor + @rm -rf report vendor .phpunit.cache diff --git a/composer.json b/composer.json index 5abcbd7..e90ade4 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,6 @@ "vo", "psr", "money", - "psr-4", - "psr-12", "tiny-blocks", "value-object" ], @@ -21,6 +19,10 @@ "homepage": "https://github.com/gustavofreze" } ], + "support": { + "issues": "https://github.com/tiny-blocks/money/issues", + "source": "https://github.com/tiny-blocks/money" + }, "config": { "sort-packages": true, "allow-plugins": { @@ -38,16 +40,18 @@ } }, "require": { - "php": "^8.1||^8.2", - "tiny-blocks/math": "^2.0", - "tiny-blocks/currency": "^2.0", - "tiny-blocks/value-object": "^2.0" + "php": "^8.2", + "tiny-blocks/math": "^2", + "tiny-blocks/currency": "^2", + "tiny-blocks/value-object": "^2", + "ext-bcmath": "*" }, "require-dev": { - "infection/infection": "^0.26", - "phpmd/phpmd": "^2.13", - "phpunit/phpunit": "^9.6", - "squizlabs/php_codesniffer": "^3.7" + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^11", + "phpstan/phpstan": "^1", + "infection/infection": "^0.29", + "squizlabs/php_codesniffer": "^3.10" }, "suggest": { "ext-bcmath": "Enables the extension which is an interface to the GNU implementation as a Basic Calculator utility library." @@ -55,13 +59,15 @@ "scripts": { "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", "phpmd": "phpmd ./src text phpmd.xml --suffixes php --exclude --ignore-violations-on-exit", + "phpstan": "phpstan analyse -c phpstan.neon.dist --quiet --no-progress", "test": "phpunit --log-junit=report/coverage/junit.xml --coverage-xml=report/coverage/coverage-xml --coverage-html=report/coverage/coverage-html tests", "test-mutation": "infection --only-covered --logger-html=report/coverage/mutation-report.html --coverage=report/coverage --min-msi=100 --min-covered-msi=100 --threads=4", "test-no-coverage": "phpunit --no-coverage", "test-mutation-no-coverage": "infection --only-covered --min-msi=100 --threads=4", "review": [ "@phpcs", - "@phpmd" + "@phpmd", + "@phpstan" ], "tests": [ "@test", diff --git a/infection.json.dist b/infection.json.dist index cfbad25..351e7f9 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,25 +1,21 @@ { "timeout": 10, "testFramework": "phpunit", - "tmpDir": "report/", + "tmpDir": "report/infection/", "source": { "directories": [ "src" ] }, "logs": { - "text": "report/logs/infection-text.log", - "summary": "report/logs/infection-summary.log" + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" }, "mutators": { - "@default": true, - "DecrementInteger": false, - "IncrementInteger": false, - "MethodCallRemoval": false, - "ProtectedVisibility": false + "@default": true }, "phpUnit": { "configDir": "", "customPath": "./vendor/bin/phpunit" } -} \ No newline at end of file +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..b99884f --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + paths: + - src + level: 9 + tmpDir: report/phpstan + ignoreErrors: + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml index 3d05fc8..7f080dd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,25 +1,35 @@ + bootstrap="vendor/autoload.php" + failOnRisky="true" + failOnWarning="true" + cacheDirectory=".phpunit.cache" + beStrictAboutOutputDuringTests="true"> + + + + src + + + - tests + tests - - src - + + + + + + + + + + diff --git a/src/Internal/Exceptions/DifferentCurrencies.php b/src/Internal/Exceptions/DifferentCurrencies.php index 0ac064f..747c1eb 100644 --- a/src/Internal/Exceptions/DifferentCurrencies.php +++ b/src/Internal/Exceptions/DifferentCurrencies.php @@ -1,5 +1,7 @@ getScale(), $currency->name, - $currency->getDefaultFractionDigits() + $currency->getFractionDigits() ) ); } diff --git a/src/Money.php b/src/Money.php index e121b83..8873a3b 100644 --- a/src/Money.php +++ b/src/Money.php @@ -1,5 +1,7 @@ amount->multiply(multiplier: BigDecimal::from(value: 1)); + $withAmountScale = $this->amount->multiply(multiplier: BigDecimal::from(value: self::ONE)); - if ($withAmountScale->getScale() > $this->currency->getDefaultFractionDigits()) { + if ($withAmountScale->getScale() > $this->currency->getFractionDigits()) { throw new InvalidCurrencyScale(amount: $withAmountScale, currency: $this->currency); } } - public static function from(float|string|BigNumber $value, string|Currency $currency): Money + public static function from(BigNumber $value, Currency $currency): Money + { + return new Money(amount: $value, currency: $currency); + } + + public static function fromFloat(float $value, string $currency): Money + { + $amount = BigDecimal::from(value: $value); + $currency = Currency::from(value: $currency); + + return new Money(amount: $amount, currency: $currency); + } + + public static function fromString(string $value, string $currency): Money { - $currency = is_string($currency) ? Currency::from(value: $currency) : $currency; - $amount = is_scalar($value) ? BigDecimal::from(value: $value) : $value; + $amount = BigDecimal::from(value: $value); + $currency = Currency::from(value: $currency); return new Money(amount: $amount, currency: $currency); } @@ -39,7 +56,7 @@ public function add(Money $addend): Money $result = $this->amount->add(addend: $addend->amount); - return self::from(value: $result->toString(), currency: $this->currency); + return self::fromString(value: $result->toString(), currency: $this->currency->value); } public function subtract(Money $subtrahend): Money @@ -50,7 +67,7 @@ public function subtract(Money $subtrahend): Money $result = $this->amount->subtract(subtrahend: $subtrahend->amount); - return self::from(value: $result->toString(), currency: $this->currency); + return self::fromString(value: $result->toString(), currency: $this->currency->value); } public function multiply(Money $multiplier): Money @@ -61,7 +78,7 @@ public function multiply(Money $multiplier): Money $result = $this->amount->multiply(multiplier: $multiplier->amount); - return self::from(value: $result->toString(), currency: $this->currency); + return self::fromString(value: $result->toString(), currency: $this->currency->value); } public function divide(Money $divisor): Money @@ -72,9 +89,9 @@ public function divide(Money $divisor): Money $result = $this->amount ->divide(divisor: $divisor->amount) - ->withScale(scale: $this->currency->getDefaultFractionDigits()); + ->withScale(scale: $this->currency->getFractionDigits()); - return self::from(value: $result->toString(), currency: $this->currency); + return self::fromString(value: $result->toString(), currency: $this->currency->value); } private function areCurrenciesDifferent(Currency $currency): bool diff --git a/tests/MoneyTest.php b/tests/MoneyTest.php index ebc2909..4a319ed 100644 --- a/tests/MoneyTest.php +++ b/tests/MoneyTest.php @@ -1,165 +1,265 @@ amount->toFloat()); + self::assertEquals($currency, $actual->currency->value); + } + + #[DataProvider('stringDataProvider')] + public function testFromString(string $value, string $currency, string $expected): void { + /** @Given a value and a currency */ + $actual = Money::fromString(value: $value, currency: $currency); + + /** @Then the amount and currency should match the expected values */ + self::assertEquals($expected, $actual->amount->toString()); + self::assertEquals($currency, $actual->currency->value); + } + + #[DataProvider('bigNumberDataProvider')] + public function testFromBigNumber(BigNumber $value, Currency $currency, string $expected): void + { + /** @Given a BigNumber and a currency */ $actual = Money::from(value: $value, currency: $currency); + /** @Then the amount and currency should match the expected values */ self::assertEquals($expected, $actual->amount->toString()); self::assertEquals($currency->value, $actual->currency->value); } public function testAdd(): void { - $augend = Money::from(value: '100', currency: 'BRL'); - $addend = Money::from(value: '1.50', currency: Currency::BRL); + /** @Given two Money objects with the same currency */ + $augend = Money::fromString(value: '100', currency: Currency::BRL->value); + $addend = Money::fromString(value: '1.50', currency: Currency::BRL->value); + /** @When adding them */ $actual = $augend->add(addend: $addend); + /** @Then the result should be the sum of the amounts with the same currency */ self::assertEquals('101.50', $actual->amount->toString()); self::assertEquals(Currency::BRL->value, $actual->currency->value); } public function testSubtract(): void { - $minuend = Money::from(value: '10.50', currency: 'EUR'); - $subtrahend = Money::from(value: '0.50', currency: Currency::EUR); + /** @Given two Money objects with the same currency */ + $minuend = Money::fromString(value: '10.50', currency: Currency::EUR->value); + $subtrahend = Money::fromString(value: '0.50', currency: Currency::EUR->value); + /** @When subtracting them */ $actual = $minuend->subtract(subtrahend: $subtrahend); + /** @Then the result should be the difference of the amounts with the same currency */ self::assertEquals('10.00', $actual->amount->toString()); self::assertEquals(Currency::EUR->value, $actual->currency->value); } public function testMultiply(): void { - $multiplicand = Money::from(value: '5', currency: 'GBP'); - $multiplier = Money::from(value: '3.12', currency: Currency::GBP); + /** @Given two Money objects with the same currency */ + $multiplicand = Money::fromString(value: '5', currency: Currency::GBP->value); + $multiplier = Money::fromString(value: '3.12', currency: Currency::GBP->value); + /** @When multiplying them */ $actual = $multiplicand->multiply(multiplier: $multiplier); + /** @Then the result should be the product of the amounts with the same currency */ self::assertEquals('15.60', $actual->amount->toString()); self::assertEquals(Currency::GBP->value, $actual->currency->value); } public function testDivide(): void { - $dividend = Money::from(value: '8.99', currency: 'CHF'); - $divisor = Money::from(value: '5', currency: Currency::CHF); + /** @Given two Money objects with the same currency */ + $dividend = Money::fromString(value: '8.99', currency: Currency::CHF->value); + $divisor = Money::fromString(value: '5', currency: Currency::CHF->value); + /** @When dividing them */ $actual = $dividend->divide(divisor: $divisor); + /** @Then the result should be the quotient of the amounts with the same currency */ self::assertEquals('1.79', $actual->amount->toString()); self::assertEquals(Currency::CHF->value, $actual->currency->value); } public function testInvalidCurrencyScale(): void { + /** @Given a Money object with an invalid scale for the currency */ $template = 'The decimal scale <4> provided for currency is invalid. '; $template .= 'The scale must be less than or equal to <2>.'; + /** @Then an InvalidCurrencyScale exception should be thrown */ $this->expectException(InvalidCurrencyScale::class); $this->expectExceptionMessage($template); - Money::from(value: 10.1234, currency: Currency::BRL); + /** @When creating the Money object */ + Money::fromFloat(value: 10.1234, currency: Currency::BRL->value); } public function testAddingDifferentCurrencies(): void { + /** @Given two Money objects with different currencies */ $template = 'Currencies and are different. '; $template .= 'The currencies must be the same to perform this operation.'; + /** @Then a DifferentCurrencies exception should be thrown */ $this->expectException(DifferentCurrencies::class); $this->expectExceptionMessage($template); - $augend = Money::from(value: 100, currency: Currency::BRL); - $addend = Money::from(value: '1.50', currency: Currency::USD); + /** @When trying to add them */ + $augend = Money::fromFloat(value: 100, currency: Currency::BRL->value); + $addend = Money::fromString(value: '1.50', currency: Currency::USD->value); $augend->add(addend: $addend); } public function testSubtractionDifferentCurrencies(): void { + /** @Given two Money objects with different currencies */ $template = 'Currencies and are different. '; $template .= 'The currencies must be the same to perform this operation.'; + /** @Then a DifferentCurrencies exception should be thrown */ $this->expectException(DifferentCurrencies::class); $this->expectExceptionMessage($template); - $minuend = Money::from(value: 100, currency: Currency::BRL); - $subtrahend = Money::from(value: '1.50', currency: Currency::EUR); + /** @When trying to subtract them */ + $minuend = Money::fromFloat(value: 100, currency: Currency::BRL->value); + $subtrahend = Money::fromString(value: '1.50', currency: Currency::EUR->value); $minuend->subtract(subtrahend: $subtrahend); } public function testMultiplicationDifferentCurrencies(): void { + /** @Given two Money objects with different currencies */ $template = 'Currencies and are different. '; $template .= 'The currencies must be the same to perform this operation.'; + /** @Then a DifferentCurrencies exception should be thrown */ $this->expectException(DifferentCurrencies::class); $this->expectExceptionMessage($template); - $multiplicand = Money::from(value: 100, currency: Currency::BRL); - $multiplier = Money::from(value: '1.50', currency: Currency::GBP); + /** @When trying to multiply them */ + $multiplicand = Money::fromFloat(value: 100, currency: Currency::BRL->value); + $multiplier = Money::fromString(value: '1.50', currency: Currency::GBP->value); $multiplicand->multiply(multiplier: $multiplier); } public function testDivisionDifferentCurrencies(): void { + /** @Given two Money objects with different currencies */ $template = 'Currencies and are different. '; $template .= 'The currencies must be the same to perform this operation.'; + /** @Then a DifferentCurrencies exception should be thrown */ $this->expectException(DifferentCurrencies::class); $this->expectExceptionMessage($template); - $dividend = Money::from(value: 100, currency: Currency::BRL); - $divisor = Money::from(value: '1.50', currency: Currency::CHF); + /** @When trying to divide them */ + $dividend = Money::fromFloat(value: 100, currency: Currency::BRL->value); + $divisor = Money::fromString(value: '1.50', currency: Currency::CHF->value); $dividend->divide(divisor: $divisor); } - public function providerForTestFrom(): array + public static function floatDataProvider(): array { return [ - [ + 'Integer value' => [ 'value' => 1, - 'currency' => Currency::XPF, + 'currency' => Currency::XPF->value, + 'expected' => 1 + ], + 'Negative value' => [ + 'value' => -50.00, + 'currency' => Currency::EUR->value, + 'expected' => -50.00 + ], + 'High scale currency' => [ + 'value' => 9.1234, + 'currency' => Currency::CLF->value, + 'expected' => 9.1234 + ], + 'Float decimal value' => [ + 'value' => 100.12, + 'currency' => Currency::USD->value, + 'expected' => 100.12 + ], + 'Value with leading zeros' => [ + 'value' => 001.23, + 'currency' => Currency::USD->value, + 'expected' => 001.23 + ] + ]; + } + + public static function stringDataProvider(): array + { + return [ + 'Integer value' => [ + 'value' => '1', + 'currency' => Currency::XPF->value, 'expected' => '1' ], - [ + 'Negative value' => [ + 'value' => '-50.00', + 'currency' => Currency::EUR->value, + 'expected' => '-50.00' + ], + 'High scale currency' => [ + 'value' => '9.1234', + 'currency' => Currency::CLF->value, + 'expected' => '9.1234' + ], + 'String decimal value' => [ 'value' => '100.12', - 'currency' => Currency::USD, + 'currency' => Currency::USD->value, 'expected' => '100.12' ], - [ + 'Value with leading zeros' => [ + 'value' => '001.23', + 'currency' => Currency::USD->value, + 'expected' => '001.23' + ] + ]; + } + + public static function bigNumberDataProvider(): array + { + return [ + 'Big decimal value' => [ 'value' => BigDecimal::from(value: 999.12), 'currency' => Currency::BRL, 'expected' => '999.12' ], - [ + 'Positive big decimal value' => [ 'value' => PositiveBigDecimal::from(value: '9.123'), 'currency' => Currency::TND, 'expected' => '9.123' - ], - [ - 'value' => '9.1234', - 'currency' => Currency::CLF, - 'expected' => '9.1234' ] ]; }