Skip to content

Commit 87d4e3c

Browse files
Merge pull request #83 from solracsf/upstream-fixes
fix: correctness, compatibility, and improvements. thanks @solracsf!
2 parents ad96c61 + 509e906 commit 87d4e3c

14 files changed

Lines changed: 377 additions & 20 deletions

src/Clients/IbericodeVatRatesClient.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,20 @@ private function parseResponse(string $response_body): array
3838
{
3939
$result = json_decode($response_body, false);
4040

41+
if (!is_object($result) || !isset($result->items) || !is_object($result->items)) {
42+
throw new ClientException('Malformed response from VAT rates service.');
43+
}
44+
4145
$return = [];
4246
foreach ($result->items as $country => $periods) {
47+
if (!is_array($periods)) {
48+
throw new ClientException("Malformed periods for country {$country}.");
49+
}
50+
4351
foreach ($periods as $i => $period) {
52+
if (!is_object($period) || !isset($period->effective_from, $period->rates)) {
53+
throw new ClientException("Malformed period entry for country {$country}.");
54+
}
4455
$periods[$i] = new Period(new \DateTimeImmutable($period->effective_from), (array) $period->rates);
4556
}
4657

src/Countries.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ class Countries implements \Iterator, \ArrayAccess
262262
'VU' => 'Vanuatu',
263263
'WF' => 'Wallis & Futuna',
264264
'WS' => 'Samoa',
265+
'XI' => 'Northern Ireland',
265266
'YE' => 'Yemen',
266267
'YT' => 'Mayotte',
267268
'ZA' => 'South Africa',
@@ -316,6 +317,7 @@ public function getCountryCodesInEU(): array
316317
'SE', // Sweden
317318
'SI', // Slovenia
318319
'SK', // Slovakia
320+
'XI', // Northern Ireland (treated as EU for VAT on goods under the Windsor Framework)
319321
'YT', // Mayotte => France
320322
];
321323
}

src/Geolocation/IP2C.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ public function locateIpAddress(string $ipAddress): string
3838
}
3939

4040
$parts = explode(';', $response);
41-
return $parts[1] === 'ZZ' ? '' : $parts[1];
41+
if (count($parts) < 2 || $parts[1] === 'ZZ') {
42+
return '';
43+
}
44+
45+
return $parts[1];
4246
}
4347
}

src/Geolocation/IP2Country.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ public function locateIpAddress(string $ipAddress): string
3838
}
3939

4040
$data = json_decode($response);
41+
if (!is_object($data) || !isset($data->countryCode) || !is_string($data->countryCode)) {
42+
return '';
43+
}
44+
4145
return $data->countryCode;
4246
}
4347
}

src/Period.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,13 @@ public function getRate(string $level): float
3737

3838
return $this->rates[$level];
3939
}
40+
41+
/**
42+
* @internal Used to serialize Period to a non-PHP cache format.
43+
* @return array<string, float>
44+
*/
45+
public function getRates(): array
46+
{
47+
return $this->rates;
48+
}
4049
}

src/Rates.php

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -68,21 +68,52 @@ private function loadFromFile(): void
6868
{
6969
$contents = file_get_contents($this->storagePath);
7070
if ($contents === false || $contents === '') {
71-
throw new Exception("Unserializable file content");
71+
throw new Exception("Empty rates cache file");
7272
}
7373

74-
$data = @unserialize($contents, [
75-
'allowed_classes' => [
76-
Period::class,
77-
DateTimeImmutable::class
78-
]
79-
]);
74+
$data = json_decode($contents, true);
75+
if (!is_array($data)) {
76+
throw new Exception("Malformed rates cache file");
77+
}
78+
79+
$rates = [];
80+
foreach ($data as $country => $periods) {
81+
if (!is_array($periods)) {
82+
throw new Exception("Malformed rates cache file");
83+
}
8084

81-
if (false === is_array($data)) {
82-
throw new Exception("Unserializable file content");
85+
foreach ($periods as $period) {
86+
if (!is_array($period) || !isset($period['effective_from'], $period['rates']) || !is_array($period['rates'])) {
87+
throw new Exception("Malformed rates cache file");
88+
}
89+
90+
$rates[$country][] = new Period(
91+
new DateTimeImmutable($period['effective_from']),
92+
$period['rates']
93+
);
94+
}
95+
}
96+
97+
$this->rates = $rates;
98+
}
99+
100+
/**
101+
* @return array<string, array<int, array{effective_from: string, rates: array<string, float>}>>
102+
*/
103+
private function ratesToArray(): array
104+
{
105+
$out = [];
106+
foreach ($this->rates as $country => $periods) {
107+
foreach ($periods as $period) {
108+
/** @var Period $period */
109+
$out[$country][] = [
110+
'effective_from' => $period->getEffectiveFrom()->format(\DateTimeInterface::ATOM),
111+
'rates' => $period->getRates(),
112+
];
113+
}
83114
}
84115

85-
$this->rates = $data;
116+
return $out;
86117
}
87118

88119
private function loadFromRemote(): void
@@ -101,15 +132,28 @@ private function loadFromRemote(): void
101132
}
102133

103134
// sort periods by DateTime so that later periods come first
104-
foreach ($this->rates as $country => $periods) {
135+
foreach (array_keys($this->rates) as $country) {
105136
usort($this->rates[$country], function (Period $period1, Period $period2) {
106-
return $period1->getEffectiveFrom() > $period2->getEffectiveFrom() ? -1 : 1;
137+
return $period2->getEffectiveFrom() <=> $period1->getEffectiveFrom();
107138
});
108139
}
109140

110141
// update local file with updated rates
111142
if ($this->storagePath !== '') {
112-
file_put_contents($this->storagePath, serialize($this->rates));
143+
$payload = json_encode($this->ratesToArray(), JSON_THROW_ON_ERROR);
144+
$this->writeStorageAtomic($payload);
145+
}
146+
}
147+
148+
private function writeStorageAtomic(string $contents): void
149+
{
150+
$tmp = $this->storagePath . '.' . bin2hex(random_bytes(6)) . '.tmp';
151+
if (file_put_contents($tmp, $contents) === false) {
152+
return;
153+
}
154+
155+
if (!@rename($tmp, $this->storagePath)) {
156+
@unlink($tmp);
113157
}
114158
}
115159

src/Validator.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ public function validateIpAddress(string $ipAddress): bool
8383
return false;
8484
}
8585

86-
return (bool) filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE);
86+
return (bool) filter_var(
87+
$ipAddress,
88+
FILTER_VALIDATE_IP,
89+
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
90+
);
8791
}
8892

8993
/**
@@ -103,6 +107,11 @@ public function validateVatNumberFormat(string $vatNumber): bool
103107
$country = substr($vatNumber, 0, 2);
104108
$number = substr($vatNumber, 2);
105109

110+
// Greece's ISO country code is GR but VIES uses EL for VAT.
111+
if ($country === 'GR') {
112+
$country = 'EL';
113+
}
114+
106115
if (! isset($this->patterns[$country])) {
107116
return false;
108117
}
@@ -123,6 +132,11 @@ protected function validateVatNumberExistence(string $vatNumber): bool
123132
$vatNumber = strtoupper($vatNumber);
124133
$country = substr($vatNumber, 0, 2);
125134
$number = substr($vatNumber, 2);
135+
136+
if ($country === 'GR') {
137+
$country = 'EL';
138+
}
139+
126140
return $this->client->checkVat($country, $number);
127141
}
128142

src/Vies/Client.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ public function __construct(int $timeout = 10)
4444
*/
4545
public function checkVat(string $countryCode, string $vatNumber): bool
4646
{
47-
return (bool)$this->getInfo($countryCode, $vatNumber)->valid;
47+
$info = $this->getInfo($countryCode, $vatNumber);
48+
49+
if (!isset($info->valid)) {
50+
throw new ViesException('VIES response is missing the "valid" field.');
51+
}
52+
53+
return (bool) $info->valid;
4854
}
4955

