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) }}
{{ $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.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()]) }} +