119 lines
4.9 KiB
PHP
119 lines
4.9 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.
|
|
*
|
|
* ## Scheduled monthly, and why that is not the owner's decision to inherit
|
|
*
|
|
* This used to say it was deliberately NOT scheduled, and to point at a note in
|
|
* routes/console.php that did not exist — so an owner reading the schedule could
|
|
* not learn the command was there at all, and reverse charge went on resting on a
|
|
* one-off answer for ever.
|
|
*
|
|
* It is scheduled now, because the exposure is the SELLER's. A registration that
|
|
* is withdrawn keeps earning rate 0 on every invoice we issue afterwards, and the
|
|
* unpaid VAT on those is ours to make good, not the customer's — so the cadence is
|
|
* not a preference but the width of the window in which that can happen unnoticed.
|
|
* Monthly, on the first, because that is the rhythm the VAT return is filed on: a
|
|
* number that lapsed in March is caught before the March return is prepared.
|
|
*
|
|
* The public service's concurrency limit is answered by `--limit` rather than by
|
|
* not running: the query takes the longest-unchecked first, so a customer base
|
|
* larger than one run rotates through it instead of starving. `--dry-run` and
|
|
* `--customer` are for the by-hand run before a 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;
|
|
}
|
|
}
|