Ask the EU register whether a VAT number is real
tests / pest (push) Failing after 8m24s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details

vat_id_verified_at was declared on the customer and read by TaxTreatment, and
set by no code anywhere. hasVerifiedVatId() therefore never returned true, so
reverse charge could not trigger for anybody - and every EU business customer was
invoiced Austrian VAT they cannot reclaim in their own country, which is not a
pass-through for them but a real cost until the invoice is corrected.

The check has THREE outcomes, and the third is why VatIdCheck exists. VIES is a
gateway onto twenty-seven national registers and any of them is regularly down.
Reading "I could not ask" as "not registered" would clear a verification that has
stood for months, move that customer onto the domestic rate, and change what
their next invoice says - because a register was asleep. Reading it as
"registered" would put reverse charge on an unchecked number, which is our
liability. So on UNAVAILABLE nothing at all is written.

Asked at the moment the number is entered, because that is when somebody is
looking at the field and can fix a typo. The save has already happened by then
and the check cannot undo it: a register that is down must not cost a customer
their address change.

Shape and membership are refused before a request is spent, and the fake refuses
on the same rule as the real verifier. A fake that answers where VIES would never
have been asked is not a simplification but a different product, and free text
normalises into a well-formed number more easily than it looks - "not a number"
becomes NOTANUMBER, which the shape rule alone reads as a Norwegian one.

clupilot:verify-vat-ids re-asks about the numbers reverse charge rests on, since
a registration can be withdrawn. Deliberately not scheduled: it queries a public
service with a concurrency limit on behalf of somebody else's tax position, and
the cadence is the owner's to set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-29 22:42:29 +02:00
parent f02e86769b
commit 2277dfe4cb
12 changed files with 716 additions and 3 deletions

View File

