CluPilotCloud/app/Services/Tax/ViesVatIdVerifier.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',
), '/');
}
}