105 lines
4.0 KiB
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;
|
|
}
|
|
}
|