Stop inventing a package, and rebuild the booking page
tests / pest (push) Failing after 8m12s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

Two pages said opposite things about one account, straight after
registering: the dashboard said "noch keine Instanz", and Paket & Addons
said "AKTUELLES PAKET Start — Status Aktiv". The dashboard was right. The
package page fell back to `$instance?->plan ?? 'start'` and rendered that
fallback as a contract the customer had entered into, complete with a status
of Aktiv, on the one page somebody opens to find out what they pay.

The contract is resolved from the CUSTOMER now, not from the instance — a
paid order parked for want of a machine has a subscription and no instance
yet, and asking the instance would tell somebody who has paid that they have
no package. An instance counts as well, whatever its subscription row says.
With neither, the page says so and points at the booking page: an "Upgrade"
from nothing is a purchase, and that has its own page.

The cart stays outside that gate. A customer can have something in it before
they have any contract at all, and hiding it there would hide the thing they
came to pay for — which is what my first version did, and seven existing
tests said so.

And the booking page. Five cards in a four-column grid left the fifth alone
on its own row, reading as a mistake; the consent box sat at the BOTTOM with
every buy button disabled until it was ticked, so the page arrived looking
broken before anybody had done anything wrong; and the price ran across the
card in one block with nothing to compare.

Now the consent comes first, because it is the condition for everything under
it, and it colours itself green once given. Three columns, so five plans read
as 3 + 2. Each card is the same four rows in the same order — price, delivery,
what you get, the button — so the eye can run across a row and compare like
with like. The button stays ENABLED with the reason it will not go through
said under it: a disabled control explains nothing, and the server is the gate
either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus^2 v1.3.50
nexxo 2026-07-30 14:29:22 +02:00
parent 2321854967
commit 3e7026407b
10 changed files with 254 additions and 104 deletions

View File

@ -1 +1 @@
1.3.49 1.3.50

View File

