CluPilotCloud/app/Console/Commands/VerifyVatIds.php

105 lines
4.0 KiB
PHP

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