157 lines
6.2 KiB
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();
|
|
});
|