CluPilotCloud/tests/Feature/Billing/VatIdVerificationTest.php

157 lines
6.2 KiB
PHP

<?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();
});