diff --git a/app/Models/Customer.php b/app/Models/Customer.php index dd5232c..6016084 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -18,7 +18,7 @@ class Customer extends Model use HasFactory, HasUuid; protected $fillable = [ - 'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'billing_address', + 'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'vat_id_verified_at', '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 8a81ece..aa52e3f 100644 --- a/app/Services/Billing/TaxTreatment.php +++ b/app/Services/Billing/TaxTreatment.php @@ -9,9 +9,10 @@ use App\Models\Customer; * * Only the two clear-cut cases are decided here: * - * - a customer with a VAT ID registered in another EU country pays no VAT to - * us; the liability shifts to them (reverse charge), and the invoice has to - * say so; + * - a customer whose VERIFIED VAT ID belongs to another EU member state pays + * no VAT to us; the liability shifts to them (reverse charge), and the + * invoice has to say so. Unverified means domestic rate: a self-declared + * string must never be able to zero the tax; * - everyone else is charged the seller's domestic rate. * * Deliberately NOT handled: cross-border sales to private individuals, which @@ -22,6 +23,13 @@ use App\Models\Customer; */ final readonly class TaxTreatment { + /** Reverse charge applies inside the EU only. */ + private const EU_MEMBER_STATES = [ + 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', + 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', + 'SE', 'SI', 'SK', + ]; + private function __construct( public float $rate, public bool $reverseCharge, @@ -31,19 +39,24 @@ final readonly class TaxTreatment { $domestic = (float) config('provisioning.tax.rate_percent', 0) / 100; - $vatId = strtoupper(preg_replace('/\s+/', '', (string) $customer?->vat_id) ?? ''); - if ($vatId === '') { + // 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) { return new self($domestic, false); } - // A VAT ID starts with its country's code. Same country as ours means a - // domestic business sale, which is taxed normally. - $sellerCountry = strtoupper((string) config('provisioning.tax.seller_country', 'AT')); - $customerCountry = substr($vatId, 0, 2); + $vatId = strtoupper(preg_replace('/[\s.-]+/', '', (string) $customer->vat_id) ?? ''); + $country = substr($vatId, 0, 2); + $seller = strtoupper((string) config('provisioning.tax.seller_country', 'AT')); - return $customerCountry !== '' && $customerCountry !== $sellerCountry - ? new self(0.0, true) - : new self($domestic, false); + // Reverse charge is an intra-EU business rule: it needs a member state + // that is not ours, and a number that at least looks like one. + $eligible = in_array($country, self::EU_MEMBER_STATES, true) + && $country !== $seller + && preg_match('/^[A-Z]{2}[0-9A-Z]{8,12}$/', $vatId) === 1; + + return $eligible ? new self(0.0, true) : new self($domestic, false); } public function grossCents(int $netCents): int diff --git a/database/migrations/2026_07_26_010000_add_vat_verification_to_customers.php b/database/migrations/2026_07_26_010000_add_vat_verification_to_customers.php new file mode 100644 index 0000000..c21a164 --- /dev/null +++ b/database/migrations/2026_07_26_010000_add_vat_verification_to_customers.php @@ -0,0 +1,30 @@ +timestamp('vat_id_verified_at')->nullable()->after('vat_id'); + }); + } + + public function down(): void + { + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn('vat_id_verified_at'); + }); + } +}; diff --git a/tests/Feature/CartTest.php b/tests/Feature/CartTest.php index c5da010..b7c686c 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']); + $customer->update(['vat_id' => 'DE 811 907 980', 'vat_id_verified_at' => now()]); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); // Reverse charge: we invoice net, the customer accounts for the VAT. @@ -177,9 +177,29 @@ 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']); + $customer->update(['vat_id' => 'ATU12345678', 'vat_id_verified_at' => now()]); expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); Livewire\Livewire::actingAs($user)->test(Billing::class) ->assertSee(__('billing.cart.vat', ['percent' => '20'])); }); + +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'); + + [$customer, ] = cartCustomer(); + Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); + + // Plausible-looking but unverified: still domestic VAT. + $customer->update(['vat_id' => 'DE811907980']); + 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()]); + 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()]); + expect(Order::query()->first()->fresh()->grossCents())->toBe(1000); +});