fix(billing): VAT follows the customer, not a global setting
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 09:30:17 +02:00
parent ac3f429958
commit 76510a59a3
8 changed files with 125 additions and 7 deletions

View File

@ -122,6 +122,9 @@ class Billing extends Component
'upgrades' => $upgrades, 'upgrades' => $upgrades,
'storage' => (array) config('provisioning.storage_addon'), 'storage' => (array) config('provisioning.storage_addon'),
'trafficAddon' => (array) config('provisioning.traffic.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, 'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null,
'addons' => (array) config('provisioning.addons'), 'addons' => (array) config('provisioning.addons'),
'pending' => $customer 'pending' => $customer

View File

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\HasUuid; use App\Models\Concerns\HasUuid;
use App\Services\Billing\TaxTreatment;
use App\Provisioning\Contracts\ProvisioningSubject; use App\Provisioning\Contracts\ProvisioningSubject;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -54,15 +55,18 @@ class Order extends Model implements ProvisioningSubject
return $this->type !== 'traffic'; 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 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. */ /** Only a pending order is still the customer's to change. */

View File

@ -0,0 +1,58 @@
<?php
namespace App\Services\Billing;
use App\Models\Customer;
/**
* Which VAT applies to a customer, and why.
*
* 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;
* - everyone else is charged the seller's domestic rate.
*
* Deliberately NOT handled: cross-border sales to private individuals, which
* are taxed at the customer's own country's rate under the OSS scheme. Doing
* that needs a maintained rate table per member state and a tax adviser's sign
* off, not a guess in a config file so those customers fall back to the
* domestic rate, which over-collects rather than under-collects.
*/
final readonly class TaxTreatment
{
private function __construct(
public float $rate,
public bool $reverseCharge,
) {}
public static function for(?Customer $customer): self
{
$domestic = (float) config('provisioning.tax.rate_percent', 0) / 100;
$vatId = strtoupper(preg_replace('/\s+/', '', (string) $customer?->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'), ',');
}
}

View File

@ -107,6 +107,9 @@ return [
*/ */
'tax' => [ 'tax' => [
'rate_percent' => (float) env('CLUPILOT_TAX_PERCENT', 20), '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). // Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php).

View File

@ -1,10 +1,12 @@
<?php <?php
return [ return [
'net_reverse_charge' => 'netto — Reverse Charge',
'net_per_month' => 'netto pro Monat', 'net_per_month' => 'netto pro Monat',
'net_once' => 'netto, einmalig', 'net_once' => 'netto, einmalig',
'net_hint' => 'netto, zzgl. :percent % USt.', 'net_hint' => 'netto, zzgl. :percent % USt.',
'cart' => [ 'cart' => [
'reverse_charge' => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge)',
'net' => 'netto', 'net' => 'netto',
'per_month' => 'pro Monat', 'per_month' => 'pro Monat',
'once' => 'einmalig', 'once' => 'einmalig',

View File

@ -1,10 +1,12 @@
<?php <?php
return [ return [
'net_reverse_charge' => 'net — reverse charge',
'net_per_month' => 'net per month', 'net_per_month' => 'net per month',
'net_once' => 'net, one-off', 'net_once' => 'net, one-off',
'net_hint' => 'net, plus :percent % VAT', 'net_hint' => 'net, plus :percent % VAT',
'cart' => [ 'cart' => [
'reverse_charge' => 'Reverse charge — VAT payable by the recipient',
'net' => 'net', 'net' => 'net',
'per_month' => 'per month', 'per_month' => 'per month',
'once' => 'one-off', 'once' => 'one-off',

View File

@ -82,7 +82,11 @@
<span class="font-mono">{{ $eur($net) }}</span> <span class="font-mono">{{ $eur($net) }}</span>
</div> </div>
<div class="flex items-center justify-between text-muted"> <div class="flex items-center justify-between text-muted">
<span>{{ __('billing.cart.vat', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }}</span> <span>
{{ $tax->reverseCharge
? __('billing.cart.reverse_charge')
: __('billing.cart.vat', ['percent' => $tax->percentLabel()]) }}
</span>
<span class="font-mono">{{ $eur($gross - $net) }}</span> <span class="font-mono">{{ $eur($gross - $net) }}</span>
</div> </div>
<div class="flex items-center justify-between border-t border-line pt-1.5 font-semibold text-ink"> <div class="flex items-center justify-between border-t border-line pt-1.5 font-semibold text-ink">
@ -122,7 +126,11 @@
@endforeach @endforeach
</ul> </ul>
<p class="mt-4 text-xl font-semibold text-ink">{{ $eur($p['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p> <p class="mt-4 text-xl font-semibold text-ink">{{ $eur($p['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
<p class="text-xs text-faint">{{ __('billing.net_hint', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }}</p> <p class="text-xs text-faint">
{{ $tax->reverseCharge
? __('billing.net_reverse_charge')
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
</p>
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('upgrade', '{{ $key }}')" wire:target="purchase('upgrade', '{{ $key }}')" wire:loading.attr="disabled"> <x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('upgrade', '{{ $key }}')" wire:target="purchase('upgrade', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.upgrade_cta') }} {{ __('billing.upgrade_cta') }}
</x-ui.button> </x-ui.button>
@ -178,7 +186,11 @@
</div> </div>
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p> <p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
<p class="mt-3 text-lg font-semibold text-ink">{{ $eur($addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p> <p class="mt-3 text-lg font-semibold text-ink">{{ $eur($addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
<p class="text-xs text-faint">{{ __('billing.net_hint', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }}</p> <p class="text-xs text-faint">
{{ $tax->reverseCharge
? __('billing.net_reverse_charge')
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
</p>
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:target="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled"> <x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:target="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.addon_cta') }} {{ __('billing.addon_cta') }}
</x-ui.button> </x-ui.button>

View File

@ -149,3 +149,37 @@ it('keeps the recurring figure consistent with the total to the cent', function
->assertSee($expected) // total gross ->assertSee($expected) // total gross
->assertSee(__('billing.cart.recurring_note', ['amount' => $expected])); // and the note ->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']));
});