fix(billing): a self-declared VAT ID must not zero the tax
Any non-empty string starting with two characters other than AT switched the customer to reverse charge — typing "XX123" was a 20 % discount. Reverse charge now requires a VAT ID that is verified, belongs to an EU member state other than ours, and looks like a VAT number at all. Unverified is the normal state and means the domestic rate: over-collecting is correctable, under-collecting is a tax liability. Changing the number clears its verification. Verification itself (VIES) is not built yet, so reverse charge stays off until someone confirms a number — which is the safe direction to be wrong in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
76510a59a3
commit
42ceafb57f
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* When a customer's VAT ID was confirmed as valid.
|
||||
*
|
||||
* Reverse charge zeroes the VAT we collect, so it may not hinge on a string the
|
||||
* customer typed. Until a VAT ID is verified this stays null and the domestic
|
||||
* rate applies — over-collecting, which is correctable, rather than
|
||||
* under-collecting, which is a tax liability.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue