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 @@

{{ $eur($current['price_cents'] ?? 0) }}

-

{{ __('billing.per_month') }}

+

{{ __('billing.net_per_month') }}

@@ -53,7 +53,13 @@ {{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }}

-

{{ $eur($order->amount_cents) }}

+
+

{{ $eur($order->amount_cents) }}

+

+ {{ __('billing.cart.net') }} · + {{ $order->isRecurring() ? __('billing.cart.per_month') : __('billing.cart.once') }} +

+