diff --git a/.env.example b/.env.example
index 64ab490..d8921f0 100644
--- a/.env.example
+++ b/.env.example
@@ -172,6 +172,10 @@ VPN_CONFIG_KEY=
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
+# Umsatzsteuersatz des Verkäufers in Prozent. Preise in der Konfiguration
+# sind NETTO; die Oberfläche weist beides aus.
+CLUPILOT_TAX_PERCENT=20
+
ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1
# ── Nextcloud blueprint template ─────────────────────────────────────────
diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php
index 98caa46..f9fc2a5 100644
--- a/app/Livewire/Billing.php
+++ b/app/Livewire/Billing.php
@@ -54,6 +54,21 @@ class Billing extends Component
?? $customer->orders()->latest('id')->value('datacenter')
?? 'fsn';
+ // A cart cannot hold two plan changes: they contradict each other and
+ // no checkout could resolve which one the customer meant. Choosing
+ // another simply replaces the pending one — add-ons still stack.
+ if ($type === 'upgrade') {
+ $replaced = Order::query()
+ ->where('customer_id', $customer->id)
+ ->where('status', 'pending')
+ ->where('type', 'upgrade')
+ ->delete();
+
+ if ($replaced > 0) {
+ $this->dispatch('notify', message: __('billing.cart.plan_replaced'));
+ }
+ }
+
Order::create([
'customer_id' => $customer->id,
'plan' => $plan,
diff --git a/app/Models/Order.php b/app/Models/Order.php
index 5cb03a8..35fb018 100644
--- a/app/Models/Order.php
+++ b/app/Models/Order.php
@@ -43,6 +43,28 @@ class Order extends Model implements ProvisioningSubject
};
}
+ /**
+ * Monthly or one-off.
+ *
+ * Traffic is bought for the month that is running out and does not renew;
+ * everything else changes the recurring bill.
+ */
+ public function isRecurring(): bool
+ {
+ return $this->type !== 'traffic';
+ }
+
+ /** Net is what is stored; gross is what gets charged. */
+ public function grossCents(): int
+ {
+ return (int) round($this->amount_cents * (1 + self::taxRate()));
+ }
+
+ public static function taxRate(): float
+ {
+ return (float) config('provisioning.tax.rate_percent', 0) / 100;
+ }
+
/** Only a pending order is still the customer's to change. */
public function isRemovable(): bool
{
diff --git a/config/provisioning.php b/config/provisioning.php
index 220c381..816fcb6 100644
--- a/config/provisioning.php
+++ b/config/provisioning.php
@@ -99,6 +99,16 @@ return [
'sample_minutes' => 15,
],
+ /*
+ | Prices in this config are NET. The rate is configurable because it follows
+ | the seller's country (20 % in Austria), and every price shown to a
+ | customer has to say which it is — a figure without "netto" or "brutto" is
+ | the single most common billing complaint there is.
+ */
+ 'tax' => [
+ 'rate_percent' => (float) env('CLUPILOT_TAX_PERCENT', 20),
+ ],
+
// Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php).
'storage_addon' => ['gb' => 100, 'price_cents' => 1000],
'addons' => [
diff --git a/lang/de/billing.php b/lang/de/billing.php
index 8ec321f..0def38c 100644
--- a/lang/de/billing.php
+++ b/lang/de/billing.php
@@ -1,7 +1,18 @@
'netto pro Monat',
+ 'net_once' => 'netto, einmalig',
+ 'net_hint' => 'netto, zzgl. :percent % USt.',
'cart' => [
+ 'net' => 'netto',
+ 'per_month' => 'pro Monat',
+ 'once' => 'einmalig',
+ 'subtotal_net' => 'Zwischensumme netto',
+ 'vat' => 'zzgl. :percent % USt.',
+ 'total_gross' => 'Gesamt brutto',
+ 'recurring_note' => 'Davon :amount brutto monatlich wiederkehrend.',
+ 'plan_replaced' => 'Der bisher vorgemerkte Paketwechsel wurde ersetzt — es kann nur einer offen sein.',
'title' => 'Vorgemerkte Käufe',
'subtitle' => 'Wird nach Zahlung aktiviert',
'added' => 'vorgemerkt am :when',
diff --git a/lang/en/billing.php b/lang/en/billing.php
index a4a1788..f2476a9 100644
--- a/lang/en/billing.php
+++ b/lang/en/billing.php
@@ -1,7 +1,18 @@
'net per month',
+ 'net_once' => 'net, one-off',
+ 'net_hint' => 'net, plus :percent % VAT',
'cart' => [
+ 'net' => 'net',
+ 'per_month' => 'per month',
+ 'once' => 'one-off',
+ 'subtotal_net' => 'Subtotal net',
+ 'vat' => 'plus :percent % VAT',
+ 'total_gross' => 'Total gross',
+ 'recurring_note' => 'Of which :amount gross recurs monthly.',
+ 'plan_replaced' => 'The plan change you had pending was replaced — only one can be open at a time.',
'title' => 'Pending purchases',
'subtitle' => 'Activated once paid',
'added' => 'added :when',
diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php
index cb00d7a..129bc45 100644
--- a/resources/views/livewire/billing.blade.php
+++ b/resources/views/livewire/billing.blade.php
@@ -16,7 +16,7 @@