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) +
+

{{ __('billing.none_title') }}

+

{{ __('billing.none_body') }}

+ +
+ {{ __('billing.none_cta') }} + {{ __('dashboard.nav.support') }} +
+
+ @else + {{-- Current plan --}}
@@ -79,9 +98,14 @@
@endif
+ @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())
@@ -150,6 +174,8 @@
@endif + @if ($hasContract) + {{-- Upgrades --}} @if (count($upgrades) > 0)
@@ -435,5 +461,7 @@ @endforeach
+ @endif +

{{ __('billing.mock_note') }}

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. --}} -
-
- @foreach ($plans as $plan) -
$plan['recommended'], - 'border-line' => ! $plan['recommended'], - ])> -
-

{{ $plan['name'] }}

- @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. --}} +
+

{{ $plan['name'] }}

+ @if ($plan['recommended']) + {{ __('order.recommended') }} + @endif +
+ @if ($plan['audience']) +

{{ $plan['audience'] }}

@endif -
- @if ($plan['audience']) -

{{ $plan['audience'] }}

- @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. --}} -

- {{ Number::currency($plan['gross'] / 100, in: $plan['currency'], locale: app()->getLocale()) }} - {{ __('order.per_month') }} -

-

- {{-- 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. --}} +

+ {{ Number::currency($plan['gross'] / 100, in: $plan['currency'], locale: app()->getLocale()) }} + {{ __('order.per_month') }} +

+

{{ __('order.incl_vat', ['rate' => $vat]) }} · {{ __('order.net') }} {{ Number::currency($plan['net'] / 100, in: $plan['currency'], locale: app()->getLocale()) }} - @endif -

- @if ($setup > 0) -

- {{ __('order.setup', ['amount' => Number::currency($setup / 100, in: $plan['currency'], locale: app()->getLocale())]) }}

- @endif + @if ($setup > 0) +

+ {{ __('order.setup', ['amount' => Number::currency($setup / 100, in: $plan['currency'], locale: app()->getLocale())]) }} +

+ @endif - + {{-- Read from the estate, not written down: where a host + has room this rolls out on its own. --}} + -
- @foreach ([ - __('order.storage') => $plan['quota_gb'].' GB', - __('order.traffic') => $plan['traffic_gb'].' GB', - __('order.seats') => $plan['seats'], - ] as $term => $value) -
-
{{ $term }}
-
{{ $value }}
-
- @endforeach -
+
+ @foreach ([ + __('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) +
+
{{ $term }}
+
{{ $value }}
+
+ @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. --}} -
- @csrf - - {{-- The consent, carried from the one box below. --}} - - {{-- The wording is the legal one and not a nicety: - the button that concludes a paid contract has to - say that it costs money. --}} - - {{ __('order.buy') }} - -
-
- @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. --}} +
+ @csrf + + + + {{-- 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. --}} + + {{ __('order.buy') }} + +

+ {{ __('order.consent_first') }} +

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