diff --git a/README.md b/README.md index fbd4e43..7809716 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This is a simple PHP library to help you deal with Europe's VAT rules. ## Installation -[PHP](https://php.net) version 7.3 or higher with the CURL and JSON extension is required. +[PHP](https://php.net) version 8.2 or higher with the CURL and JSON extension is required. For VAT number existence checking, the PHP SOAP extension is required as well. diff --git a/composer.json b/composer.json index d725f37..f288a31 100755 --- a/composer.json +++ b/composer.json @@ -10,13 +10,13 @@ } ], "require": { - "php": ">=7.3", + "php": ">=8.2", "ext-curl": "*", "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^9.5", - "squizlabs/php_codesniffer": "^3.5" + "phpunit/phpunit": "^11.1", + "friendsofphp/php-cs-fixer": "^3.54" }, "autoload": { "psr-4": { @@ -35,5 +35,8 @@ "suggest": { "ibericode/vat-bundle": "Symfony bundle for integrating this package", "ext-soap": "Needed to support VIES VAT number validation" + }, + "scripts": { + "check-syntax": "find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php --define error_reporting=-1 -l" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 812ee72..fe907ce 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,31 +1,16 @@ - - - - ./tests - - - - - ./src - - - - - + + + + ./tests + + + + + + + + ./src + + diff --git a/src/Clients/IbericodeVatRatesClient.php b/src/Clients/IbericodeVatRatesClient.php deleted file mode 100644 index 5247212..0000000 --- a/src/Clients/IbericodeVatRatesClient.php +++ /dev/null @@ -1,52 +0,0 @@ -= 400) { - throw new ClientException("Error fetching rates from {$url}."); - } - - return $this->parseResponse($body); - } - - private function parseResponse(string $response_body): array - { - $result = json_decode($response_body, false); - - $return = []; - foreach ($result->items as $country => $periods) { - foreach ($periods as $i => $period) { - $periods[$i] = new Period(new \DateTimeImmutable($period->effective_from), (array) $period->rates); - } - - $return[$country] = $periods; - } - - return $return; - } -} diff --git a/src/Countries.php b/src/Countries.php index c89cf0f..cabf2b1 100644 --- a/src/Countries.php +++ b/src/Countries.php @@ -4,8 +4,6 @@ namespace Ibericode\Vat; -use DateTime; - /** * Class Countries * @@ -285,7 +283,41 @@ public function hasCountryCode(string $code): bool */ public function getCountryCodesInEU(): array { - return ['AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR', 'HU', 'HR', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK']; + return [ + 'AT', // Austria + 'AX', // Aland islands => Finland + 'BE', // Belgium + 'BG', // Bulgaria + 'CY', // Cyprus + 'CZ', // Czechia + 'DE', // Germany + 'DK', // Denmark + 'EE', // Estonia + 'ES', // Spain + 'FI', // Finland + 'FR', // France + 'GF', // French guiana => France + 'GP', // Guadeloupe => France + 'GR', // Greece + 'HU', // Hungary + 'HR', // Croatia + 'IE', // Ireland + 'IT', // Italy + 'LT', // Lithuania + 'LU', // Luxembourg + 'LV', // Latvia + 'MT', // Malta + 'MQ', // Martinique => France + 'NL', // Netherlands + 'PL', // Poland + 'PT', // Portugal + 'RE', // Reunion => France + 'RO', // Romania + 'SE', // Sweden + 'SI', // Slovenia + 'SK', // Slovakia + 'YT', // Mayotte => France + ]; } /** @@ -304,8 +336,7 @@ public function isCountryCodeInEU(string $code): bool * @return mixed Can return any type. * @since 5.0.0 */ - #[\ReturnTypeWillChange] - public function current() + public function current(): mixed { return current($this->data); } @@ -316,8 +347,7 @@ public function current() * @return void Any returned value is ignored. * @since 5.0.0 */ - #[\ReturnTypeWillChange] - public function next() + public function next(): void { next($this->data); } @@ -328,8 +358,7 @@ public function next() * @return mixed scalar on success, or null on failure. * @since 5.0.0 */ - #[\ReturnTypeWillChange] - public function key() + public function key(): mixed { return key($this->data); } @@ -341,8 +370,7 @@ public function key() * Returns true on success or false on failure. * @since 5.0.0 */ - #[\ReturnTypeWillChange] - public function valid() + public function valid(): bool { return key($this->data) !== null; } @@ -353,8 +381,7 @@ public function valid() * @return void Any returned value is ignored. * @since 5.0.0 */ - #[\ReturnTypeWillChange] - public function rewind() + public function rewind(): void { reset($this->data); } @@ -363,8 +390,7 @@ public function rewind() * @param string $countryCode * @return bool */ - #[\ReturnTypeWillChange] - public function offsetExists($countryCode) + public function offsetExists($countryCode): bool { return isset($this->data[$countryCode]); } @@ -374,8 +400,7 @@ public function offsetExists($countryCode) * @return string * @throws \Exception */ - #[\ReturnTypeWillChange] - public function offsetGet($countryCode) + public function offsetGet($countryCode): mixed { if (!$this->offsetExists($countryCode)) { throw new Exception("Invalid country code {$countryCode}"); @@ -387,22 +412,18 @@ public function offsetGet($countryCode) /** * @param string $countryCode * @param string $name - * @return string * @throws \Exception */ - #[\ReturnTypeWillChange] - public function offsetSet($countryCode, $name) + public function offsetSet($countryCode, $name): void { throw new Exception('Invalid use of Countries class'); } /** * @param string $countryCode - * @return string * @throws \Exception */ - #[\ReturnTypeWillChange] - public function offsetUnset($countryCode) + public function offsetUnset($countryCode): void { throw new Exception('Invalid use of Countries class'); } diff --git a/src/Period.php b/src/Period.php index 3b59d59..9098589 100644 --- a/src/Period.php +++ b/src/Period.php @@ -17,11 +17,13 @@ class Period { private $effectiveFrom; private $rates = []; + private $exceptions = []; - public function __construct(DateTimeInterface $effectiveFrom, array $rates) + public function __construct(DateTimeInterface $effectiveFrom, array $rates, array $exceptions = []) { $this->effectiveFrom = $effectiveFrom; $this->rates = $rates; + $this->exceptions = $exceptions; } public function getEffectiveFrom(): DateTimeInterface @@ -29,12 +31,28 @@ public function getEffectiveFrom(): DateTimeInterface return $this->effectiveFrom; } - public function getRate(string $level): float + public function getRate(string $level, ?string $postcode = null): float { if (!isset($this->rates[$level])) { throw new InvalidArgumentException("Invalid rate level: {$level}"); } - return $this->rates[$level]; + return $this->getExceptionRate($level, $postcode) ?? $this->rates[$level]; } + + private function getExceptionRate(string $level, ?string $postcode): ?float + { + if (!$postcode) { + return null; + } + + foreach ($this->exceptions as $exception) { + if (preg_match('/^'.$exception['postcode'].'$/', $postcode)) { + return $exception[$level] ?? $exception[Rates::RATE_STANDARD]; + } + } + + return null; + } + } diff --git a/src/Rates.php b/src/Rates.php index a98a10b..80bea98 100644 --- a/src/Rates.php +++ b/src/Rates.php @@ -38,7 +38,7 @@ class Rates * @param int $refreshInterval How often to check for new VAT rates. Defaults to every 12 hours. * @param Client|null $client The VAT client to use. */ - public function __construct(string $storagePath, int $refreshInterval = 12 * 3600, Client $client = null) + public function __construct(string $storagePath, int $refreshInterval = 12 * 3600, ?Client $client = null) { $this->refreshInterval = $refreshInterval; $this->storagePath = $storagePath; @@ -67,7 +67,11 @@ private function load(): void private function loadFromFile(): void { $contents = file_get_contents($this->storagePath); - $data = unserialize($contents, [ + if ($contents === false || $contents === '') { + throw new Exception("Unserializable file content"); + } + + $data = @unserialize($contents, [ 'allowed_classes' => [ Period::class, DateTimeImmutable::class @@ -131,25 +135,26 @@ private function resolvePeriod(string $countryCode, DateTimeInterface $datetime) /** * @param string $countryCode ISO-3166-1-alpha2 country code * @param string $level + * @param ?string $postcode * @return float * @throws \Exception */ - public function getRateForCountry(string $countryCode, string $level = self::RATE_STANDARD): float + public function getRateForCountry(string $countryCode, string $level = self::RATE_STANDARD, ?string $postcode = null): float { $todayMidnight = new \DateTimeImmutable('today midnight'); - return $this->getRateForCountryOnDate($countryCode, $todayMidnight, $level); + return $this->getRateForCountryOnDate($countryCode, $todayMidnight, $level, $postcode); } /** * @param string $countryCode ISO-3166-1-alpha2 country code * @param DateTimeInterface $datetime * @param string $level + * @param ?string $postcode * @return float * @throws Exception */ - public function getRateForCountryOnDate(string $countryCode, \DateTimeInterface $datetime, string $level = self::RATE_STANDARD): float + public function getRateForCountryOnDate(string $countryCode, \DateTimeInterface $datetime, string $level = self::RATE_STANDARD, ?string $postcode = null) : float { - $activePeriod = $this->resolvePeriod($countryCode, $datetime); - return $activePeriod->getRate($level); + return $this->resolvePeriod($countryCode, $datetime)->getRate($level, $postcode); } } diff --git a/src/Validator.php b/src/Validator.php index d276ba9..2e811dc 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -29,7 +29,7 @@ class Validator 'GB' => '(\d{9}|\d{12}|(GD|HA)\d{3})', 'HR' => '\d{11}', 'HU' => '\d{8}', - 'IE' => '([A-Z\d]{8}|[A-Z\d]{9})', + 'IE' => '((\d{7}[A-Z]{1,2})|(\d[A-Z]\d{5}[A-Z]))', 'IT' => '\d{11}', 'LT' => '(\d{9}|\d{12})', 'LU' => '\d{8}', @@ -55,7 +55,7 @@ class Validator * * @param Vies\Client $client (optional) */ - public function __construct(Vies\Client $client = null) + public function __construct(?Vies\Client $client = null) { $this->client = $client ?: new Vies\Client(); } diff --git a/src/Vies/Client.php b/src/Vies/Client.php index 67134aa..2b9ae44 100644 --- a/src/Vies/Client.php +++ b/src/Vies/Client.php @@ -43,6 +43,19 @@ public function __construct(int $timeout = 10) * @throws ViesException */ public function checkVat(string $countryCode, string $vatNumber): bool + { + return (bool)$this->getInfo($countryCode, $vatNumber)->valid; + } + + /** + * @param string $countryCode + * @param string $vatNumber + * + * @return object + * + * @throws ViesException + */ + public function getInfo(string $countryCode, string $vatNumber): object { try { $response = $this->getClient()->checkVat( @@ -55,7 +68,7 @@ public function checkVat(string $countryCode, string $vatNumber): bool throw new ViesException($e->getMessage(), $e->getCode()); } - return (bool) $response->valid; + return $response; } /** diff --git a/tests/Clients/ClientsTest.php b/tests/Clients/ClientsTest.php index e3d2055..0f87972 100644 --- a/tests/Clients/ClientsTest.php +++ b/tests/Clients/ClientsTest.php @@ -2,18 +2,21 @@ namespace Ibericode\Vat\Tests\Clients; +use Ibericode\Vat\Clients\ClientException; use Ibericode\Vat\Clients\IbericodeVatRatesClient; use Ibericode\Vat\Clients\Client; use Ibericode\Vat\Period; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class ClientsTest extends TestCase { /** * @group remote-http - * @dataProvider clientProvider + * @throws ClientException */ - public function testClient(Client $client) + #[DataProvider('clientsProvider')] + public function testClient(Client $client): void { $data = $client->fetch(); $this->assertIsArray($data); @@ -22,7 +25,7 @@ public function testClient(Client $client) $this->assertInstanceOf(Period::class, $data['NL'][0]); } - public function clientProvider() + public static function clientsProvider(): \Generator { yield [new IbericodeVatRatesClient()]; } diff --git a/tests/CountriesTest.php b/tests/CountriesTest.php index 85381bc..d97d13e 100644 --- a/tests/CountriesTest.php +++ b/tests/CountriesTest.php @@ -9,14 +9,14 @@ class CountriesTest extends TestCase { - public function testIterator() + public function testIterator(): void { $countries = new Countries(); $this->assertCount(249, $countries); } - public function testArrayAccess() + public function testArrayAccess(): void { $countries = new Countries(); @@ -27,35 +27,35 @@ public function testArrayAccess() $countries['FOO']; } - public function testArrayAccessWithInvalidCountryCode() + public function testArrayAccessWithInvalidCountryCode(): void { $countries = new Countries(); $this->expectException(Exception::class); $countries['FOO']; } - public function testArrayAccessSetValue() + public function testArrayAccessSetValue(): void { $countries = new Countries(); $this->expectException(Exception::class); $countries['FOO'] = 'bar'; } - public function testArrayAccessUnsetValue() + public function testArrayAccessUnsetValue(): void { $countries = new Countries(); $this->expectException(Exception::class); unset($countries['FOO']); } - public function testHasCode() + public function testHasCode(): void { $countries = new Countries(); $this->assertFalse($countries->hasCountryCode('FOO')); $this->assertTrue($countries->hasCountryCode('NL')); } - public function testIsCodeInEU() + public function testIsCodeInEU(): void { $countries = new Countries(); $this->assertFalse($countries->isCountryCodeInEU('FOO')); diff --git a/tests/GeolocatorTest.php b/tests/GeolocatorTest.php index 9f2c2ba..137ffdc 100644 --- a/tests/GeolocatorTest.php +++ b/tests/GeolocatorTest.php @@ -3,22 +3,23 @@ namespace Ibericode\Vat\Tests; use Ibericode\Vat\Geolocator; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class GeolocatorTest extends TestCase { /** - * @dataProvider provider * @group remote-http */ - public function testClient($service) + #[DataProvider('servicesProvider')] + public function testService($service): void { $geolocator = new Geolocator($service); $country = $geolocator->locateIpAddress('8.8.8.8'); $this->assertEquals('US', $country); } - public function provider() + public static function servicesProvider(): \Generator { yield ['ip2c.org']; } diff --git a/tests/RatesTest.php b/tests/RatesTest.php index 12e865b..f25a424 100644 --- a/tests/RatesTest.php +++ b/tests/RatesTest.php @@ -19,7 +19,7 @@ protected function setUp(): void } } - private function getRatesClientMock() + private function getRatesClientMock(): \PHPUnit\Framework\MockObject\MockObject { $client = $this->getMockBuilder(IbericodeVatRatesClient::class) ->getMock(); @@ -39,6 +39,17 @@ private function getRatesClientMock() new Period(new \DateTimeImmutable('2019/01/01'), [ 'standard' => 21.00, 'reduced' => 9.00, + ], [ + [ + "name" => "Park Frankendael", + "postcode" => "1097", + "standard" => 0 + ], + [ + "name" => "Park de Meer", + "postcode" => "(1098|1099)", + "standard" => 0 + ] ]) ] ]); @@ -53,6 +64,15 @@ public function testGetRateForCountry() $this->assertEquals(21.0, $rates->getRateForCountry('NL')); } + public function testGetRateForCountryAndPostcode() + { + $client = $this->getRatesClientMock(); + $rates = new Rates('vendor/rates', 30, $client); + $this->assertEquals(0, $rates->getRateForCountry('NL', Rates::RATE_STANDARD, '1097')); + $this->assertEquals(0, $rates->getRateForCountry('NL', Rates::RATE_STANDARD, '1099')); + $this->assertEquals(0, $rates->getRateForCountry('NL', Rates::RATE_STANDARD, '1098')); + } + public function testGetRateForCountryOnDate() { $client = $this->getRatesClientMock(); diff --git a/tests/ValidatorTest.php b/tests/ValidatorTest.php index 1381327..64d78f8 100644 --- a/tests/ValidatorTest.php +++ b/tests/ValidatorTest.php @@ -3,6 +3,7 @@ namespace Ibericode\Vat\Tests; use Ibericode\Vat\Validator; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** @@ -12,9 +13,9 @@ class ValidatorTest extends TestCase { /** - * @covers Validator::validateVatNumberFormat + * @coversXXX Validator::validateVatNumberFormat */ - public function testValidateVatNumberFormat() + public function testValidateVatNumberFormat(): void { $valid = [ 'ATU12345678', @@ -39,6 +40,8 @@ public function testValidateVatNumberFormat() 'HU12345678', 'HR12345678901', 'IE1234567X', + 'IE1X34567X', + 'IE1234567XX', 'IT12345678901', 'LT123456789', 'LU12345678', @@ -78,6 +81,9 @@ public function testValidateVatNumberFormat() 'HU1234567', 'HR1234567890', 'IE123456X', + 'IE1X34567XX', + 'IE12345678X', + 'IE123456789', 'IT1234567890', 'LT12345678', 'LU1234567', @@ -113,16 +119,14 @@ public function testValidateVatNumberFormat() } } - /** - * @dataProvider validIpAddresses - */ - public function testValidateIpAddressWithValid($value) + #[DataProvider('validIpAddresses')] + public function testValidateIpAddressWithValid($value): void { $validator = new Validator(); $this->assertTrue($validator->validateIpAddress($value)); } - public function validIpAddresses() + public static function validIpAddresses(): array { return [ ['8.8.8.8'], @@ -130,16 +134,14 @@ public function validIpAddresses() ]; } - /** - * @dataProvider invalidIpAddresses - */ - public function testValidateIpAddressWithInvalidValues($value) + #[DataProvider('invalidIpAddresses')] + public function testValidateIpAddressWithInvalidValues($value): void { $validator = new Validator(); $this->assertFalse($validator->validateIpAddress($value)); } - public function invalidIpAddresses() + public static function invalidIpAddresses(): array { return [ ['0.8.8.8.8'], @@ -148,18 +150,14 @@ public function invalidIpAddresses() ]; } - /** - * @dataProvider validCountryCodes - */ - public function testValidateCountryCodeWithValidValues($value) + #[DataProvider('validCountryCodes')] + public function testValidateCountryCodeWithValidValues($value): void { $validator = new Validator(); $this->assertTrue($validator->validateCountryCode($value)); } - /** - * @dataProvider invalidCountryCodes - */ + #[DataProvider('invalidCountryCodes')] public function testValidateCountryCodeWithInvalidValues($value) { $validator = new Validator(); @@ -167,7 +165,7 @@ public function testValidateCountryCodeWithInvalidValues($value) } - public function validCountryCodes() + public static function validCountryCodes(): array { return [ ['NL'], @@ -177,7 +175,7 @@ public function validCountryCodes() ]; } - public function invalidCountryCodes() + public static function invalidCountryCodes(): array { return [ ['FOO'],