Stop inventing a package, and rebuild the booking page
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
parent
2321854967
commit
3e7026407b
|
|
@ -538,12 +538,35 @@ class Billing extends Component
|
|||
$customer = $this->customer();
|
||||
$instance = $customer?->instances()->latest('id')->first();
|
||||
$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
|
||||
// one does today, and offering that as an "upgrade" would charge a
|
||||
// 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
|
||||
// WITH the reason rather than hidden: a customer who cannot downgrade
|
||||
|
|
@ -567,12 +590,13 @@ class Billing extends Component
|
|||
: collect();
|
||||
|
||||
return view('livewire.billing', [
|
||||
'hasContract' => $hasContract,
|
||||
'currentKey' => $currentKey,
|
||||
// What they HAVE comes from their contract; only what they could
|
||||
// BUY comes from the shop. Reading this off the catalogue showed a
|
||||
// customer today's price as though it were theirs, and dropped the
|
||||
// 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,
|
||||
'plans' => $plans,
|
||||
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ return [
|
|||
'traffic_cta' => ':gb GB nachbuchen',
|
||||
'title' => 'Paket & Addons',
|
||||
'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',
|
||||
'per_month' => 'pro Monat',
|
||||
'month_short' => 'Mon.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
'recommended' => 'Empfohlen',
|
||||
'consent_title' => 'Sofortiger Beginn — bitte bestätigen',
|
||||
'consent_first' => 'Bitte oben zuerst bestätigen.',
|
||||
'up_to' => 'bis :count',
|
||||
'per_month' => '/Monat',
|
||||
'incl_vat' => 'inkl. :rate % MwSt.',
|
||||
// Für ein bestätigtes EU-Unternehmen außerhalb Österreichs: keine Umsatzsteuer,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ return [
|
|||
'traffic_cta' => 'Add :gb GB',
|
||||
'title' => 'Plan & 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',
|
||||
'per_month' => 'per month',
|
||||
'month_short' => 'mo',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
'recommended' => 'Recommended',
|
||||
'consent_title' => 'Immediate start — please confirm',
|
||||
'consent_first' => 'Please confirm above first.',
|
||||
'up_to' => 'up to :count',
|
||||
'per_month' => '/month',
|
||||
'incl_vat' => 'incl. :rate % VAT',
|
||||
// For a verified EU business outside Austria: no VAT, and therefore no second
|
||||
|
|
|
|||
|
|
@ -7,6 +7,25 @@
|
|||
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
|
||||
</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 --}}
|
||||
<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">
|
||||
|
|
@ -79,9 +98,14 @@
|
|||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 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())
|
||||
<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">
|
||||
|
|
@ -150,6 +174,8 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hasContract)
|
||||
|
||||
{{-- Upgrades --}}
|
||||
@if (count($upgrades) > 0)
|
||||
<div class="animate-rise [animation-delay:120ms]">
|
||||
|
|
@ -435,5 +461,7 @@
|
|||
@endforeach
|
||||
</div>
|
||||
|
||||
@endif
|
||||
|
||||
<p class="text-xs text-muted">{{ __('billing.mock_note') }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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="animate-rise">
|
||||
<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>
|
||||
|
||||
@if ($contract)
|
||||
{{-- Not an error and not a dead end: they have a contract, and the way
|
||||
to a bigger package is a plan change, which prorates and leaves
|
||||
their data where it is. A second checkout would build a second,
|
||||
empty cloud and bill for both. --}}
|
||||
{{-- Not an error and not a dead end: they have a contract, and the way to
|
||||
a bigger package is a plan change, which prorates and leaves their
|
||||
data where it is. A second checkout would build a second, empty cloud
|
||||
and bill for both. --}}
|
||||
<x-ui.alert variant="info">
|
||||
{{ __('order.already_customer') }}
|
||||
<a href="{{ route('billing') }}" class="font-semibold underline">{{ __('order.to_billing') }}</a>
|
||||
|
|
@ -18,51 +32,66 @@
|
|||
@else
|
||||
@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
|
||||
law's own wording and four copies of it is a wall nobody reads.
|
||||
The forms each carry it as a hidden field bound to this one box.
|
||||
<div x-data="{ immediateStart: false }" class="space-y-6">
|
||||
{{-- ── The consent, first ────────────────────────────────────────
|
||||
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
|
||||
`accepted`), so with JavaScript off the field arrives empty and the
|
||||
checkout is refused with the sentence explaining why — the safe
|
||||
direction to fail in. Ticking a box in a browser is not the record
|
||||
anyway: the record is the timestamp taken when the payment lands. --}}
|
||||
<div x-data="{ immediateStart: false }">
|
||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($plans as $plan)
|
||||
The server rule is still the real gate (CheckoutController
|
||||
validates `accepted`), so with JavaScript off the field arrives
|
||||
empty and the checkout is refused with the sentence explaining
|
||||
why. Ticking a box in a browser was never the record: the record
|
||||
is the timestamp taken when the payment lands. --}}
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-lg border p-5 shadow-xs transition-colors animate-rise [animation-delay:40ms]"
|
||||
x-bind:class="immediateStart ? 'border-success-border bg-success-bg' : 'border-accent-border bg-accent-subtle'">
|
||||
<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([
|
||||
'flex flex-col rounded-lg border bg-surface p-6 shadow-xs animate-rise',
|
||||
'border-ink shadow-md' => $plan['recommended'],
|
||||
'flex flex-col rounded-xl border bg-surface p-6 shadow-xs animate-rise',
|
||||
'border-ink shadow-md ring-1 ring-ink' => $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">
|
||||
<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'])
|
||||
<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
|
||||
</div>
|
||||
@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
|
||||
|
||||
{{-- What this customer's card will be charged, like the public
|
||||
sheet. A customer quoted 58,80 € out there must not meet
|
||||
49 € on the page with the button on it. --}}
|
||||
{{-- The price, gross and large. A customer quoted 58,80 €
|
||||
on the website must not meet 49 € on the page with
|
||||
the button on it; the net figure is underneath for
|
||||
the books. --}}
|
||||
<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>
|
||||
</p>
|
||||
<p class="mt-1 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
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
{{ __('order.incl_vat', ['rate' => $vat]) }} ·
|
||||
{{ __('order.net') }} {{ Number::currency($plan['net'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($setup > 0)
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
|
|
@ -70,15 +99,17 @@
|
|||
</p>
|
||||
@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" />
|
||||
|
||||
<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 ([
|
||||
__('order.storage') => $plan['quota_gb'].' GB',
|
||||
__('order.traffic') => $plan['traffic_gb'].' GB',
|
||||
__('order.seats') => $plan['seats'],
|
||||
__('order.storage') => $plan['quota_gb'] >= 1000 ? ($plan['quota_gb'] / 1000).' TB' : $plan['quota_gb'].' GB',
|
||||
__('order.traffic') => $plan['traffic_gb'] >= 1000 ? ($plan['traffic_gb'] / 1000).' TB' : $plan['traffic_gb'].' GB',
|
||||
__('order.seats') => __('order.up_to', ['count' => $plan['seats']]),
|
||||
] 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>
|
||||
<dd class="font-mono tabular-nums text-ink">{{ $value }}</dd>
|
||||
</div>
|
||||
|
|
@ -91,37 +122,24 @@
|
|||
<form method="POST" action="{{ route('checkout.start') }}" class="mt-6">
|
||||
@csrf
|
||||
<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' : ''" />
|
||||
{{-- The wording is the legal one and not a nicety:
|
||||
the button that concludes a paid contract has to
|
||||
say that it costs money. --}}
|
||||
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full"
|
||||
x-bind:disabled="!immediateStart">
|
||||
|
||||
{{-- Enabled, with the reason it will not go through
|
||||
said next to it. A disabled button explains
|
||||
nothing; this one is refused by the server with a
|
||||
sentence, and the line below says so first. --}}
|
||||
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full">
|
||||
{{ __('order.buy') }}
|
||||
</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>
|
||||
</div>
|
||||
@endforeach
|
||||
</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>
|
||||
|
||||
<p class="max-w-[80ch] text-xs leading-relaxed text-muted">
|
||||
{{ __('order.terms') }}
|
||||
</p>
|
||||
<p class="max-w-[80ch] text-xs leading-relaxed text-muted">{{ __('order.terms') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Livewire\Admin\Overview;
|
|||
use App\Mail\OperatorMessageMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\SentMail;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SupportRequest;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
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']));
|
||||
}
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
->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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue