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;
|
use HasFactory, HasUuid;
|
||||||
|
|
||||||
protected $fillable = [
|
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',
|
'locale', 'stripe_customer_id', 'status', 'closed_at',
|
||||||
'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color',
|
'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:
|
* Only the two clear-cut cases are decided here:
|
||||||
*
|
*
|
||||||
* - a customer with a VAT ID registered in another EU country pays no VAT to
|
* - a customer whose VERIFIED VAT ID belongs to another EU member state pays
|
||||||
* us; the liability shifts to them (reverse charge), and the invoice has to
|
* no VAT to us; the liability shifts to them (reverse charge), and the
|
||||||
* say so;
|
* 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.
|
* - everyone else is charged the seller's domestic rate.
|
||||||
*
|
*
|
||||||
* Deliberately NOT handled: cross-border sales to private individuals, which
|
* Deliberately NOT handled: cross-border sales to private individuals, which
|
||||||
|
|
@ -22,6 +23,13 @@ use App\Models\Customer;
|
||||||
*/
|
*/
|
||||||
final readonly class TaxTreatment
|
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(
|
private function __construct(
|
||||||
public float $rate,
|
public float $rate,
|
||||||
public bool $reverseCharge,
|
public bool $reverseCharge,
|
||||||
|
|
@ -31,19 +39,24 @@ final readonly class TaxTreatment
|
||||||
{
|
{
|
||||||
$domestic = (float) config('provisioning.tax.rate_percent', 0) / 100;
|
$domestic = (float) config('provisioning.tax.rate_percent', 0) / 100;
|
||||||
|
|
||||||
$vatId = strtoupper(preg_replace('/\s+/', '', (string) $customer?->vat_id) ?? '');
|
// Unverified is the normal state, and it must cost the customer nothing
|
||||||
if ($vatId === '') {
|
// 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);
|
return new self($domestic, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A VAT ID starts with its country's code. Same country as ours means a
|
$vatId = strtoupper(preg_replace('/[\s.-]+/', '', (string) $customer->vat_id) ?? '');
|
||||||
// domestic business sale, which is taxed normally.
|
$country = substr($vatId, 0, 2);
|
||||||
$sellerCountry = strtoupper((string) config('provisioning.tax.seller_country', 'AT'));
|
$seller = strtoupper((string) config('provisioning.tax.seller_country', 'AT'));
|
||||||
$customerCountry = substr($vatId, 0, 2);
|
|
||||||
|
|
||||||
return $customerCountry !== '' && $customerCountry !== $sellerCountry
|
// Reverse charge is an intra-EU business rule: it needs a member state
|
||||||
? new self(0.0, true)
|
// that is not ours, and a number that at least looks like one.
|
||||||
: new self($domestic, false);
|
$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
|
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');
|
config()->set('provisioning.tax.seller_country', 'AT');
|
||||||
|
|
||||||
[$customer, $user] = cartCustomer();
|
[$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']);
|
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
|
||||||
|
|
||||||
// Reverse charge: we invoice net, the customer accounts for the VAT.
|
// 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);
|
expect(Order::query()->first()->grossCents())->toBe(1200);
|
||||||
|
|
||||||
// An Austrian VAT ID is a domestic business sale, taxed normally.
|
// 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);
|
expect(Order::query()->first()->fresh()->grossCents())->toBe(1200);
|
||||||
|
|
||||||
Livewire\Livewire::actingAs($user)->test(Billing::class)
|
Livewire\Livewire::actingAs($user)->test(Billing::class)
|
||||||
->assertSee(__('billing.cart.vat', ['percent' => '20']));
|
->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