fix(billing): verification vouches for a value, not for a row
tests / pest (push) Successful in 6m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

A timestamp alone said "some number was checked once": editing the field left
it intact, so a customer could swap a verified foreign VAT ID for any
plausible-looking one and keep zero-VAT pricing. The verified value is stored
and compared, which makes the rule self-enforcing — no writer has to remember
to clear a flag, and there are several writers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 09:34:40 +02:00
parent 42ceafb57f
commit 5aef5e693c
4 changed files with 60 additions and 7 deletions

View File

@ -14,11 +14,29 @@ use RuntimeException;
class Customer extends Model class Customer extends Model
{ {
/** Comparable form: spaces and punctuation are cosmetic in a VAT number. */
public function normalisedVatId(): string
{
return strtoupper(preg_replace('/[\s.-]+/', '', (string) $this->vat_id) ?? '');
}
/**
* True only for the value that was actually checked. Binding it to the
* value rather than to a flag means editing the field cannot inherit the
* old confirmation, whichever code path does the editing.
*/
public function hasVerifiedVatId(): bool
{
return $this->vat_id_verified_at !== null
&& $this->normalisedVatId() !== ''
&& $this->normalisedVatId() === strtoupper((string) $this->vat_id_verified_value);
}
/** @use HasFactory<\Database\Factories\CustomerFactory> */ /** @use HasFactory<\Database\Factories\CustomerFactory> */
use HasFactory, HasUuid; use HasFactory, HasUuid;
protected $fillable = [ protected $fillable = [
'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'vat_id_verified_at', 'billing_address', 'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address',
'locale', 'stripe_customer_id', 'status', 'closed_at', 'locale', 'stripe_customer_id', 'status', 'closed_at',
'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color', 'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color',
]; ];

View File

@ -42,11 +42,11 @@ final readonly class TaxTreatment
// Unverified is the normal state, and it must cost the customer nothing // Unverified is the normal state, and it must cost the customer nothing
// more than the domestic rate they would pay anyway — but it must not // more than the domestic rate they would pay anyway — but it must not
// zero the VAT either. Otherwise typing "XX123" is a discount. // zero the VAT either. Otherwise typing "XX123" is a discount.
if ($customer?->vat_id_verified_at === null) { if ($customer === null || ! $customer->hasVerifiedVatId()) {
return new self($domestic, false); return new self($domestic, false);
} }
$vatId = strtoupper(preg_replace('/[\s.-]+/', '', (string) $customer->vat_id) ?? ''); $vatId = $customer->normalisedVatId();
$country = substr($vatId, 0, 2); $country = substr($vatId, 0, 2);
$seller = strtoupper((string) config('provisioning.tax.seller_country', 'AT')); $seller = strtoupper((string) config('provisioning.tax.seller_country', 'AT'));

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The exact VAT ID that was verified.
*
* A timestamp alone says "some number was checked once" edit the field and
* the old confirmation silently vouches for the new value. Storing what was
* verified makes the check self-enforcing: no writer has to remember to clear
* a flag.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->string('vat_id_verified_value')->nullable()->after('vat_id_verified_at');
});
}
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->dropColumn('vat_id_verified_value');
});
}
};

View File

@ -155,7 +155,7 @@ it('does not charge Austrian VAT to an EU business with a VAT ID', function () {
config()->set('provisioning.tax.seller_country', 'AT'); config()->set('provisioning.tax.seller_country', 'AT');
[$customer, $user] = cartCustomer(); [$customer, $user] = cartCustomer();
$customer->update(['vat_id' => 'DE 811 907 980', 'vat_id_verified_at' => now()]); $customer->update(['vat_id' => 'DE 811 907 980', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'DE811907980']);
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
// Reverse charge: we invoice net, the customer accounts for the VAT. // Reverse charge: we invoice net, the customer accounts for the VAT.
@ -177,7 +177,7 @@ it('charges domestic VAT to a domestic business and to private customers', funct
expect(Order::query()->first()->grossCents())->toBe(1200); expect(Order::query()->first()->grossCents())->toBe(1200);
// An Austrian VAT ID is a domestic business sale, taxed normally. // An Austrian VAT ID is a domestic business sale, taxed normally.
$customer->update(['vat_id' => 'ATU12345678', 'vat_id_verified_at' => now()]); $customer->update(['vat_id' => 'ATU12345678', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'ATU12345678']);
expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); expect(Order::query()->first()->fresh()->grossCents())->toBe(1200);
Livewire\Livewire::actingAs($user)->test(Billing::class) Livewire\Livewire::actingAs($user)->test(Billing::class)
@ -196,10 +196,15 @@ it('will not let a typed-in VAT ID zero the tax', function () {
expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); expect(Order::query()->first()->fresh()->grossCents())->toBe(1200);
// Nonsense, even if someone marked it verified: not an EU member state. // Nonsense, even if someone marked it verified: not an EU member state.
$customer->update(['vat_id' => 'XX123', 'vat_id_verified_at' => now()]); $customer->update(['vat_id' => 'XX123', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'XX123']);
expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); expect(Order::query()->first()->fresh()->grossCents())->toBe(1200);
// Verified and from a member state that is not ours: reverse charge. // Verified and from a member state that is not ours: reverse charge.
$customer->update(['vat_id' => 'DE811907980', 'vat_id_verified_at' => now()]); $customer->update(['vat_id' => 'DE811907980', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'DE811907980']);
expect(Order::query()->first()->fresh()->grossCents())->toBe(1000); expect(Order::query()->first()->fresh()->grossCents())->toBe(1000);
// Swapping the number keeps the old confirmation in the row — it must not
// vouch for a value nobody checked.
$customer->update(['vat_id' => 'FR12345678901']);
expect(Order::query()->first()->fresh()->grossCents())->toBe(1200);
}); });