feat(portal): billing page — current plan, upgrades, extra storage, add-ons

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 13:36:52 +02:00
parent eabcb98f91
commit 515f26234e
13 changed files with 379 additions and 9 deletions

97
app/Livewire/Billing.php Normal file
View File

@ -0,0 +1,97 @@
<?php
namespace App\Livewire;
use App\Models\Customer;
use App\Models\Order;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Billing extends Component
{
/**
* Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
* mocked for now the order is created with status 'pending'.
*/
public function purchase(string $type, ?string $key = null): void
{
$customer = $this->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(),
]);
}
}

View File

@ -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',
];

View File

@ -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' => [

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->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']);
});
}
};

36
lang/de/billing.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'title' => '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.'],
],
];

View File

@ -13,6 +13,7 @@ return [
'users' => 'Benutzer',
'backups' => 'Backups',
'invoices' => 'Rechnungen',
'billing' => 'Paket & Addons',
'support' => 'Support',
],

36
lang/en/billing.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'title' => '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.'],
],
];

View File

@ -13,6 +13,7 @@ return [
'users' => 'Users',
'backups' => 'Backups',
'invoices' => 'Invoices',
'billing' => 'Plan & add-ons',
'support' => 'Support',
],

View File

@ -28,6 +28,7 @@
['users', 'users', 'users'],
['backups', 'database', 'backups'],
['invoices', 'receipt', 'invoices'],
['billing', 'box', 'billing'],
['support', 'life-buoy', 'support'],
] as [$route, $icon, $key])
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">

View File

@ -0,0 +1,95 @@
<?php use Illuminate\Support\Number; ?>
<div class="space-y-6" x-data="{ msgs: @js(['purchased' => __('billing.purchased')]) }">
@php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('billing.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
</div>
{{-- Current plan --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-line bg-surface-2 px-6 py-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-faint">{{ __('billing.current_plan') }}</p>
<p class="mt-0.5 text-lg font-semibold text-ink">{{ __('billing.plan.'.$currentKey) }}</p>
</div>
<div class="text-right">
<p class="text-2xl font-semibold text-ink">{{ $eur($current['price_cents'] ?? 0) }}</p>
<p class="text-xs text-muted">{{ __('billing.per_month') }}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-px bg-line sm:grid-cols-4">
@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])
<div class="bg-surface px-6 py-4">
<p class="text-xs text-muted">{{ __($label) }}</p>
<p class="mt-0.5 font-semibold text-ink">{{ $value }}</p>
</div>
@endforeach
</div>
</div>
@if ($pending->isNotEmpty())
<div class="flex items-start gap-3 rounded-xl border border-info-border bg-info-bg p-4 animate-rise">
<x-ui.icon name="bell" class="size-5 shrink-0 text-info" />
<p class="text-sm text-body">{{ __('billing.pending_note', ['count' => $pending->count()]) }}</p>
</div>
@endif
{{-- Upgrades --}}
@if (count($upgrades) > 0)
<div class="animate-rise [animation-delay:120ms]">
<h2 class="mb-3 font-semibold text-ink">{{ __('billing.upgrade_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($upgrades as $key)
@php $p = $plans[$key]; @endphp
<div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs transition hover:border-accent-border">
<p class="font-semibold text-ink">{{ __('billing.plan.'.$key) }}</p>
<p class="mt-1 text-sm text-muted">{{ $p['quota_gb'] }} GB · {{ $p['cores'] }} vCPU · {{ round($p['ram_mb'] / 1024) }} GB RAM</p>
<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>
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('upgrade', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.upgrade_cta') }}
</x-ui.button>
</div>
@endforeach
</div>
</div>
@endif
{{-- Extra storage + Add-ons --}}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise [animation-delay:180ms]">
{{-- Storage --}}
<div class="flex flex-col rounded-xl border border-accent-border bg-accent-subtle p-5">
<div class="flex items-center gap-2">
<x-ui.icon name="database" class="size-5 text-accent-active" />
<p class="font-semibold text-ink">{{ __('billing.storage_title') }}</p>
</div>
<p class="mt-2 text-sm text-body">{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}</p>
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('storage')" wire:loading.attr="disabled">
{{ __('billing.storage_cta', ['gb' => $storage['gb']]) }}
</x-ui.button>
</div>
{{-- Add-ons --}}
@foreach ($addons as $key => $addon)
<div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs">
<div class="flex items-center gap-2">
<x-ui.icon name="box" class="size-5 text-muted" />
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
</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>
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.addon_cta') }}
</x-ui.button>
</div>
@endforeach
</div>
<p class="text-xs text-faint">{{ __('billing.mock_note') }}</p>
</div>

View File

@ -68,9 +68,9 @@
<p class="text-sm font-semibold text-ink">{{ __('dashboard.upsell.title', ['percent' => $storagePercent]) }}</p>
<p class="text-sm text-muted">{{ __('dashboard.upsell.body') }}</p>
</div>
<x-ui.button variant="secondary" size="sm" class="relative ml-auto" @click="$dispatch('notify', { message: msgs.addon })">
{{ __('dashboard.upsell.cta') }}
</x-ui.button>
<a href="{{ route('billing') }}" wire:navigate class="relative ml-auto">
<x-ui.button variant="secondary" size="sm">{{ __('dashboard.upsell.cta') }}</x-ui.button>
</a>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[1.35fr_1fr] lg:items-start">

View File

@ -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');
});

View File

@ -0,0 +1,70 @@
<?php
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\User;
use Livewire\Livewire;
function billingSetup(string $plan = 'start'): array
{
$user = User::factory()->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();
});