fix(billing): normalise both sides of the VAT comparison; cast the timestamp
tests / pest (push) Successful in 6m45s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details

A verifier that returns the number in display form ("DE 811 907 980") would
have failed the comparison against the normalised current value and silently
switched a genuine reverse-charge customer back to domestic VAT. Both sides are
normalised now, and vat_id_verified_at is a real datetime rather than a string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 09:36:07 +02:00
parent 5aef5e693c
commit c36ea17b39
2 changed files with 30 additions and 3 deletions

View File

@ -15,9 +15,14 @@ use RuntimeException;
class Customer extends Model
{
/** Comparable form: spaces and punctuation are cosmetic in a VAT number. */
public static function normaliseVatId(?string $value): string
{
return strtoupper(preg_replace('/[\s.-]+/', '', (string) $value) ?? '');
}
public function normalisedVatId(): string
{
return strtoupper(preg_replace('/[\s.-]+/', '', (string) $this->vat_id) ?? '');
return self::normaliseVatId($this->vat_id);
}
/**
@ -27,9 +32,11 @@ class Customer extends Model
*/
public function hasVerifiedVatId(): bool
{
// Both sides normalised: a verifier that stores the number in display
// form ("DE 811 907 980") must not invalidate a genuine confirmation.
return $this->vat_id_verified_at !== null
&& $this->normalisedVatId() !== ''
&& $this->normalisedVatId() === strtoupper((string) $this->vat_id_verified_value);
&& $this->normalisedVatId() === self::normaliseVatId($this->vat_id_verified_value);
}
/** @use HasFactory<\Database\Factories\CustomerFactory> */
@ -43,7 +50,7 @@ class Customer extends Model
protected function casts(): array
{
return ['closed_at' => 'datetime'];
return ['closed_at' => 'datetime', 'vat_id_verified_at' => 'datetime'];
}
public function seats(): HasMany

View File

@ -184,6 +184,26 @@ it('charges domestic VAT to a domestic business and to private customers', funct
->assertSee(__('billing.cart.vat', ['percent' => '20']));
});
it('accepts a verified value stored in display form', function () {
config()->set('provisioning.tax.rate_percent', 20);
config()->set('provisioning.tax.seller_country', 'AT');
[$customer, ] = cartCustomer();
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
// A verifier may hand back the number the way it prints it; spaces are
// cosmetic and must not invalidate a genuine confirmation.
$customer->update([
'vat_id' => 'DE811907980',
'vat_id_verified_at' => now(),
'vat_id_verified_value' => 'DE 811 907 980',
]);
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue()
->and($customer->fresh()->vat_id_verified_at)->toBeInstanceOf(Illuminate\Support\Carbon::class)
->and(Order::query()->first()->fresh()->grossCents())->toBe(1000);
});
it('will not let a typed-in VAT ID zero the tax', function () {
config()->set('provisioning.tax.rate_percent', 20);
config()->set('provisioning.tax.seller_country', 'AT');