From 5aef5e693caeef34bb5b1f11ef682f07990b4869 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 09:34:40 +0200 Subject: [PATCH] fix(billing): verification vouches for a value, not for a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Models/Customer.php | 20 ++++++++++++- app/Services/Billing/TaxTreatment.php | 4 +-- ..._010001_bind_vat_verification_to_value.php | 30 +++++++++++++++++++ tests/Feature/CartTest.php | 13 +++++--- 4 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_07_26_010001_bind_vat_verification_to_value.php diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 6016084..30d50dd 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -14,11 +14,29 @@ use RuntimeException; 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, HasUuid; 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', 'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color', ]; diff --git a/app/Services/Billing/TaxTreatment.php b/app/Services/Billing/TaxTreatment.php index aa52e3f..ca0b9ae 100644 --- a/app/Services/Billing/TaxTreatment.php +++ b/app/Services/Billing/TaxTreatment.php @@ -42,11 +42,11 @@ final readonly class TaxTreatment // 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 // 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); } - $vatId = strtoupper(preg_replace('/[\s.-]+/', '', (string) $customer->vat_id) ?? ''); + $vatId = $customer->normalisedVatId(); $country = substr($vatId, 0, 2); $seller = strtoupper((string) config('provisioning.tax.seller_country', 'AT')); diff --git a/database/migrations/2026_07_26_010001_bind_vat_verification_to_value.php b/database/migrations/2026_07_26_010001_bind_vat_verification_to_value.php new file mode 100644 index 0000000..6e0e2a4 --- /dev/null +++ b/database/migrations/2026_07_26_010001_bind_vat_verification_to_value.php @@ -0,0 +1,30 @@ +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'); + }); + } +}; diff --git a/tests/Feature/CartTest.php b/tests/Feature/CartTest.php index b7c686c..c0e3ca8 100644 --- a/tests/Feature/CartTest.php +++ b/tests/Feature/CartTest.php @@ -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'); [$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']); // 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); // 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); 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); // 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); // 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); + + // 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); });