@ -0,0 +1,84 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Services\Tax\VatIdCheck;
use App\Services\Tax\VatIdVerifier;
/**
* Ask the register about a customer's VAT number and record what it said.
*
* The one place a check becomes a stored verification, so the portal form, the
* console and the nightly re-check cannot disagree about what counts. It decides
* nothing about tax: TaxTreatment reads the stored verification and applies the
* reverse-charge rule. This only answers "is the number real".
*
* Deliberately not gated on the customer's recorded type. Whether a verified
* number earns reverse charge is TaxTreatment's question it already refuses a
* customer who has recorded themselves as a private person and a number that
* is registered is registered whoever holds it. Splitting that decision across
* two files is how the two would drift.
*/
class VerifyCustomerVatId
{
public function __construct(private VatIdVerifier $verifier) {}
/**
* Returns what the register said, so the caller can tell the customer.
*
* Null when there is no number to check at all not an outcome, an absence,
* and a caller showing "invalid" for an empty field would be wrong.
*/
public function __invoke(Customer $customer): ?VatIdCheck
{
$vatId = $customer->normalisedVatId();
if ($vatId === '') {
// Removing the number removes the verification with it. Otherwise the
// stamp would sit on the record with nothing to compare against, and
// the next number typed in would look pre-verified for one save.
$this->clear($customer);
return null;
}
$check = $this->verifier->check($vatId);
// The whole reason VatIdCheck has three outcomes. An outage tells us
// nothing, so nothing is written: a verification that has been standing
// for months must not be cleared — and a customer's invoices changed —
// because a national register was down for the night.
if ($check->isUnavailable()) {
return $check;
}
if ($check->isInvalid()) {
$this->clear($customer);
return $check;
}
// Bound to the value that was checked, not to a flag. Customer::
// hasVerifiedVatId() compares the two, so editing the number afterwards
// cannot inherit this confirmation whichever code path does the editing.
$customer->forceFill([
'vat_id_verified_at' => now(),
'vat_id_verified_value' => $vatId,
])->save();
return $check;
}
private function clear(Customer $customer): void
{
if ($customer->vat_id_verified_at === null && $customer->vat_id_verified_value === null) {
return;
}
$customer->forceFill([
'vat_id_verified_at' => null,
'vat_id_verified_value' => null,
])->save();
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Console\Commands;
use App\Actions\VerifyCustomerVatId;
use App\Models\Customer;
use Illuminate\Console\Command;
/**
* Re-ask the register about the VAT numbers we are relying on.
*
* A number is not verified once and for ever. A company is wound up, a
* registration is withdrawn, a member state removes it and from that moment we
* are zero-rating an invoice against a number that no longer exists, which is our
* liability and not the customer's. So the ones reverse charge actually rests on
* are asked again.
*
* It also picks up the numbers nobody has checked yet: every number entered
* before this check existed is unverified, so those customers are on the domestic
* rate today whether or not they are entitled to reverse charge.
*
* Deliberately NOT scheduled by default. It talks to a public service with a
* concurrency limit, on behalf of somebody else's tax position, and an owner
* should decide the cadence rather than inherit one see the note in
* routes/console.php. Run it monthly, or before a VAT return.
*/
class VerifyVatIds extends Command
{
protected $signature = 'clupilot:verify-vat-ids
{--dry-run : Show what would be asked without touching the register or any record}
{--customer= : One customer, by uuid}
{--limit=200 : How many to look at in one run}';
protected $description = 'Check customer VAT numbers against the EU VIES register';
public function handle(VerifyCustomerVatId $verify): int
{
$customers = Customer::query()
->whereNotNull('vat_id')
->where('vat_id', '!=', '')
->when($this->option('customer'), fn ($q, $uuid) => $q->where('uuid', $uuid))
// Longest unchecked first, so a short run covers the ones we know
// least about rather than the ones checked yesterday. A plain
// ascending sort does that on its own — never-checked rows are NULL
// and both engines sort those first — which is why there is no raw
// SQL here: MariaDB and SQLite would need different expressions for
// it, and a query that only runs on one of them makes the test
// database a different product from the live one.
->orderBy('vat_id_verified_at')
->limit((int) $this->option('limit'))
->get();
if ($customers->isEmpty()) {
$this->info('No VAT numbers on record.');
return self::SUCCESS;
}
$counts = ['verified' => 0, 'rejected' => 0, 'unchecked' => 0];
foreach ($customers as $customer) {
if ($this->option('dry-run')) {
$this->line(sprintf('would ask: %s (%s)', $customer->normalisedVatId(), $customer->name));
continue;
}
$check = $verify($customer);
match (true) {
$check === null => null,
$check->isValid() => $counts['verified']++,
$check->isInvalid() => $counts['rejected']++,
default => $counts['unchecked']++,
};
if ($check?->isInvalid()) {
// Named individually, because this one needs a person: an invoice
// already issued at 0 % against a number the register now rejects
// has to be looked at, and a count in a summary hides that.
$this->warn(sprintf(
'not registered: %s (%s) — reverse charge no longer applies',
$customer->normalisedVatId(),
$customer->name,
));
}
}
if ($this->option('dry-run')) {
$this->info($customers->count().' number(s) would be asked about.');
return self::SUCCESS;
}
$this->info(sprintf(
'%d confirmed, %d rejected, %d could not be checked (register unavailable — nothing changed for those).',
$counts['verified'],
$counts['rejected'],
$counts['unchecked'],
));
return self::SUCCESS;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Actions\VerifyCustomerVatId;
use App\Actions\WithdrawContract;
use App\Livewire\Concerns\ChangesOwnPassword;
use App\Livewire\Concerns\ConfirmsPassword;
@ -206,7 +207,39 @@ class Settings extends Component
'billing_address' => $data['billingAddress'] ?: null,
]);
$this->dispatch('notify', message: __('settings.profile_saved'));
$this->dispatch('notify', message: $this->verifyVatId($c));
}
/**
* Check the number against the register, and say what came back.
*
* Here, at the moment it is entered, because that is the only moment somebody
* is looking at the field and can correct a typo. Reverse charge is only
* lawful against a number that is actually registered, so an unchecked number
* is charged the domestic rate which means an EU business that mistyped
* theirs pays VAT it cannot reclaim at home until somebody notices.
*
* The save has already happened by the time this runs and cannot be undone by
* it. A register that is down must not cost the customer their address change,
* and a number the register rejects is still the number they typed theirs to
* correct, not ours to discard.
*/
private function verifyVatId(Customer $customer): string
{
$check = app(VerifyCustomerVatId::class)($customer);
if ($check === null) {
return __('settings.profile_saved');
}
if ($check->isValid()) {
return __('settings.vat_verified');
}
// Told apart on purpose. "We could not check right now" is our problem and
// resolves itself on the next sweep; "the register does not know this
// number" is something only the customer can fix.
return __($check->isUnavailable() ? 'settings.vat_unchecked' : 'settings.vat_unknown');
}
public function saveBranding(): void

View File

@ -7,8 +7,6 @@ use App\Http\Middleware\EnsureCustomerActive;
use App\Http\Middleware\RestrictAdminHost;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Listeners\RecordSentMail;
use App\Services\Mail\ImapMailbox;
use App\Services\Mail\InboundMailbox;
use App\Listeners\RecordSignInDevice;
use App\Mail\MaintenanceCancelledMail;
use App\Mail\Transport\MailboxTransport;
@ -20,6 +18,8 @@ use App\Services\Dns\FileHostDnsDirectory;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Dns\HostDnsDirectory;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Mail\ImapMailbox;
use App\Services\Mail\InboundMailbox;
use App\Services\Maintenance\MaintenanceNotifier;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Monitoring\MonitoringClient;
@ -29,6 +29,8 @@ use App\Services\Ssh\PhpseclibRemoteShell;
use App\Services\Ssh\RemoteShell;
use App\Services\Stripe\HttpStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Tax\VatIdVerifier;
use App\Services\Tax\ViesVatIdVerifier;
use App\Services\Traefik\SshTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub;
@ -64,6 +66,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(TraefikWriter::class, SshTraefikWriter::class);
$this->app->bind(MonitoringClient::class, HttpMonitoringClient::class);
$this->app->bind(StripeClient::class, HttpStripeClient::class);
$this->app->bind(VatIdVerifier::class, ViesVatIdVerifier::class);
// The support mailbox, read over IMAP. A test puts a FakeMailbox here:
// the socket half cannot be exercised without a mail server, and a test
// that needs one is a test nobody runs. What IS exercised is the

View File

@ -0,0 +1,60 @@
<?php
namespace App\Services\Tax;
use App\Models\Customer;
/**
* The register, for tests.
*
* Answers UNAVAILABLE for anything it was not told about, which is deliberately
* the awkward default: a test that forgets to arm a number finds out, instead of
* quietly proving that an unchecked number earns a reverse charge.
*/
final class FakeVatIdVerifier implements VatIdVerifier
{
/** @var array<string, VatIdCheck> */
private array $answers = [];
/** @var array<int, string> every number that was asked about, in order */
public array $asked = [];
public function registered(string $vatId, ?string $name = null): self
{
$this->answers[Customer::normaliseVatId($vatId)] = VatIdCheck::valid($name);
return $this;
}
public function notRegistered(string $vatId): self
{
$this->answers[Customer::normaliseVatId($vatId)] = VatIdCheck::invalid();
return $this;
}
/** The register is down — the case that must never clear a verification. */
public function down(string $vatId): self
{
$this->answers[Customer::normaliseVatId($vatId)] = VatIdCheck::unavailable('test');
return $this;
}
public function check(string $vatId): VatIdCheck
{
$normalised = Customer::normaliseVatId($vatId);
// Refused before it is recorded as asked, exactly as the real verifier
// refuses before it spends a request. Without this the fake would answer
// where VIES would never have been consulted, and a test could prove the
// opposite of what the product does.
if (! VatIdShape::verifiable($normalised)) {
return VatIdCheck::invalid();
}
$this->asked[] = $normalised;
return $this->answers[$normalised] ?? VatIdCheck::unavailable('not armed in this test');
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Services\Tax;
/**
* What VIES answered about one VAT number.
*
* Three outcomes, not two, and the third is the whole reason this class exists.
* VIES is a gateway onto twenty-seven national registers, and any one of them is
* regularly unreachable a member state takes its service down for the night,
* the concurrency limit is hit, the gateway times out. None of that says anything
* about the number.
*
* So UNAVAILABLE must never be read as INVALID. Treating an outage as a failed
* check would clear a verification that has been standing for months, move that
* customer from reverse charge onto the domestic rate, and change what their next
* invoice says because a national register was asleep. It must not be read as
* VALID either: reverse charge on an unchecked number is our liability, not the
* customer's.
*
* The name and address are what the register holds. They are kept because they
* are the evidence that the number belongs to the company we are invoicing, and
* because a mismatch there is worth an operator's attention even when the number
* itself is registered.
*/
final readonly class VatIdCheck
{
public const VALID = 'valid';
public const INVALID = 'invalid';
public const UNAVAILABLE = 'unavailable';
private function __construct(
public string $outcome,
public ?string $name = null,
public ?string $address = null,
public ?string $reason = null,
) {}
public static function valid(?string $name = null, ?string $address = null): self
{
return new self(self::VALID, $name, $address);
}
public static function invalid(): self
{
return new self(self::INVALID);
}
/** @param string|null $reason what stopped us asking — for the log, never for the customer */
public static function unavailable(?string $reason = null): self
{
return new self(self::UNAVAILABLE, reason: $reason);
}
public function isValid(): bool
{
return $this->outcome === self::VALID;
}
public function isInvalid(): bool
{
return $this->outcome === self::INVALID;
}
/**
* Nothing was learnt.
*
* Callers must leave whatever they already knew alone when this is true
* see the class comment for why that is not merely cautious.
*/
public function isUnavailable(): bool
{
return $this->outcome === self::UNAVAILABLE;
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services\Tax;
use App\Models\Customer;
use App\Services\Billing\TaxTreatment;
/**
* Whether a string is even worth asking VIES about.
*
* Shared by the real verifier and the fake, deliberately. A fake that answers
* where the real one would not have asked is not a simplification, it is a
* different product and every test written against it proves something about
* that product instead of about this one.
*
* Two conditions, and the second is less obvious than it looks. Free text
* normalises into a well-formed VAT number more easily than one would think:
* "not a number" loses its spaces and becomes NOTANUMBER, which the shape rule
* alone reads as a perfectly good Norwegian number. VIES is the EU's register and
* holds nothing else, and reverse charge is an intra-EU rule in any case, so a
* prefix outside the union is answered here rather than spent on a request.
*/
final class VatIdShape
{
/** @return bool false when no member state could hold a number like this */
public static function verifiable(string $vatId): bool
{
$normalised = Customer::normaliseVatId($vatId);
if (preg_match('/^([A-Z]{2})([0-9A-Z]{8,12})$/', $normalised, $parts) !== 1) {
return false;
}
// Against TaxTreatment's list rather than a second copy of it: two lists
// of member states is how one of them ends up a year out of date.
return in_array($parts[1], TaxTreatment::EU_MEMBER_STATES, true);
}
/**
* The two halves VIES wants, or null when there is nothing to ask.
*
* @return array{0: string, 1: string}|null
*/
public static function split(string $vatId): ?array
{
$normalised = Customer::normaliseVatId($vatId);
if (! self::verifiable($normalised)) {
return null;
}
return [substr($normalised, 0, 2), substr($normalised, 2)];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Services\Tax;
interface VatIdVerifier
{
/**
* Ask the register whether this VAT number exists.
*
* Takes the number in any shape a person might type it spaces, dots, a
* lower-case country prefix and normalises it itself, so no caller has to
* remember to. Returns UNAVAILABLE rather than throwing when the register
* cannot be reached: an outage is an answer callers have to handle, not an
* exception to be swallowed at each call site.
*/
public function check(string $vatId): VatIdCheck;
}

View File

@ -0,0 +1,109 @@
<?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',
), '/');
}
}

View File

@ -13,6 +13,14 @@ return [
'billing_address' => 'Rechnungsadresse',
'profile_saved' => 'Firmendaten gespeichert.',
// Drei Meldungen statt einer, weil die drei Fälle für den Kunden etwas
// Verschiedenes bedeuten: bestätigt, von uns gerade nicht prüfbar, oder dem
// Register unbekannt. Nur der letzte ist etwas, das er selbst richtigstellen
// kann — siehe App\Services\Tax\VatIdCheck.
'vat_verified' => 'Firmendaten gespeichert. Ihre UID ist im EU-Register bestätigt.',
'vat_unchecked' => 'Firmendaten gespeichert. Das EU-Register ist gerade nicht erreichbar — Ihre UID wird erneut geprüft.',
'vat_unknown' => 'Firmendaten gespeichert. Das EU-Register kennt diese UID nicht — bitte prüfen Sie die Nummer. Bis dahin wird Umsatzsteuer verrechnet.',
'branding_title' => 'Branding',
'branding_sub' => 'Logo und Farben werden bei der Bereitstellung auf Ihre Cloud angewendet. Ohne Angabe nutzen wir die CluPilot-Standardwerte.',
'brand_display_name' => 'Anzeigename',

View File

@ -13,6 +13,14 @@ return [
'billing_address' => 'Billing address',
'profile_saved' => 'Company details saved.',
// Three messages rather than one, because the three cases mean different
// things to the customer: confirmed, not checkable by us right now, or
// unknown to the register. Only the last is something they can put right —
// see App\Services\Tax\VatIdCheck.
'vat_verified' => 'Company details saved. Your VAT number is confirmed in the EU register.',
'vat_unchecked' => 'Company details saved. The EU register cannot be reached right now — your VAT number will be checked again.',
'vat_unknown' => 'Company details saved. The EU register does not know this VAT number — please check it. Until then VAT is charged.',
'branding_title' => 'Branding',
'branding_sub' => 'Your logo and colors are applied to your cloud during provisioning. If left empty we use the CluPilot defaults.',
'brand_display_name' => 'Display name',

View File

@ -0,0 +1,156 @@
<?php
use App\Actions\VerifyCustomerVatId;
use App\Models\Customer;
use App\Services\Billing\TaxTreatment;
use App\Services\Tax\FakeVatIdVerifier;
use App\Services\Tax\VatIdVerifier;
/**
* Checking a VAT number against the EU register.
*
* The hole this closes: `vat_id_verified_at` was declared on the model and read
* by TaxTreatment, and set by no code anywhere. So reverse charge could never
* trigger, and every EU business customer was invoiced Austrian VAT they cannot
* reclaim in their own country.
*
* The tests are mostly about the third answer. VIES is a gateway onto twenty-seven
* national registers and any of them is regularly down, so "I could not ask" has
* to be told apart from "the number is not registered" one of them must change
* nothing at all.
*/
function armRegister(): FakeVatIdVerifier
{
$fake = new FakeVatIdVerifier;
app()->instance(VatIdVerifier::class, $fake);
return $fake;
}
function businessWithVatId(string $vatId): Customer
{
return Customer::factory()->create([
'customer_type' => Customer::TYPE_BUSINESS,
'vat_id' => $vatId,
]);
}
it('records a number the register knows, and reverse charge then applies', function () {
armRegister()->registered('NL123456789B01', 'Berger B.V.');
$customer = businessWithVatId('NL123456789B01');
app(VerifyCustomerVatId::class)($customer);
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue()
// Proven through the tax rule, not through the column: the column is only
// interesting because of what TaxTreatment does with it.
->and(TaxTreatment::for($customer->fresh())->reverseCharge)->toBeTrue()
->and(TaxTreatment::for($customer->fresh())->rate)->toBe(0.0);
});
it('refuses a number the register does not know, and keeps charging VAT', function () {
armRegister()->notRegistered('NL999999999B99');
$customer = businessWithVatId('NL999999999B99');
$check = app(VerifyCustomerVatId::class)($customer);
expect($check->isInvalid())->toBeTrue()
->and($customer->fresh()->hasVerifiedVatId())->toBeFalse()
->and(TaxTreatment::for($customer->fresh())->reverseCharge)->toBeFalse();
});
it('changes nothing at all when the register is unreachable', function () {
// The one that matters. An outage must not clear a verification that has been
// standing for months and quietly move that customer onto the domestic rate —
// their next invoice would say something different because a national register
// was asleep.
$customer = businessWithVatId('NL123456789B01');
armRegister()->registered('NL123456789B01');
app(VerifyCustomerVatId::class)($customer);
$stampedAt = $customer->fresh()->vat_id_verified_at;
armRegister()->down('NL123456789B01');
$check = app(VerifyCustomerVatId::class)($customer->fresh());
expect($check->isUnavailable())->toBeTrue()
->and($customer->fresh()->hasVerifiedVatId())->toBeTrue()
->and($customer->fresh()->vat_id_verified_at->equalTo($stampedAt))->toBeTrue()
->and(TaxTreatment::for($customer->fresh())->reverseCharge)->toBeTrue();
});
it('withdraws reverse charge once the register stops knowing the number', function () {
// A registration can be revoked. From that moment we are zero-rating against
// a number that does not exist, which is our liability, not the customer's.
$customer = businessWithVatId('NL123456789B01');
armRegister()->registered('NL123456789B01');
app(VerifyCustomerVatId::class)($customer);
armRegister()->notRegistered('NL123456789B01');
app(VerifyCustomerVatId::class)($customer->fresh());
expect($customer->fresh()->hasVerifiedVatId())->toBeFalse()
->and(TaxTreatment::for($customer->fresh())->reverseCharge)->toBeFalse();
});
it('drops the verification when the number is removed', function () {
$customer = businessWithVatId('NL123456789B01');
armRegister()->registered('NL123456789B01');
app(VerifyCustomerVatId::class)($customer);
$customer->update(['vat_id' => null]);
$check = app(VerifyCustomerVatId::class)($customer->fresh());
expect($check)->toBeNull()
->and($customer->fresh()->vat_id_verified_at)->toBeNull()
->and($customer->fresh()->vat_id_verified_value)->toBeNull();
});
it('never asks the register about a number no member state could hold', function () {
$fake = armRegister();
$customer = businessWithVatId('not-a-number');
$check = app(VerifyCustomerVatId::class)($customer);
expect($check->isInvalid())->toBeTrue()
// Not one request spent on something that cannot be a VAT number.
->and($fake->asked)->toBeEmpty();
});
it('still charges domestic VAT to a business in our own country', function () {
// Reverse charge is a cross-border rule. An Austrian business pays Austrian
// VAT and reclaims it as input tax like any other Austrian buyer.
armRegister()->registered('ATU12345678');
$customer = businessWithVatId('ATU12345678');
app(VerifyCustomerVatId::class)($customer);
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue()
->and(TaxTreatment::for($customer->fresh())->reverseCharge)->toBeFalse()
->and(TaxTreatment::for($customer->fresh())->rate)->toBeGreaterThan(0.0);
});
it('still charges VAT to somebody who has recorded themselves as a private person', function () {
armRegister()->registered('NL123456789B01');
$customer = Customer::factory()->create([
'customer_type' => Customer::TYPE_CONSUMER,
'vat_id' => 'NL123456789B01',
]);
app(VerifyCustomerVatId::class)($customer);
// The number is real — it is just not theirs to zero the tax with.
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue()
->and(TaxTreatment::for($customer->fresh())->reverseCharge)->toBeFalse();
});
it('asks about every number on record and touches nothing in a dry run', function () {
armRegister()->registered('NL123456789B01');
$customer = businessWithVatId('NL123456789B01');
$this->artisan('clupilot:verify-vat-ids --dry-run')->assertSuccessful();
expect($customer->fresh()->hasVerifiedVatId())->toBeFalse();
$this->artisan('clupilot:verify-vat-ids')->assertSuccessful();
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue();
});