5056
/**
@@ -65,7 +71,11 @@ public function getInfo(string $countryCode, string $vatNumber): object
6571
)
6672
);
6773
} catch (SoapFault $e) {
68-
throw new ViesException($e->getMessage(), $e->getCode());
74+
if (ViesServiceUnavailableException::isTransientFault($e->getMessage())) {
75+
throw new ViesServiceUnavailableException($e->getMessage(), (int) $e->getCode(), $e);
76+
}
77+
78+
throw new ViesException($e->getMessage(), (int) $e->getCode(), $e);
6979
}
7080

7181
return $response;
@@ -77,7 +87,25 @@ public function getInfo(string $countryCode, string $vatNumber): object
7787
protected function getClient(): SoapClient
7888
{
7989
if ($this->client === null) {
80-
$this->client = new SoapClient(self::URL, ['connection_timeout' => $this->timeout]);
90+
if (!class_exists(SoapClient::class)) {
91+
throw new ViesException(
92+
'The PHP SOAP extension (ext-soap) is required for VIES VAT number validation '
93+
. 'but is not loaded. Install or enable ext-soap, e.g. via your distribution\'s '
94+
. 'php-soap package.'
95+
);
96+
}
97+
98+
$this->client = new SoapClient(self::URL, [
99+
'connection_timeout' => $this->timeout,
100+
'cache_wsdl' => WSDL_CACHE_DISK,
101+
'keep_alive' => false,
102+
'compression' => SOAP_COMPRESSION_ACCEPT | SOAP_COMPRESSION_GZIP,
103+
'stream_context' => stream_context_create([
104+
'http' => [
105+
'timeout' => $this->timeout,
106+
],
107+
]),
108+
]);
81109
}
82110

83111
return $this->client;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace Ibericode\Vat\Vies;
4+
5+
/**
6+
* Thrown when the VIES service refused to give a definitive answer.
7+
*
8+
* This wraps the operationally-distinct error codes returned by VIES that
9+
* indicate the service is temporarily unable to respond — as opposed to
10+
* a definitive "VAT number is invalid". Callers should treat these as
11+
* transient and retry with backoff rather than recording the VAT as invalid.
12+
*
13+
* Covers VIES error codes:
14+
* - MS_UNAVAILABLE (the member-state's node is down)
15+
* - SERVICE_UNAVAILABLE (VIES global outage)
16+
* - TIMEOUT (member-state node timed out)
17+
* - MS_MAX_CONCURRENT_REQ (per-member-state throttling)
18+
* - GLOBAL_MAX_CONCURRENT_REQ (global throttling)
19+
* - IP_BLOCKED (caller IP temporarily blocked)
20+
*/
21+
class ViesServiceUnavailableException extends ViesException
22+
{
23+
private const TRANSIENT_FAULT_STRINGS = [
24+
'MS_UNAVAILABLE',
25+
'SERVICE_UNAVAILABLE',
26+
'TIMEOUT',
27+
'MS_MAX_CONCURRENT_REQ',
28+
'GLOBAL_MAX_CONCURRENT_REQ',
29+
'IP_BLOCKED',
30+
];
31+
32+
public static function isTransientFault(string $faultString): bool
33+
{
34+
$upper = strtoupper($faultString);
35+
foreach (self::TRANSIENT_FAULT_STRINGS as $needle) {
36+
if (str_contains($upper, $needle)) {
37+
return true;
38+
}
39+
}
40+
41+
return false;
42+
}
43+
}

tests/Clients/ClientsTest.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Ibericode\Vat\Period;
99
use PHPUnit\Framework\Attributes\DataProvider;
1010
use PHPUnit\Framework\TestCase;
11+
use ReflectionMethod;
1112

1213
class ClientsTest extends TestCase
1314
{
@@ -29,4 +30,36 @@ public static function clientsProvider(): \Generator
2930
{
3031
yield [new IbericodeVatRatesClient()];
3132
}
33+
34+
#[DataProvider('malformedResponseProvider')]
35+
public function testParseResponseThrowsOnMalformedBody(string $body): void
36+
{
37+
$client = new IbericodeVatRatesClient();
38+
$method = new ReflectionMethod($client, 'parseResponse');
39+
$this->expectException(ClientException::class);
40+
$method->invoke($client, $body);
41+
}
42+
43+
public static function malformedResponseProvider(): \Generator
44+
{
45+
yield 'empty body' => [''];
46+
yield 'invalid JSON' => ['not json'];
47+
yield 'JSON null' => ['null'];
48+
yield 'JSON without items' => ['{"foo":"bar"}'];
49+
yield 'items not an object' => ['{"items":"oops"}'];
50+
yield 'period entry missing fields' => ['{"items":{"NL":[{"foo":"bar"}]}}'];
51+
yield 'periods not an array' => ['{"items":{"NL":"oops"}}'];
52+
}
53+
54+
public function testParseResponseAcceptsValidBody(): void
55+
{
56+
$body = '{"items":{"NL":[{"effective_from":"2019-01-01","rates":{"standard":21.0,"reduced":9.0}}]}}';
57+
$client = new IbericodeVatRatesClient();
58+
$method = new ReflectionMethod($client, 'parseResponse');
59+
$data = $method->invoke($client, $body);
60+
61+
$this->assertIsArray($data);
62+
$this->assertArrayHasKey('NL', $data);
63+
$this->assertInstanceOf(Period::class, $data['NL'][0]);
64+
}
3265
}

0 commit comments

Comments
 (0)