From 76510a59a3158d2d9ce2548c97d91d7441a564bc Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 09:30:17 +0200 Subject: [PATCH] fix(billing): VAT follows the customer, not a global setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex was right that this could misstate real charges: an EU business with a VAT ID registered in another country is billed under reverse charge, and we were adding 20 % Austrian VAT to their total anyway. TaxTreatment resolves it from the customer's VAT ID, and the whole page — cart, plan cards, add-on cards — now states one treatment instead of contradicting itself. Explicitly NOT handled: cross-border sales to private individuals, which are taxed at the buyer's national rate under OSS. That needs a maintained rate table and a tax adviser, not a guess, so those fall back to the domestic rate — over-collecting rather than under-collecting. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Billing.php | 3 ++ app/Models/Order.php | 12 +++-- app/Services/Billing/TaxTreatment.php | 58 ++++++++++++++++++++++ config/provisioning.php | 3 ++ lang/de/billing.php | 2 + lang/en/billing.php | 2 + resources/views/livewire/billing.blade.php | 18 +++++-- tests/Feature/CartTest.php | 34 +++++++++++++ 8 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 app/Services/Billing/TaxTreatment.php diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index bc57f3c..2f411ad 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -122,6 +122,9 @@ class Billing extends Component 'upgrades' => $upgrades, 'storage' => (array) config('provisioning.storage_addon'), 'trafficAddon' => (array) config('provisioning.traffic.addon'), + // Resolved once: the cart and the plan cards must not state + // different tax treatments on the same page. + 'tax' => \App\Services\Billing\TaxTreatment::for($customer), 'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null, 'addons' => (array) config('provisioning.addons'), 'pending' => $customer diff --git a/app/Models/Order.php b/app/Models/Order.php index 35fb018..e84e361 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\Concerns\HasUuid; +use App\Services\Billing\TaxTreatment; use App\Provisioning\Contracts\ProvisioningSubject; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -54,15 +55,18 @@ class Order extends Model implements ProvisioningSubject return $this->type !== 'traffic'; } - /** Net is what is stored; gross is what gets charged. */ + /** + * Net is what is stored; gross is what gets charged — and how much VAT that + * is depends on the customer, not on a global setting. + */ public function grossCents(): int { - return (int) round($this->amount_cents * (1 + self::taxRate())); + return $this->taxTreatment()->grossCents($this->amount_cents); } - public static function taxRate(): float + public function taxTreatment(): TaxTreatment { - return (float) config('provisioning.tax.rate_percent', 0) / 100; + return TaxTreatment::for($this->customer); } /** Only a pending order is still the customer's to change. */ diff --git a/app/Services/Billing/TaxTreatment.php b/app/Services/Billing/TaxTreatment.php new file mode 100644 index 0000000..8a81ece --- /dev/null +++ b/app/Services/Billing/TaxTreatment.php @@ -0,0 +1,58 @@ +vat_id) ?? ''); + if ($vatId === '') { + 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); + + return $customerCountry !== '' && $customerCountry !== $sellerCountry + ? new self(0.0, true) + : new self($domestic, false); + } + + public function grossCents(int $netCents): int + { + return (int) round($netCents * (1 + $this->rate)); + } + + public function percentLabel(): string + { + return rtrim(rtrim(number_format($this->rate * 100, 1, ',', '.'), '0'), ','); + } +} diff --git a/config/provisioning.php b/config/provisioning.php index 816fcb6..6a34ad1 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -107,6 +107,9 @@ return [ */ 'tax' => [ 'rate_percent' => (float) env('CLUPILOT_TAX_PERCENT', 20), + // Ours. A customer whose VAT ID belongs to another EU country is billed + // under reverse charge — see App\Services\Billing\TaxTreatment. + 'seller_country' => env('CLUPILOT_TAX_COUNTRY', 'AT'), ], // Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php). diff --git a/lang/de/billing.php b/lang/de/billing.php index 0def38c..6581433 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -1,10 +1,12 @@ 'netto — Reverse Charge', 'net_per_month' => 'netto pro Monat', 'net_once' => 'netto, einmalig', 'net_hint' => 'netto, zzgl. :percent % USt.', 'cart' => [ + 'reverse_charge' => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge)', 'net' => 'netto', 'per_month' => 'pro Monat', 'once' => 'einmalig', diff --git a/lang/en/billing.php b/lang/en/billing.php index f2476a9..b389d24 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -1,10 +1,12 @@ 'net — reverse charge', 'net_per_month' => 'net per month', 'net_once' => 'net, one-off', 'net_hint' => 'net, plus :percent % VAT', 'cart' => [ + 'reverse_charge' => 'Reverse charge — VAT payable by the recipient', 'net' => 'net', 'per_month' => 'per month', 'once' => 'one-off', diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index 0d5e63e..36a0716 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -82,7 +82,11 @@ {{ $eur($net) }}
- {{ __('billing.cart.vat', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }} + + {{ $tax->reverseCharge + ? __('billing.cart.reverse_charge') + : __('billing.cart.vat', ['percent' => $tax->percentLabel()]) }} + {{ $eur($gross - $net) }}
@@ -122,7 +126,11 @@ @endforeach

{{ $eur($p['price_cents']) }} / {{ __('billing.month_short') }}

-

{{ __('billing.net_hint', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }}

+

+ {{ $tax->reverseCharge + ? __('billing.net_reverse_charge') + : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }} +

{{ __('billing.upgrade_cta') }} @@ -178,7 +186,11 @@

{{ __('billing.addon.'.$key.'.desc') }}

{{ $eur($addon['price_cents']) }} / {{ __('billing.month_short') }}

-

{{ __('billing.net_hint', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }}

+

+ {{ $tax->reverseCharge + ? __('billing.net_reverse_charge') + : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }} +

{{ __('billing.addon_cta') }} diff --git a/tests/Feature/CartTest.php b/tests/Feature/CartTest.php index 9274a14..c5da010 100644 --- a/tests/Feature/CartTest.php +++ b/tests/Feature/CartTest.php @@ -149,3 +149,37 @@ it('keeps the recurring figure consistent with the total to the cent', function ->assertSee($expected) // total gross ->assertSee(__('billing.cart.recurring_note', ['amount' => $expected])); // and the note }); + +it('does not charge Austrian VAT to an EU business with a VAT ID', function () { + config()->set('provisioning.tax.rate_percent', 20); + config()->set('provisioning.tax.seller_country', 'AT'); + + [$customer, $user] = cartCustomer(); + $customer->update(['vat_id' => 'DE 811 907 980']); + Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); + + // Reverse charge: we invoice net, the customer accounts for the VAT. + Livewire\Livewire::actingAs($user)->test(Billing::class) + ->assertSee(__('billing.cart.reverse_charge')) + ->assertDontSee(__('billing.cart.vat', ['percent' => '20'])); + + expect(Order::query()->first()->grossCents())->toBe(1000); +}); + +it('charges domestic VAT to a domestic business and to private customers', function () { + config()->set('provisioning.tax.rate_percent', 20); + config()->set('provisioning.tax.seller_country', 'AT'); + + [$customer, $user] = cartCustomer(); + Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); + + // No VAT ID at all → domestic rate. + expect(Order::query()->first()->grossCents())->toBe(1200); + + // An Austrian VAT ID is a domestic business sale, taxed normally. + $customer->update(['vat_id' => 'ATU12345678']); + expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); + + Livewire\Livewire::actingAs($user)->test(Billing::class) + ->assertSee(__('billing.cart.vat', ['percent' => '20'])); +});