110 lines
4.1 KiB
PHP
110 lines
4.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Tax;
|
|
|
|
use App\Models\Customer;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
/**
|
|
* The Commission's VIES register, over its REST endpoint.
|
|
*
|
|
* REST rather than the SOAP service the documentation leads with: one request,
|
|
* one JSON answer, and no generated client to keep in step. No credentials —
|
|
* VIES is public.
|
|
*
|
|
* Why any of this exists: reverse charge is only lawful against a VAT number
|
|
* that is actually registered in another member state, and until this class was
|
|
* written nothing in the codebase ever checked one. `vat_id_verified_at` was
|
|
* declared on the model, read by TaxTreatment, and set by no code at all — so
|
|
* reverse charge could never trigger and every EU business customer was invoiced
|
|
* Austrian VAT they cannot reclaim at home.
|
|
*/
|
|
final class ViesVatIdVerifier implements VatIdVerifier
|
|
{
|
|
/**
|
|
* The gateway's own errors, as opposed to a verdict on the number.
|
|
*
|
|
* VIES answers `valid: false` for both "this number is not registered" and
|
|
* "I could not ask" — the difference is only in `userError`. Reading the
|
|
* boolean alone would turn every outage into a customer losing their reverse
|
|
* charge, which is exactly the mistake VatIdCheck exists to prevent.
|
|
*/
|
|
private const GATEWAY_ERRORS = [
|
|
'SERVICE_UNAVAILABLE', 'MS_UNAVAILABLE', 'MS_MAX_CONCURRENT_REQ',
|
|
'TIMEOUT', 'SERVER_BUSY', 'GLOBAL_MAX_CONCURRENT_REQ',
|
|
];
|
|
|
|
public function check(string $vatId): VatIdCheck
|
|
{
|
|
// Normalised here so no caller has to: the same number arrives as
|
|
// "ATU12345678", "atu 123 456 78" and "ATU12345678 " from three
|
|
// different forms, and the register accepts exactly one of those.
|
|
$normalised = Customer::normaliseVatId($vatId);
|
|
|
|
// Shared with the fake, so a test cannot prove something about a
|
|
// simplification that the real register would never have been asked.
|
|
$parts = VatIdShape::split($normalised);
|
|
|
|
if ($parts === null) {
|
|
// Not a verdict from the register, but there is nothing to ask about:
|
|
// no member state could hold a number like this.
|
|
return VatIdCheck::invalid();
|
|
}
|
|
|
|
try {
|
|
$response = Http::acceptJson()
|
|
// Short on purpose. This runs while somebody waits for a form to
|
|
// save, and VIES answering slowly must cost them seconds, not the
|
|
// request. A timeout is an outage, and an outage changes nothing.
|
|
->timeout(8)
|
|
->post($this->endpoint(), [
|
|
'countryCode' => $parts[1],
|
|
'vatNumber' => $parts[2],
|
|
]);
|
|
} catch (Throwable $e) {
|
|
return VatIdCheck::unavailable($e->getMessage());
|
|
}
|
|
|
|
if (! $response->successful()) {
|
|
return VatIdCheck::unavailable('http_'.$response->status());
|
|
}
|
|
|
|
$error = (string) ($response->json('userError') ?? '');
|
|
|
|
if (in_array($error, self::GATEWAY_ERRORS, true)) {
|
|
Log::info('VIES could not answer for a VAT number', ['reason' => $error]);
|
|
|
|
return VatIdCheck::unavailable($error);
|
|
}
|
|
|
|
if ($response->json('valid') !== true) {
|
|
return VatIdCheck::invalid();
|
|
}
|
|
|
|
// The register returns "---" for a company that has asked not to have its
|
|
// details published. That is a registered number with no name attached,
|
|
// not an empty answer, so it is stored as nothing rather than as dashes.
|
|
return VatIdCheck::valid(
|
|
$this->orNull($response->json('name')),
|
|
$this->orNull($response->json('address')),
|
|
);
|
|
}
|
|
|
|
private function orNull(mixed $value): ?string
|
|
{
|
|
$value = trim((string) $value);
|
|
|
|
return $value === '' || trim($value, '- ') === '' ? null : $value;
|
|
}
|
|
|
|
private function endpoint(): string
|
|
{
|
|
return rtrim((string) config(
|
|
'services.vies.endpoint',
|
|
'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number',
|
|
), '/');
|
|
}
|
|
}
|