From 515f26234e779c633a529e87f8f72154c916c1f3 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 13:36:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal):=20billing=20page=20=E2=80=94=20cu?= =?UTF-8?q?rrent=20plan,=20upgrades,=20extra=20storage,=20add-ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /billing page + nav; plans gained price_cents, storage_addon + addons catalogue in config. Purchases create a pending Order intent (fulfillment mocked). Dashboard storage upsell links here. DE+EN. 6 tests. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Billing.php | 97 +++++++++++++++++++ app/Models/Order.php | 2 +- config/provisioning.php | 18 +++- ..._07_25_090002_add_type_to_orders_table.php | 23 +++++ lang/de/billing.php | 36 +++++++ lang/de/dashboard.php | 1 + lang/en/billing.php | 36 +++++++ lang/en/dashboard.php | 1 + resources/views/layouts/portal-app.blade.php | 1 + resources/views/livewire/billing.blade.php | 95 ++++++++++++++++++ resources/views/livewire/dashboard.blade.php | 6 +- routes/web.php | 2 + tests/Feature/BillingTest.php | 70 +++++++++++++ 13 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 app/Livewire/Billing.php create mode 100644 database/migrations/2026_07_25_090002_add_type_to_orders_table.php create mode 100644 lang/de/billing.php create mode 100644 lang/en/billing.php create mode 100644 resources/views/livewire/billing.blade.php create mode 100644 tests/Feature/BillingTest.php diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php new file mode 100644 index 0000000..416bb12 --- /dev/null +++ b/app/Livewire/Billing.php @@ -0,0 +1,97 @@ +customer(); + if ($customer === null) { + return; + } + + $instance = $customer->instances()->latest('id')->first(); + $currentPlan = $instance?->plan ?? 'start'; + $plans = (array) config('provisioning.plans'); + $addons = (array) config('provisioning.addons'); + + [$plan, $amount, $addonKey] = match ($type) { + 'upgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null], + 'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null], + 'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key], + default => [null, 0, null], + }; + + // Guard against invalid keys / non-upgrades. + $valid = match ($type) { + 'upgrade' => isset($plans[$key]) && ($plans[$key]['price_cents'] ?? 0) > ($plans[$currentPlan]['price_cents'] ?? 0), + 'storage' => true, + 'addon' => isset($addons[$key]), + default => false, + }; + if (! $valid || $plan === null) { + return; + } + + $datacenter = $instance?->host?->datacenter + ?? $customer->orders()->latest('id')->value('datacenter') + ?? 'fsn'; + + Order::create([ + 'customer_id' => $customer->id, + 'plan' => $plan, + 'type' => $type, + 'addon_key' => $addonKey, + 'amount_cents' => $amount, + 'currency' => 'EUR', + 'datacenter' => $datacenter, + 'status' => 'pending', + ]); + + $this->dispatch('notify', message: __('billing.purchased')); + } + + private function customer(): ?Customer + { + $user = auth()->user(); + + return $user ? Customer::query()->where('email', $user->email)->first() : null; + } + + public function render() + { + $customer = $this->customer(); + $instance = $customer?->instances()->latest('id')->first(); + $plans = (array) config('provisioning.plans'); + $currentKey = $instance?->plan ?? 'start'; + $currentPrice = (int) ($plans[$currentKey]['price_cents'] ?? 0); + + $upgrades = collect($plans) + ->filter(fn ($p, $k) => (int) ($p['price_cents'] ?? 0) > $currentPrice) + ->keys()->all(); + + return view('livewire.billing', [ + 'currentKey' => $currentKey, + 'current' => $plans[$currentKey] ?? [], + 'instance' => $instance, + 'plans' => $plans, + 'upgrades' => $upgrades, + 'storage' => (array) config('provisioning.storage_addon'), + 'addons' => (array) config('provisioning.addons'), + 'pending' => $customer + ? $customer->orders()->where('status', 'pending')->latest('id')->get() + : collect(), + ]); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index 73485e4..d972a9c 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -16,7 +16,7 @@ class Order extends Model implements ProvisioningSubject use HasFactory, HasUuid; protected $fillable = [ - 'customer_id', 'plan', 'amount_cents', 'currency', 'datacenter', + 'customer_id', 'plan', 'type', 'addon_key', 'amount_cents', 'currency', 'datacenter', 'stripe_event_id', 'status', ]; diff --git a/config/provisioning.php b/config/provisioning.php index 9ea1636..3bdb062 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -46,12 +46,20 @@ return [ ], ], - // Plan → resource sizing + Nextcloud blueprint template VMID (per DC/version). + // Plan → resource sizing + monthly price + Nextcloud blueprint template VMID. 'plans' => [ - 'start' => ['quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'template_vmid' => 9000], - 'team' => ['quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'template_vmid' => 9000], - 'business' => ['quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'template_vmid' => 9000], - 'enterprise' => ['quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'template_vmid' => 9000], + 'start' => ['quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'price_cents' => 4900, 'template_vmid' => 9000], + 'team' => ['quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'price_cents' => 17900, 'template_vmid' => 9000], + 'business' => ['quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'price_cents' => 39900, 'template_vmid' => 9000], + 'enterprise' => ['quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'price_cents' => 79900, 'template_vmid' => 9000], + ], + + // Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php). + 'storage_addon' => ['gb' => 100, 'price_cents' => 1000], + 'addons' => [ + 'extra_backups' => ['price_cents' => 500], + 'priority_support' => ['price_cents' => 2900], + 'collabora_pro' => ['price_cents' => 1900], ], 'dns' => [ diff --git a/database/migrations/2026_07_25_090002_add_type_to_orders_table.php b/database/migrations/2026_07_25_090002_add_type_to_orders_table.php new file mode 100644 index 0000000..8ecb125 --- /dev/null +++ b/database/migrations/2026_07_25_090002_add_type_to_orders_table.php @@ -0,0 +1,23 @@ +string('type')->default('new')->after('plan'); // new|upgrade|storage|addon + $table->string('addon_key')->nullable()->after('type'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn(['type', 'addon_key']); + }); + } +}; diff --git a/lang/de/billing.php b/lang/de/billing.php new file mode 100644 index 0000000..9a385de --- /dev/null +++ b/lang/de/billing.php @@ -0,0 +1,36 @@ + 'Paket & Addons', + 'subtitle' => 'Ihr aktuelles Paket, Upgrades, Zusatzspeicher und Erweiterungen.', + 'current_plan' => 'Aktuelles Paket', + 'per_month' => 'pro Monat', + 'month_short' => 'Mon.', + 'storage' => 'Speicher', + 'cores' => 'vCPU', + 'ram' => 'RAM', + 'status' => 'Status', + 'status_active' => 'Aktiv', + 'pending_note' => ':count Kauf vorgemerkt — wird nach Zahlung aktiviert.', + 'upgrade_title' => 'Upgrade', + 'upgrade_cta' => 'Upgraden', + 'storage_title' => 'Zusatzspeicher', + 'storage_body' => 'Jederzeit erweiterbar — +:gb GB für :price pro Monat, monatlich kündbar.', + 'storage_cta' => '+:gb GB buchen', + 'addon_cta' => 'Hinzufügen', + 'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.', + 'mock_note' => 'Zahlung & Bereitstellung folgen nach Anbindung des Zahlungsanbieters.', + + 'plan' => [ + 'start' => 'Start', + 'team' => 'Team', + 'business' => 'Business', + 'enterprise' => 'Enterprise', + ], + + 'addon' => [ + 'extra_backups' => ['name' => 'Tägliche Off-Site-Backups', 'desc' => 'Zusätzliche verschlüsselte Backups an einem zweiten Standort.'], + 'priority_support' => ['name' => 'Priority-Support', 'desc' => 'Bevorzugte Bearbeitung, Reaktionszeit unter 1 Stunde.'], + 'collabora_pro' => ['name' => 'Collabora Online Pro', 'desc' => 'Erweiterte Office-Funktionen und mehr gleichzeitige Bearbeiter.'], + ], +]; diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 7cef6da..07525c3 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -13,6 +13,7 @@ return [ 'users' => 'Benutzer', 'backups' => 'Backups', 'invoices' => 'Rechnungen', + 'billing' => 'Paket & Addons', 'support' => 'Support', ], diff --git a/lang/en/billing.php b/lang/en/billing.php new file mode 100644 index 0000000..b18fd32 --- /dev/null +++ b/lang/en/billing.php @@ -0,0 +1,36 @@ + 'Plan & add-ons', + 'subtitle' => 'Your current plan, upgrades, extra storage and add-ons.', + 'current_plan' => 'Current plan', + 'per_month' => 'per month', + 'month_short' => 'mo', + 'storage' => 'Storage', + 'cores' => 'vCPU', + 'ram' => 'RAM', + 'status' => 'Status', + 'status_active' => 'Active', + 'pending_note' => ':count purchase pending — activated after payment.', + 'upgrade_title' => 'Upgrade', + 'upgrade_cta' => 'Upgrade', + 'storage_title' => 'Extra storage', + 'storage_body' => 'Expandable anytime — +:gb GB for :price per month, cancel monthly.', + 'storage_cta' => 'Add :gb GB', + 'addon_cta' => 'Add', + 'purchased' => 'Purchase noted — we activate it after payment.', + 'mock_note' => 'Payment & fulfillment follow once the payment provider is connected.', + + 'plan' => [ + 'start' => 'Start', + 'team' => 'Team', + 'business' => 'Business', + 'enterprise' => 'Enterprise', + ], + + 'addon' => [ + 'extra_backups' => ['name' => 'Daily off-site backups', 'desc' => 'Additional encrypted backups at a second location.'], + 'priority_support' => ['name' => 'Priority support', 'desc' => 'Priority handling, response time under 1 hour.'], + 'collabora_pro' => ['name' => 'Collabora Online Pro', 'desc' => 'Advanced office features and more concurrent editors.'], + ], +]; diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 8ca1ec3..242a24c 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -13,6 +13,7 @@ return [ 'users' => 'Users', 'backups' => 'Backups', 'invoices' => 'Invoices', + 'billing' => 'Plan & add-ons', 'support' => 'Support', ], diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 22fdda8..1ab36a6 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -28,6 +28,7 @@ ['users', 'users', 'users'], ['backups', 'database', 'backups'], ['invoices', 'receipt', 'invoices'], + ['billing', 'box', 'billing'], ['support', 'life-buoy', 'support'], ] as [$route, $icon, $key]) diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php new file mode 100644 index 0000000..0407cfa --- /dev/null +++ b/resources/views/livewire/billing.blade.php @@ -0,0 +1,95 @@ + +
+ @php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp + +
+

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

+

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

+
+ + {{-- Current plan --}} +
+
+
+

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

+

{{ __('billing.plan.'.$currentKey) }}

+
+
+

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

+

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

+
+
+
+ @foreach ([ + ['billing.storage', ($current['quota_gb'] ?? 0).' GB'], + ['billing.cores', $current['cores'] ?? '—'], + ['billing.ram', isset($current['ram_mb']) ? round($current['ram_mb'] / 1024).' GB' : '—'], + ['billing.status', __('billing.status_active')], + ] as [$label, $value]) +
+

{{ __($label) }}

+

{{ $value }}

+
+ @endforeach +
+
+ + @if ($pending->isNotEmpty()) +
+ +

{{ __('billing.pending_note', ['count' => $pending->count()]) }}

+
+ @endif + + {{-- Upgrades --}} + @if (count($upgrades) > 0) +
+

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

+
+ @foreach ($upgrades as $key) + @php $p = $plans[$key]; @endphp +
+

{{ __('billing.plan.'.$key) }}

+

{{ $p['quota_gb'] }} GB · {{ $p['cores'] }} vCPU · {{ round($p['ram_mb'] / 1024) }} GB RAM

+

{{ $eur($p['price_cents']) }} / {{ __('billing.month_short') }}

+ + {{ __('billing.upgrade_cta') }} + +
+ @endforeach +
+
+ @endif + + {{-- Extra storage + Add-ons --}} +
+ {{-- Storage --}} +
+
+ +

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

+
+

{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}

+ + {{ __('billing.storage_cta', ['gb' => $storage['gb']]) }} + +
+ + {{-- Add-ons --}} + @foreach ($addons as $key => $addon) +
+
+ +

{{ __('billing.addon.'.$key.'.name') }}

+
+

{{ __('billing.addon.'.$key.'.desc') }}

+

{{ $eur($addon['price_cents']) }} / {{ __('billing.month_short') }}

+ + {{ __('billing.addon_cta') }} + +
+ @endforeach +
+ +

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

+
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 90c4c78..7401d28 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -68,9 +68,9 @@

{{ __('dashboard.upsell.title', ['percent' => $storagePercent]) }}

{{ __('dashboard.upsell.body') }}

- - {{ __('dashboard.upsell.cta') }} - + + {{ __('dashboard.upsell.cta') }} +
diff --git a/routes/web.php b/routes/web.php index 22cdbcd..9343d7c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\StripeWebhookController; use App\Livewire\Admin; +use App\Livewire\Billing; use App\Livewire\Auth\Login; use App\Livewire\Auth\TwoFactorChallenge; use App\Livewire\Backups; @@ -40,6 +41,7 @@ Route::middleware('auth')->group(function () { Route::get('/users', Users::class)->name('users'); Route::get('/backups', Backups::class)->name('backups'); Route::get('/invoices', Invoices::class)->name('invoices'); + Route::get('/billing', Billing::class)->name('billing'); Route::get('/support', Support::class)->name('support'); }); diff --git a/tests/Feature/BillingTest.php b/tests/Feature/BillingTest.php new file mode 100644 index 0000000..c1eb0d0 --- /dev/null +++ b/tests/Feature/BillingTest.php @@ -0,0 +1,70 @@ +create(['email' => 'k@billing.test', 'is_admin' => false]); + $customer = Customer::factory()->create(['email' => 'k@billing.test']); + $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => $plan]); + Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => $plan, 'status' => 'active']); + + return compact('user', 'customer'); +} + +it('gates billing to authenticated users', function () { + $this->get(route('billing'))->assertRedirect('/login'); +}); + +it('shows the current plan and offers upgrades', function () { + ['user' => $user] = billingSetup('start'); + + Livewire::actingAs($user)->test(Billing::class) + ->assertSee(__('billing.plan.start')) + ->assertSee(__('billing.plan.team')) // upgrade card + ->assertSee(__('billing.storage_title')); +}); + +it('records an upgrade purchase intent', function () { + ['user' => $user, 'customer' => $customer] = billingSetup('start'); + + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'upgrade', 'team'); + + $order = Order::query()->where('customer_id', $customer->id)->where('type', 'upgrade')->first(); + expect($order)->not->toBeNull() + ->and($order->plan)->toBe('team') + ->and($order->status)->toBe('pending') + ->and($order->amount_cents)->toBe(17900); +}); + +it('records a storage and an addon purchase intent', function () { + ['user' => $user, 'customer' => $customer] = billingSetup('start'); + + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'storage'); + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'priority_support'); + + expect(Order::query()->where('customer_id', $customer->id)->where('type', 'storage')->exists())->toBeTrue(); + $addon = Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->first(); + expect($addon?->addon_key)->toBe('priority_support'); +}); + +it('rejects a downgrade disguised as an upgrade', function () { + ['user' => $user, 'customer' => $customer] = billingSetup('business'); + + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'upgrade', 'start'); + + expect(Order::query()->where('customer_id', $customer->id)->where('type', 'upgrade')->exists())->toBeFalse(); +}); + +it('rejects an unknown addon key', function () { + ['user' => $user, 'customer' => $customer] = billingSetup('start'); + + Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'ghost'); + + expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse(); +});