@ -538,12 +538,35 @@ class Billing extends Component
$customer = $this->customer(); $customer = $this->customer();
$instance = $customer?->instances()->latest('id')->first(); $instance = $customer?->instances()->latest('id')->first();
$plans = app(PlanCatalogue::class)->sellable(); $plans = app(PlanCatalogue::class)->sellable();
$currentKey = $instance?->plan ?? 'start';
// The contract, resolved from the CUSTOMER rather than from the
// instance: a paid order waiting for a machine has a subscription and no
// instance yet (see ReserveResources, which parks it), and asking the
// instance would report "no package" at somebody who has paid.
$contract = $customer === null ? null : Subscription::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'past_due'])
->latest('id')
->first();
// Whether there is anything to show at all. Without this the page fell
// back to 'start' for a customer who had bought nothing and rendered it
// as "AKTUELLES PAKET Start — Status Aktiv": a contract nobody entered
// into, on the page a customer opens to find out what they pay. Reported
// straight after registering, and the dashboard said the opposite on the
// same account.
// An INSTANCE counts too, whatever the subscription rows say: a running
// cloud is a contract by any reading a customer cares about, and older
// installations have instances whose subscription row predates the
// catalogue rebuild.
$hasContract = $contract !== null || $instance !== null;
$currentKey = $instance?->plan ?? $contract?->plan ?? (string) array_key_first($plans);
// Rank, not price: a grandfathered plan can cost less than a smaller // Rank, not price: a grandfathered plan can cost less than a smaller
// one does today, and offering that as an "upgrade" would charge a // one does today, and offering that as an "upgrade" would charge a
// customer immediately for losing resources. // customer immediately for losing resources.
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0); $currentTier = (int) ($instance?->subscription?->tier ?? $contract?->tier ?? $plans[$currentKey]['tier'] ?? 0);
// Smaller plans, each with the reason it cannot be taken today. Shown // Smaller plans, each with the reason it cannot be taken today. Shown
// WITH the reason rather than hidden: a customer who cannot downgrade // WITH the reason rather than hidden: a customer who cannot downgrade
@ -567,12 +590,13 @@ class Billing extends Component
: collect(); : collect();
return view('livewire.billing', [ return view('livewire.billing', [
'hasContract' => $hasContract,
'currentKey' => $currentKey, 'currentKey' => $currentKey,
// What they HAVE comes from their contract; only what they could // What they HAVE comes from their contract; only what they could
// BUY comes from the shop. Reading this off the catalogue showed a // BUY comes from the shop. Reading this off the catalogue showed a
// customer today's price as though it were theirs, and dropped the // customer today's price as though it were theirs, and dropped the
// card entirely once their plan stopped being sold. // card entirely once their plan stopped being sold.
'current' => $this->currentTerms($instance?->subscription, $instance, $plans[$currentKey] ?? []), 'current' => $this->currentTerms($instance?->subscription ?? $contract, $instance, $plans[$currentKey] ?? []),
'instance' => $instance, 'instance' => $instance,
'plans' => $plans, 'plans' => $plans,
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(), 'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),

View File

@ -65,6 +65,9 @@ return [
'traffic_cta' => ':gb GB nachbuchen', 'traffic_cta' => ':gb GB nachbuchen',
'title' => 'Paket & Addons', 'title' => 'Paket & Addons',
'subtitle' => 'Ihr aktuelles Paket, Upgrades, Zusatzspeicher und Erweiterungen.', 'subtitle' => 'Ihr aktuelles Paket, Upgrades, Zusatzspeicher und Erweiterungen.',
'none_title' => 'Sie haben noch kein Paket gebucht',
'none_body' => 'Hier stehen später Ihr Paket, Ihre Zusatzoptionen und was das monatlich kostet. Solange nichts gebucht ist, gibt es hier auch nichts zu ändern.',
'none_cta' => 'Paket buchen',
'current_plan' => 'Aktuelles Paket', 'current_plan' => 'Aktuelles Paket',
'per_month' => 'pro Monat', 'per_month' => 'pro Monat',
'month_short' => 'Mon.', 'month_short' => 'Mon.',

View File

@ -8,6 +8,9 @@ return [
'subtitle' => 'Wählen Sie Ihr Paket. Nach der Zahlung wird Ihre Cloud automatisch aufgesetzt — Sie bekommen die Zugangsdaten per E-Mail und müssen nichts weiter tun.', 'subtitle' => 'Wählen Sie Ihr Paket. Nach der Zahlung wird Ihre Cloud automatisch aufgesetzt — Sie bekommen die Zugangsdaten per E-Mail und müssen nichts weiter tun.',
'recommended' => 'Empfohlen', 'recommended' => 'Empfohlen',
'consent_title' => 'Sofortiger Beginn — bitte bestätigen',
'consent_first' => 'Bitte oben zuerst bestätigen.',
'up_to' => 'bis :count',
'per_month' => '/Monat', 'per_month' => '/Monat',
'incl_vat' => 'inkl. :rate % MwSt.', 'incl_vat' => 'inkl. :rate % MwSt.',
// Für ein bestätigtes EU-Unternehmen außerhalb Österreichs: keine Umsatzsteuer, // Für ein bestätigtes EU-Unternehmen außerhalb Österreichs: keine Umsatzsteuer,

View File

@ -65,6 +65,9 @@ return [
'traffic_cta' => 'Add :gb GB', 'traffic_cta' => 'Add :gb GB',
'title' => 'Plan & add-ons', 'title' => 'Plan & add-ons',
'subtitle' => 'Your current plan, upgrades, extra storage and add-ons.', 'subtitle' => 'Your current plan, upgrades, extra storage and add-ons.',
'none_title' => 'You have not booked a package yet',
'none_body' => 'Your package, your extras and what they cost per month will be here. While nothing is booked there is nothing to change here either.',
'none_cta' => 'Book a package',
'current_plan' => 'Current plan', 'current_plan' => 'Current plan',
'per_month' => 'per month', 'per_month' => 'per month',
'month_short' => 'mo', 'month_short' => 'mo',

View File

@ -8,6 +8,9 @@ return [
'subtitle' => 'Pick your package. Once it is paid your cloud is built automatically — your credentials arrive by mail and there is nothing else for you to do.', 'subtitle' => 'Pick your package. Once it is paid your cloud is built automatically — your credentials arrive by mail and there is nothing else for you to do.',
'recommended' => 'Recommended', 'recommended' => 'Recommended',
'consent_title' => 'Immediate start — please confirm',
'consent_first' => 'Please confirm above first.',
'up_to' => 'up to :count',
'per_month' => '/month', 'per_month' => '/month',
'incl_vat' => 'incl. :rate % VAT', 'incl_vat' => 'incl. :rate % VAT',
// For a verified EU business outside Austria: no VAT, and therefore no second // For a verified EU business outside Austria: no VAT, and therefore no second

View File

@ -7,6 +7,25 @@
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p> <p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
</div> </div>
{{-- Nothing booked. Said plainly instead of falling back to the first plan
and rendering it as "AKTUELLES PAKET — Status Aktiv": that is a contract
nobody entered into, on the page a customer opens to find out what they
pay, and the dashboard said the opposite about the same account.
Everything below needs a contract to mean anything an "Upgrade" from
nothing is a purchase, and that has its own page. --}}
@if (! $hasContract)
<div class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="text-lg font-semibold text-ink">{{ __('billing.none_title') }}</h2>
<p class="mt-2 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('billing.none_body') }}</p>
<div class="mt-6 flex flex-wrap gap-2.5">
<x-ui.button variant="ink" :href="route('order')" wire:navigate>{{ __('billing.none_cta') }}</x-ui.button>
<x-ui.button variant="secondary" :href="route('support')" wire:navigate>{{ __('dashboard.nav.support') }}</x-ui.button>
</div>
</div>
@else
{{-- Current plan --}} {{-- Current plan --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]"> <div class="overflow-hidden rounded-lg 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 class="flex flex-wrap items-center justify-between gap-4 border-b border-line bg-surface-2 px-6 py-4">
@ -79,9 +98,14 @@
</div> </div>
@endif @endif
</div> </div>
@endif
{{-- Cart: "5 purchases pending" told nobody what they had ordered, and {{-- Cart: "5 purchases pending" told nobody what they had ordered, and
offered no way to change their mind. --}} offered no way to change their mind.
Outside the contract gate on purpose: a customer can have something in
their cart before they have any contract at all, and hiding it there
would hide the thing they came to pay for. --}}
@if ($pending->isNotEmpty()) @if ($pending->isNotEmpty())
<div class="overflow-hidden rounded-lg border border-info-border bg-surface shadow-xs animate-rise"> <div class="overflow-hidden rounded-lg border border-info-border bg-surface shadow-xs animate-rise">
<div class="flex items-center gap-2 border-b border-line bg-info-bg px-5 py-3"> <div class="flex items-center gap-2 border-b border-line bg-info-bg px-5 py-3">
@ -150,6 +174,8 @@
</div> </div>
@endif @endif
@if ($hasContract)
{{-- Upgrades --}} {{-- Upgrades --}}
@if (count($upgrades) > 0) @if (count($upgrades) > 0)
<div class="animate-rise [animation-delay:120ms]"> <div class="animate-rise [animation-delay:120ms]">
@ -435,5 +461,7 @@
@endforeach @endforeach
</div> </div>
@endif
<p class="text-xs text-muted">{{ __('billing.mock_note') }}</p> <p class="text-xs text-muted">{{ __('billing.mock_note') }}</p>
</div> </div>

View File

@ -1,14 +1,28 @@
{{--
Buying a package.
The first version put five cards in a four-column grid, so the fifth wrapped
and sat alone; the consent box was at the BOTTOM, which left every button
disabled on arrival a page that looks broken before you have done anything
wrong. And the prices ran across the card in one block, so nothing stood out
to compare.
Now: the consent comes first, because it is the condition for everything under
it. Three columns, so five plans read as 3 + 2 rather than 4 + 1. Each card is
one column of the same four rows price, delivery, what you get, the button
so the eye can run down a row and compare like with like.
--}}
<div class="mx-auto max-w-[1120px] space-y-6"> <div class="mx-auto max-w-[1120px] space-y-6">
<div class="animate-rise"> <div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('order.title') }}</h1> <h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('order.title') }}</h1>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('order.subtitle') }}</p> <p class="mt-1 max-w-[68ch] text-sm leading-relaxed text-muted">{{ __('order.subtitle') }}</p>
</div> </div>
@if ($contract) @if ($contract)
{{-- Not an error and not a dead end: they have a contract, and the way {{-- Not an error and not a dead end: they have a contract, and the way to
to a bigger package is a plan change, which prorates and leaves a bigger package is a plan change, which prorates and leaves their
their data where it is. A second checkout would build a second, data where it is. A second checkout would build a second, empty cloud
empty cloud and bill for both. --}} and bill for both. --}}
<x-ui.alert variant="info"> <x-ui.alert variant="info">
{{ __('order.already_customer') }} {{ __('order.already_customer') }}
<a href="{{ route('billing') }}" class="font-semibold underline">{{ __('order.to_billing') }}</a> <a href="{{ route('billing') }}" class="font-semibold underline">{{ __('order.to_billing') }}</a>
@ -18,51 +32,66 @@
@else @else
@error('plan')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror @error('plan')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
{{-- One consent for the page, not one per card: the sentence is the <div x-data="{ immediateStart: false }" class="space-y-6">
law's own wording and four copies of it is a wall nobody reads. {{-- ── The consent, first ────────────────────────────────────────
The forms each carry it as a hidden field bound to this one box. It was at the bottom, and every buy button was disabled until it
was ticked so the page arrived looking broken. Read in the
order it applies: this is the condition, the packages are the
choice.
The server rule is the real gate (CheckoutController validates The server rule is still the real gate (CheckoutController
`accepted`), so with JavaScript off the field arrives empty and the validates `accepted`), so with JavaScript off the field arrives
checkout is refused with the sentence explaining why the safe empty and the checkout is refused with the sentence explaining
direction to fail in. Ticking a box in a browser is not the record why. Ticking a box in a browser was never the record: the record
anyway: the record is the timestamp taken when the payment lands. --}} is the timestamp taken when the payment lands. --}}
<div x-data="{ immediateStart: false }"> <label class="flex cursor-pointer items-start gap-3 rounded-lg border p-5 shadow-xs transition-colors animate-rise [animation-delay:40ms]"
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4"> x-bind:class="immediateStart ? 'border-success-border bg-success-bg' : 'border-accent-border bg-accent-subtle'">
@foreach ($plans as $plan) <input type="checkbox" x-model="immediateStart"
class="mt-0.5 size-4 shrink-0 rounded border-line-strong text-accent-active focus:ring-accent-active" />
<span class="min-w-0">
<span class="block text-sm font-semibold text-ink">{{ __('order.consent_title') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-body">{{ __('checkout.immediate_start') }}</span>
</span>
</label>
@error('immediate_start')
<x-ui.alert variant="error">{{ $message }}</x-ui.alert>
@enderror
{{-- ── The packages ──────────────────────────────────────────────
Three columns: five plans read as 3 + 2, and a fifth card alone
on a row of four reads as a mistake. items-start, because the
recommended one is allowed to be taller without dragging its
neighbours' buttons down with it. --}}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:items-start">
@foreach ($plans as $i => $plan)
<div @class([ <div @class([
'flex flex-col rounded-lg border bg-surface p-6 shadow-xs animate-rise', 'flex flex-col rounded-xl border bg-surface p-6 shadow-xs animate-rise',
'border-ink shadow-md' => $plan['recommended'], 'border-ink shadow-md ring-1 ring-ink' => $plan['recommended'],
'border-line' => ! $plan['recommended'], 'border-line' => ! $plan['recommended'],
])> ]) style="animation-delay: {{ 60 + $i * 40 }}ms">
{{-- Name, who it is for, and the one badge. --}}
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-ink">{{ $plan['name'] }}</h2> <h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ $plan['name'] }}</h2>
@if ($plan['recommended']) @if ($plan['recommended'])
<span class="rounded-pill bg-accent-active px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-on-accent">{{ __('order.recommended') }}</span> <span class="rounded-pill bg-accent-active px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-on-accent">{{ __('order.recommended') }}</span>
@endif @endif
</div> </div>
@if ($plan['audience']) @if ($plan['audience'])
<p class="mt-1 text-xs text-muted">{{ $plan['audience'] }}</p> <p class="mt-1 text-xs leading-relaxed text-muted">{{ $plan['audience'] }}</p>
@endif @endif
{{-- What this customer's card will be charged, like the public {{-- The price, gross and large. A customer quoted 58,80
sheet. A customer quoted 58,80 out there must not meet on the website must not meet 49 on the page with
49 on the page with the button on it. --}} the button on it; the net figure is underneath for
the books. --}}
<p class="mt-5 flex items-baseline gap-1.5"> <p class="mt-5 flex items-baseline gap-1.5">
<span class="font-mono text-2xl font-semibold tabular-nums text-ink">{{ Number::currency($plan['gross'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}</span> <span class="text-[2rem] font-bold leading-none tabular-nums tracking-[-0.03em] text-ink">{{ Number::currency($plan['gross'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}</span>
<span class="text-xs text-muted">{{ __('order.per_month') }}</span> <span class="text-xs text-muted">{{ __('order.per_month') }}</span>
</p> </p>
<p class="mt-1 text-xs text-muted"> <p class="mt-1.5 text-xs text-muted">
{{-- A reverse-charge business is charged the bare net, so
there is no VAT to state and no second figure to show:
saying "inkl. 20 %" over a net price would describe a
tax nothing collects. --}}
@if ($tax->reverseCharge)
{{ __('order.reverse_charge') }}
@else
{{ __('order.incl_vat', ['rate' => $vat]) }} · {{ __('order.incl_vat', ['rate' => $vat]) }} ·
{{ __('order.net') }} {{ Number::currency($plan['net'] / 100, in: $plan['currency'], locale: app()->getLocale()) }} {{ __('order.net') }} {{ Number::currency($plan['net'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}
@endif
</p> </p>
@if ($setup > 0) @if ($setup > 0)
<p class="mt-1 text-xs text-muted"> <p class="mt-1 text-xs text-muted">
@ -70,15 +99,17 @@
</p> </p>
@endif @endif
{{-- Read from the estate, not written down: where a host
has room this rolls out on its own. --}}
<x-ui.delivery :state="$plan['delivery']" class="mt-4" /> <x-ui.delivery :state="$plan['delivery']" class="mt-4" />
<dl class="mt-5 flex-1 space-y-2 border-t border-line pt-4 text-sm"> <dl class="mt-4 flex-1 space-y-2 border-t border-line pt-4 text-sm">
@foreach ([ @foreach ([
__('order.storage') => $plan['quota_gb'].' GB', __('order.storage') => $plan['quota_gb'] >= 1000 ? ($plan['quota_gb'] / 1000).' TB' : $plan['quota_gb'].' GB',
__('order.traffic') => $plan['traffic_gb'].' GB', __('order.traffic') => $plan['traffic_gb'] >= 1000 ? ($plan['traffic_gb'] / 1000).' TB' : $plan['traffic_gb'].' GB',
__('order.seats') => $plan['seats'], __('order.seats') => __('order.up_to', ['count' => $plan['seats']]),
] as $term => $value) ] as $term => $value)
<div class="flex justify-between gap-3"> <div class="flex items-baseline justify-between gap-3">
<dt class="text-muted">{{ $term }}</dt> <dt class="text-muted">{{ $term }}</dt>
<dd class="font-mono tabular-nums text-ink">{{ $value }}</dd> <dd class="font-mono tabular-nums text-ink">{{ $value }}</dd>
</div> </div>
@ -91,37 +122,24 @@
<form method="POST" action="{{ route('checkout.start') }}" class="mt-6"> <form method="POST" action="{{ route('checkout.start') }}" class="mt-6">
@csrf @csrf
<input type="hidden" name="plan" value="{{ $plan['key'] }}" /> <input type="hidden" name="plan" value="{{ $plan['key'] }}" />
{{-- The consent, carried from the one box below. --}}
<input type="hidden" name="immediate_start" x-bind:value="immediateStart ? '1' : ''" /> <input type="hidden" name="immediate_start" x-bind:value="immediateStart ? '1' : ''" />
{{-- The wording is the legal one and not a nicety:
the button that concludes a paid contract has to {{-- Enabled, with the reason it will not go through
say that it costs money. --}} said next to it. A disabled button explains
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full" nothing; this one is refused by the server with a
x-bind:disabled="!immediateStart"> sentence, and the line below says so first. --}}
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full">
{{ __('order.buy') }} {{ __('order.buy') }}
</x-ui.button> </x-ui.button>
<p class="mt-2 text-center text-[11px] leading-snug text-muted" x-show="!immediateStart" x-cloak>
{{ __('order.consent_first') }}
</p>
</form> </form>
</div> </div>
@endforeach @endforeach
</div> </div>
{{-- The withdrawal consent. Without it on record a withdrawal on day
thirteen gets the WHOLE amount back however long the machine ran,
because a seller who cannot prove the consent was given cannot
charge for the days it was given for. --}}
<label class="mt-5 flex max-w-[80ch] cursor-pointer items-start gap-3 rounded-lg border border-line bg-surface-2 p-4">
<input type="checkbox" x-model="immediateStart"
class="mt-0.5 size-4 shrink-0 rounded border-line-strong text-accent-active focus:ring-accent-active" />
<span class="text-xs leading-relaxed text-body">{{ __('checkout.immediate_start') }}</span>
</label>
@error('immediate_start')
<x-ui.alert variant="error" class="mt-3">{{ $message }}</x-ui.alert>
@enderror
</div> </div>
<p class="max-w-[80ch] text-xs leading-relaxed text-muted"> <p class="max-w-[80ch] text-xs leading-relaxed text-muted">{{ __('order.terms') }}</p>
{{ __('order.terms') }}
</p>
@endif @endif
</div> </div>

View File

@ -6,6 +6,7 @@ use App\Livewire\Admin\Overview;
use App\Mail\OperatorMessageMail; use App\Mail\OperatorMessageMail;
use App\Models\Customer; use App\Models\Customer;
use App\Models\SentMail; use App\Models\SentMail;
use App\Models\Subscription;
use App\Models\SupportRequest; use App\Models\SupportRequest;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Livewire\Livewire; use Livewire\Livewire;
@ -347,3 +348,38 @@ it('names every mailer that swallows mail, not only the log one', function () {
->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => $mailer ?? 'null'])); ->assertSee(__('admin.notice.mail_not_delivering', ['mailer' => $mailer ?? 'null']));
} }
}); });
it('does not invent a package for a customer who has booked nothing', function () {
// Reported straight after registering: the dashboard said "noch keine
// Instanz", and the package page said "AKTUELLES PAKET Start — Status Aktiv"
// about the same account. currentKey fell back to 'start', and the view
// rendered that fallback as a contract the customer had entered into.
$user = App\Models\User::factory()->create(['email_verified_at' => now()]);
Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]);
Livewire::actingAs($user)
->test(App\Livewire\Billing::class)
->assertViewHas('hasContract', false)
->assertSee(__('billing.none_title'))
// And no invented status beside a plan name.
->assertDontSee(__('billing.current_plan'));
});
it('shows the contract of a customer whose order is still waiting for a machine', function () {
// Resolved from the CUSTOMER, not from the instance: a paid order parked for
// want of capacity has a subscription and no instance yet, and asking the
// instance would report "no package" at somebody who has paid.
$user = App\Models\User::factory()->create(['email_verified_at' => now()]);
$customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]);
Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'status' => 'active',
'instance_id' => null,
]);
Livewire::actingAs($user)
->test(App\Livewire\Billing::class)
->assertViewHas('hasContract', true)
->assertSee(__('billing.plan.team'))
->assertDontSee(__('billing.none_title'));
});

View File

@ -286,3 +286,35 @@ it('records no consent for a session that never carried one', function () {
expect(App\Models\Order::query()->where('stripe_event_id', 'cs_evt_no_consent')->first()?->immediate_start_consent_at) expect(App\Models\Order::query()->where('stripe_event_id', 'cs_evt_no_consent')->first()?->immediate_start_consent_at)
->toBeNull(); ->toBeNull();
}); });
it('asks for the consent before the packages, not after them', function () {
// The consent used to sit at the bottom with every buy button disabled until
// it was ticked, so the page arrived looking broken. Read in the order it
// applies: the condition first, the choice second — and the button stays
// enabled, with the reason it will not go through said next to it.
$page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php'));
$consentAt = strpos($page, "__('checkout.immediate_start')");
$gridAt = strpos($page, 'lg:grid-cols-3');
expect($consentAt)->toBeLessThan($gridAt)
// No disabled button: a disabled control explains nothing.
->and($page)->not->toContain('x-bind:disabled="!immediateStart"')
->and($page)->toContain("__('order.consent_first')");
});
it('lays five packages out as three and two', function () {
// A fifth card alone on a row of four reads as a mistake.
expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php')))
->toContain('lg:grid-cols-3')
->not->toContain('lg:grid-cols-4');
});
it('still refuses the checkout when the box was not ticked', function () {
// The layout changed; the rule did not. The server is the gate.
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start'])
->assertSessionHasErrors('immediate_start');
expect($this->stripe->checkouts)->toBeEmpty();
});