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
parent
eabcb98f91
commit
515f26234e
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.'],
|
||||
],
|
||||
];
|
||||
|
|
@ -13,6 +13,7 @@ return [
|
|||
'users' => 'Benutzer',
|
||||
'backups' => 'Backups',
|
||||
'invoices' => 'Rechnungen',
|
||||
'billing' => 'Paket & Addons',
|
||||
'support' => 'Support',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'],
|
||||
],
|
||||
];
|
||||
|
|
@ -13,6 +13,7 @@ return [
|
|||
'users' => 'Users',
|
||||
'backups' => 'Backups',
|
||||
'invoices' => 'Invoices',
|
||||
'billing' => 'Plan & add-ons',
|
||||
'support' => 'Support',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue