fix(billing): verification vouches for a value, not for a row
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
parent
42ceafb57f
commit
5aef5e693c
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue