diff --git a/VERSION b/VERSION
index f88d360..52356d3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.3.49
+1.3.50
diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php
index 99182a5..041058f 100644
--- a/app/Livewire/Billing.php
+++ b/app/Livewire/Billing.php
@@ -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(),
diff --git a/lang/de/billing.php b/lang/de/billing.php
index ee0b526..c6d84b7 100644
--- a/lang/de/billing.php
+++ b/lang/de/billing.php
@@ -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.',
diff --git a/lang/de/order.php b/lang/de/order.php
index 0831bd7..9b1650d 100644
--- a/lang/de/order.php
+++ b/lang/de/order.php
@@ -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,
diff --git a/lang/en/billing.php b/lang/en/billing.php
index d16f43b..e536e33 100644
--- a/lang/en/billing.php
+++ b/lang/en/billing.php
@@ -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',
diff --git a/lang/en/order.php b/lang/en/order.php
index 9bf9101..a480d2d 100644
--- a/lang/en/order.php
+++ b/lang/en/order.php
@@ -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
diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php
index 6ba8ae9..bcd6dee 100644
--- a/resources/views/livewire/billing.blade.php
+++ b/resources/views/livewire/billing.blade.php
@@ -7,6 +7,25 @@
{{ __('billing.subtitle') }}
+ {{-- 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)
+
+ @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())
diff --git a/resources/views/livewire/order.blade.php b/resources/views/livewire/order.blade.php
index 5fe395b..78fb08e 100644
--- a/resources/views/livewire/order.blade.php
+++ b/resources/views/livewire/order.blade.php
@@ -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.
+--}}
{{ __('order.title') }}
-
{{ __('order.subtitle') }}
+
{{ __('order.subtitle') }}
@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. --}}
{{ __('order.already_customer') }}
{{ __('order.to_billing') }}
@@ -18,110 +32,114 @@
@else
@error('plan'){{ $message }}@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.
+
+ {{-- ── 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. --}}
-
- @if ($plan['recommended'])
- {{ __('order.recommended') }}
+ 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. --}}
+
+
+ @error('immediate_start')
+ {{ $message }}
+ @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. --}}
+
+ @foreach ($plans as $i => $plan)
+
$plan['recommended'],
+ 'border-line' => ! $plan['recommended'],
+ ]) style="animation-delay: {{ 60 + $i * 40 }}ms">
+ {{-- Name, who it is for, and the one badge. --}}
+
- @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. --}}
-
- {{-- 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
+ {{-- 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. --}}
+
- {{-- A plain form post, not a Livewire action: the response
- is a redirect to another domain, and Livewire's
- XHR-and-swap cannot leave the site. --}}
-
-
- @endforeach
+ {{-- A plain form post, not a Livewire action: the response
+ is a redirect to another domain, and Livewire's
+ XHR-and-swap cannot leave the site. --}}
+
+
+ @endforeach
+
- {{-- 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. --}}
-
-
- @error('immediate_start')
- {{ $message }}
- @enderror
-
-
-
- {{ __('order.terms') }}
-
+
{{ __('order.terms') }}
@endif
diff --git a/tests/Feature/Admin/MailRegisterTest.php b/tests/Feature/Admin/MailRegisterTest.php
index 1093418..5752cc8 100644
--- a/tests/Feature/Admin/MailRegisterTest.php
+++ b/tests/Feature/Admin/MailRegisterTest.php
@@ -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'));
+});
diff --git a/tests/Feature/SelfServiceOrderTest.php b/tests/Feature/SelfServiceOrderTest.php
index fbf01d1..b1e5468 100644
--- a/tests/Feature/SelfServiceOrderTest.php
+++ b/tests/Feature/SelfServiceOrderTest.php
@@ -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();
+});