fix(billing): VAT follows the customer, not a global setting
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
parent
ac3f429958
commit
76510a59a3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'), ',');
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'net_reverse_charge' => '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',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'net_reverse_charge' => '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',
|
||||
|
|
|
|||
|
|
@ -82,7 +82,11 @@
|
|||
<span class="font-mono">{{ $eur($net) }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-line pt-1.5 font-semibold text-ink">
|
||||
|
|
@ -122,7 +126,11 @@
|
|||
@endforeach
|
||||
</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="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">
|
||||
{{ __('billing.upgrade_cta') }}
|
||||
</x-ui.button>
|
||||
|
|
@ -178,7 +186,11 @@
|
|||
</div>
|
||||
<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="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">
|
||||
{{ __('billing.addon_cta') }}
|
||||
</x-ui.button>
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue