From f85f57152e97a5fa2e74e3e8ed5042d2603f4b41 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 19:42:28 +0200 Subject: [PATCH] Let the customer buy it themselves, and tab the integrations page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every path on the site ended in "anfragen": a mailto: link and a promise to answer the same working day. That is an afternoon of somebody's time per customer, for a product whose whole point is that the machine does the work — and it scales exactly as far as one inbox does. Now: sign in, pick a package, pay, and provisioning starts by itself. The pieces were already there — Stripe holds a Price per plan version (the catalogue sync writes the ids), the webhook opens the contract, the pipeline builds the VM — and what was missing was the checkout session between them. CheckoutController opens one and gets out of the way; the purchase becomes real on the webhook, which stays the only place allowed to believe a payment happened. The success URL is a redirect target and therefore a URL anybody can type, so it says "we are building it", not "you have paid". Nothing in the request may decide the price. The plan key arrives from a form and everything else is re-derived from the catalogue, including the plan VERSION, so a version published while the customer types their card number cannot change what they were quoted. Two cases that are not errors: no capacity is a parked order (already built), and an existing customer is sent to the plan change rather than sold a second, empty cloud that bills twice. The site says what it can keep. "danach wissen Sie … wie Ihre Daten sicher umziehen" was a promise made before seeing the thing it is about: we do not know where a visitor's data is, what shape it is in, or whether it can be moved at all. Migration is named as its own question, looked at first and quoted afterwards. The integrations page gets the settings page's treatment for the same reason: it was one narrow column of six cards, so an operator connecting DNS scrolled past Stripe to reach it and the raw .env editor sat at the bottom of everything. Three tabs by what a section is FOR — services we buy from outside, our own machines, the file underneath — the same axis the page was rebuilt around when it stopped being split by storage mechanism. The tab is in the query string; unlocking survives a switch, because a confirmed password is a session fact and not a property of a tab. The .env tab is Owner-only, like the editor it holds. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Http/Controllers/CheckoutController.php | 144 +++++++++++++++ app/Livewire/Admin/Integrations.php | 42 +++++ app/Livewire/Order.php | 97 ++++++++++ app/Services/Provisioning/HostCapacity.php | 27 +++ lang/de/checkout.php | 9 + lang/de/dashboard.php | 1 + lang/de/integrations.php | 9 +- lang/de/order.php | 26 +++ lang/en/checkout.php | 9 + lang/en/dashboard.php | 1 + lang/en/integrations.php | 9 +- lang/en/order.php | 26 +++ resources/views/landing.blade.php | 47 +++-- .../livewire/admin/integrations.blade.php | 53 +++++- resources/views/livewire/dashboard.blade.php | 6 +- resources/views/livewire/order.blade.php | 91 +++++++++ routes/web.php | 16 ++ tests/Feature/Admin/EnvRestartTest.php | 9 +- tests/Feature/Admin/IntegrationsPageTest.php | 28 +-- tests/Feature/Admin/SettingsTabsTest.php | 29 +++ tests/Feature/SelfServiceOrderTest.php | 173 ++++++++++++++++++ 22 files changed, 822 insertions(+), 32 deletions(-) create mode 100644 app/Http/Controllers/CheckoutController.php create mode 100644 app/Livewire/Order.php create mode 100644 lang/de/checkout.php create mode 100644 lang/de/order.php create mode 100644 lang/en/checkout.php create mode 100644 lang/en/order.php create mode 100644 resources/views/livewire/order.blade.php create mode 100644 tests/Feature/SelfServiceOrderTest.php diff --git a/VERSION b/VERSION index a76ee30..36165ba 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.29 +1.3.30 diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php new file mode 100644 index 0000000..60c2088 --- /dev/null +++ b/app/Http/Controllers/CheckoutController.php @@ -0,0 +1,144 @@ +validate([ + 'plan' => ['required', 'string', 'max:64'], + 'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY], + ]); + + $term = $data['term'] ?? Subscription::TERM_MONTHLY; + $user = $request->user(); + + // Somebody who already has a running contract is not buying a second + // one by accident: changing package is a plan change, which prorates + // and keeps their data where it is. A second checkout would build them + // a second, empty cloud and bill for both. + if ($this->hasLiveContract($user?->email)) { + return redirect()->route('billing')->with('status', __('checkout.already_customer')); + } + + if (! $stripe->isConfigured()) { + // Nothing to send them to. Better a plain sentence than a redirect + // into an error page on somebody else's domain. + Log::error('Checkout attempted while Stripe is not configured.'); + + return back()->withErrors(['plan' => __('checkout.unavailable')]); + } + + try { + $version = $catalogue->currentVersion($data['plan']); + $price = $version->prices->firstWhere('term', $term); + } catch (Throwable $e) { + // An unknown, withdrawn or overlapping plan. The catalogue throws + // by design; a shop must not guess which of two versions was meant. + Log::warning('Checkout for an unavailable plan', ['plan' => $data['plan'], 'exception' => $e]); + + return back()->withErrors(['plan' => __('checkout.plan_gone')]); + } + + // A price with no Stripe id was never synced. Sending the customer on + // would open a checkout for nothing. + if ($price === null || blank($price->stripe_price_id)) { + Log::error('Checkout for a plan that is not on sale in Stripe', [ + 'plan' => $data['plan'], + 'term' => $term, + ]); + + return back()->withErrors(['plan' => __('checkout.plan_gone')]); + } + + try { + $url = $stripe->createCheckoutSession( + priceId: (string) $price->stripe_price_id, + successUrl: route('checkout.done'), + cancelUrl: route('order'), + // Exactly the keys StripeWebhookController reads back. The + // VERSION goes along, so the contract is opened on what the + // customer was shown even if a new version is published while + // they are typing their card number. + metadata: [ + 'plan' => $version->family->key, + 'plan_version_id' => (string) $version->id, + 'datacenter' => app(HostCapacity::class)->preferredDatacenter(), + ], + customerEmail: $user?->email, + ); + } catch (Throwable $e) { + Log::error('Stripe would not open a checkout session', ['exception' => $e]); + + return back()->withErrors(['plan' => __('checkout.unavailable')]); + } + + return redirect()->away($url); + } + + /** + * Where Stripe sends them back to. + * + * Deliberately says "we are building it", not "you have paid": this page is + * a redirect target, and a redirect target is a URL anybody can type. What + * the customer actually sees underneath is the provisioning card on their + * dashboard, which is driven by a run that only the webhook can start. + */ + public function done(): RedirectResponse + { + return redirect()->route('dashboard')->with('status', __('checkout.thanks')); + } + + /** Does this address already have a contract that is being billed? */ + private function hasLiveContract(?string $email): bool + { + if ($email === null) { + return false; + } + + $customer = Customer::query()->where('email', $email)->first(); + + return $customer !== null && Subscription::query() + ->where('customer_id', $customer->id) + ->whereIn('status', ['active', 'past_due']) + ->exists(); + } +} diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index d2c6c6c..9af9d84 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Layout; use Livewire\Attributes\On; +use Livewire\Attributes\Url; use Livewire\Component; use Throwable; @@ -50,10 +51,34 @@ use Throwable; * and the same confirmed password, because it is the one place on this page * that can reach every credential the vault deliberately keeps write-only — * see EnvFileEditor for the safeguards that make that survivable. + * + * TABBED, like Admin\Settings and for the same reason: this was one narrow + * column of six cards, and an operator connecting DNS scrolled past Stripe to + * get to it. The split is by what a section is FOR — outside services we buy + * from, our own machines, and the raw file underneath — which is the same axis + * the page was rebuilt around when it stopped being split by storage mechanism. + * + * The open tab is in the query string, so a reload or a bookmark lands where + * the operator was. The .env tab in particular is worth linking to directly, + * and the unlock survives the switch because the confirmation is a session + * fact, not a property of a tab. */ #[Layout('layouts.admin')] class Integrations extends Component { + /** + * The tabs, in order. The list IS the schema: it validates the query + * string, builds the bar and decides what renders. + * + * `services` is what we buy from outside (Stripe, Hetzner DNS, the + * monitoring bridge), `platform` is how we reach our own machines (the + * WireGuard hub, the SSH identity), `env` is the file underneath all of it. + */ + public const TABS = ['services', 'platform', 'env']; + + #[Url(except: 'services')] + public string $tab = 'services'; + use ConfirmsPassword { forgetPasswordConfirmation as private lockAgain; } @@ -111,6 +136,17 @@ class Integrations extends Component { abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403); + // A tab name out of the URL is a string a stranger typed. + if (! in_array($this->tab, self::TABS, true)) { + $this->tab = self::TABS[0]; + } + + // The raw file is Owner-only. Landing on the first tab says that + // without a blank page; the editor itself checks again anyway. + if ($this->tab === 'env' && ! Gate::allows('secrets.manage')) { + $this->tab = self::TABS[0]; + } + if (Gate::allows('hosts.manage')) { $this->dnsZone = ProvisioningSettings::dnsZone(); $this->wgEndpoint = ProvisioningSettings::wgEndpoint(); @@ -377,6 +413,12 @@ class Integrations extends Component } return view('livewire.admin.integrations', [ + // Only the tabs this operator can open. A tab that renders nothing + // is worse than one that is not there. + 'tabs' => array_values(array_filter( + self::TABS, + fn (string $tab) => $tab !== 'env' || $canSecrets, + )), 'canSecrets' => $canSecrets, 'canInfra' => $canInfra, 'unlocked' => $unlocked, diff --git a/app/Livewire/Order.php b/app/Livewire/Order.php new file mode 100644 index 0000000..e70b46e --- /dev/null +++ b/app/Livewire/Order.php @@ -0,0 +1,97 @@ +where('email', $user->email)->first(); + + // Somebody already on a contract is not buying a second cloud from + // this page — changing package is a plan change, which prorates and + // keeps their data where it is. + $contract = $customer === null ? null : Subscription::query() + ->where('customer_id', $customer->id) + ->whereIn('status', ['active', 'past_due']) + ->first(); + + return view('livewire.order', [ + 'plans' => $this->plans(), + 'contract' => $contract, + 'vat' => rtrim(rtrim(number_format(CompanyProfile::taxRate(), 1, ',', '.'), '0'), ','), + 'setup' => CompanyProfile::setupFeeCents(), + ]); + } + + /** + * The packages on sale, in the words and figures the public sheet uses. + * + * @return array> + */ + private function plans(): array + { + try { + $sellable = app(PlanCatalogue::class)->sellable(); + } catch (Throwable) { + // The catalogue fails loudly by design — an overlap must not decide + // a customer's terms by row order. A portal page is not the place + // to argue about it: no list, one sentence, and the operator's + // console is already shouting. + return []; + } + + $capacity = app(HostCapacity::class); + $rate = CompanyProfile::taxRate(); + $plans = []; + + foreach ($sellable as $key => $plan) { + $net = (int) $plan['price_cents']; + + $plans[] = [ + 'key' => $key, + 'name' => (string) $plan['name'], + 'audience' => (string) ($plan['audience'] ?? ''), + 'gross' => (int) round($net * (1 + $rate / 100)), + 'net' => $net, + 'currency' => (string) $plan['currency'], + 'quota_gb' => (int) $plan['quota_gb'], + 'traffic_gb' => (int) $plan['traffic_gb'], + 'seats' => (int) $plan['seats'], + 'recommended' => (bool) ($plan['recommended'] ?? false), + 'delivery' => $capacity->deliveryFor((int) ($plan['disk_gb'] ?? 0)), + ]; + } + + return $plans; + } +} diff --git a/app/Services/Provisioning/HostCapacity.php b/app/Services/Provisioning/HostCapacity.php index 05cffa3..c35efd1 100644 --- a/app/Services/Provisioning/HostCapacity.php +++ b/app/Services/Provisioning/HostCapacity.php @@ -2,6 +2,7 @@ namespace App\Services\Provisioning; +use App\Models\Datacenter; use App\Models\Host; use App\Models\Order; use App\Models\ProvisioningRun; @@ -203,6 +204,32 @@ class HostCapacity ]; } + /** + * Where a new order should be placed when nobody has said. + * + * The customer is not asked which datacenter they want — one location is + * what is sold, and a dropdown of internal site codes would be a question + * nobody outside this building can answer. So the shop picks: the site + * whose roomiest host has the most left, which is where the order will + * actually roll out fastest. + * + * Falls back to the first configured datacenter, and then to the same + * literal the Stripe webhook has always defaulted to, so a checkout is + * never blocked by an estate that has not been built yet. + */ + public function preferredDatacenter(): string + { + $best = Host::query() + ->where('status', 'active') + ->get() + ->sortByDesc(fn (Host $host) => $host->availableGb()) + ->first(); + + return $best?->datacenter + ?? Datacenter::query()->orderBy('code')->value('code') + ?? 'fsn'; + } + /** * How soon a package of this size can be delivered. * diff --git a/lang/de/checkout.php b/lang/de/checkout.php new file mode 100644 index 0000000..0dc1ea7 --- /dev/null +++ b/lang/de/checkout.php @@ -0,0 +1,9 @@ + 'Danke! Sobald die Zahlung bestätigt ist, beginnt die Einrichtung Ihrer Cloud — den Fortschritt sehen Sie hier auf dieser Seite.', + 'unavailable' => 'Die Bezahlung ist gerade nicht möglich. Bitte versuchen Sie es in ein paar Minuten noch einmal.', + 'plan_gone' => 'Dieses Paket ist derzeit nicht buchbar. Bitte wählen Sie ein anderes.', + 'already_customer' => 'Sie haben bereits ein laufendes Paket. Hier wechseln Sie es, statt ein zweites zu kaufen.', +]; diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 0150739..fce3254 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -27,6 +27,7 @@ return [ 'no_instance_label' => 'Noch keine Instanz', 'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.', 'no_instance_cta' => 'Paket wählen', + 'no_instance_order' => 'Paket buchen', // Das Erst-Passwort steht hier — statt in der Mail — bis der Kunde es // bestätigt. Nach der Bestätigung ist es unwiderruflich gelöscht. diff --git a/lang/de/integrations.php b/lang/de/integrations.php index ce3254c..c352919 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -81,4 +81,11 @@ return [ 'env_invalid' => 'Zeile :line ist weder leer, noch ein Kommentar, noch SCHLÜSSEL=Wert. Nichts wurde gespeichert.', 'env_empty' => 'Eine leere Datei würde jede Einstellung löschen. Nichts wurde gespeichert.', -]; + // Die Reiter. Getrennt nach dem, wofür ein Abschnitt da ist — nicht danach, + // welcher Speichermechanismus einen Wert zufällig hält. + 'tab' => [ + 'services' => 'Dienste', + 'platform' => 'Plattform', + 'env' => 'Umgebung (.env)', + ], +]; \ No newline at end of file diff --git a/lang/de/order.php b/lang/de/order.php new file mode 100644 index 0000000..419e8da --- /dev/null +++ b/lang/de/order.php @@ -0,0 +1,26 @@ + 'Paket buchen', + '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', + 'per_month' => '/Monat', + 'incl_vat' => 'inkl. :rate % MwSt.', + 'net' => 'netto', + 'setup' => 'zzgl. Einrichtung einmalig :amount', + + 'storage' => 'Speicher', + 'traffic' => 'Datenvolumen', + 'seats' => 'Benutzer', + + 'buy' => 'Zahlungspflichtig buchen', + 'terms' => 'Monatlich kündbar. Die Zahlung läuft über Stripe; Kartendaten erreichen unsere Server nie. Es gelten unsere AGB und die Datenschutzerklärung. Datenübernahme aus einem bestehenden System ist nicht Teil des Pakets — schreiben Sie uns, wir sehen uns Ihre Ausgangslage an und machen ein Angebot.', + + 'already_customer' => 'Sie haben bereits ein laufendes Paket. Ein größeres Paket ist ein Wechsel, kein zweiter Kauf — dabei bleiben Ihre Daten, wo sie sind.', + 'to_billing' => 'Zum Paketwechsel', + 'no_catalogue' => 'Die Paketliste ist gerade nicht abrufbar. Bitte versuchen Sie es in ein paar Minuten noch einmal.', +]; diff --git a/lang/en/checkout.php b/lang/en/checkout.php new file mode 100644 index 0000000..122676e --- /dev/null +++ b/lang/en/checkout.php @@ -0,0 +1,9 @@ + 'Thank you! As soon as the payment is confirmed we start building your cloud — you can follow it on this page.', + 'unavailable' => 'Payment is not possible right now. Please try again in a few minutes.', + 'plan_gone' => 'That package cannot be booked at the moment. Please choose another one.', + 'already_customer' => 'You already have a running package. Change it here rather than buying a second one.', +]; diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 55185b4..f36fc69 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -27,6 +27,7 @@ return [ 'no_instance_label' => 'No instance yet', 'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.', 'no_instance_cta' => 'Choose a package', + 'no_instance_order' => 'Book a package', // The initial password lives here — not in the mail — until the customer // confirms it. Once confirmed it is deleted for good. diff --git a/lang/en/integrations.php b/lang/en/integrations.php index f80f0ba..dc19341 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -81,4 +81,11 @@ return [ 'env_invalid' => 'Line :line is neither blank, a comment, nor KEY=value. Nothing was saved.', 'env_empty' => 'An empty file would erase every setting. Nothing was saved.', -]; + // The tabs. Split by what a section is for — not by which storage + // mechanism happens to hold a given value. + 'tab' => [ + 'services' => 'Services', + 'platform' => 'Platform', + 'env' => 'Environment (.env)', + ], +]; \ No newline at end of file diff --git a/lang/en/order.php b/lang/en/order.php new file mode 100644 index 0000000..feabdcd --- /dev/null +++ b/lang/en/order.php @@ -0,0 +1,26 @@ + 'Book a package', + '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', + 'per_month' => '/month', + 'incl_vat' => 'incl. :rate % VAT', + 'net' => 'net', + 'setup' => 'plus one-off setup :amount', + + 'storage' => 'Storage', + 'traffic' => 'Traffic', + 'seats' => 'Users', + + 'buy' => 'Buy now', + 'terms' => 'Cancellable monthly. Payment runs through Stripe; card details never reach our servers. Our terms and privacy policy apply. Migrating data from an existing system is not part of the package — write to us, we will look at your situation and quote for it.', + + 'already_customer' => 'You already have a running package. Moving to a larger one is a change, not a second purchase — your data stays where it is.', + 'to_billing' => 'Change package', + 'no_catalogue' => 'The package list is not available right now. Please try again in a few minutes.', +]; diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index e2964a9..3e0a0df 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -41,9 +41,14 @@ Was wir tun, können Sie nachweisen: mit Protokoll, Prüfbericht und AV-Vertrag.

+ {{-- Buying is the primary action now. Every path on this page used to + end in "anfragen" — a mailto: link and an afternoon of somebody's + time per customer, for a product whose whole point is that the + machine does the work. Asking is still here, one step quieter, + because a question is a question and not a sale. --}}
- Preise ansehen - Erstgespräch vereinbaren + Jetzt buchen + Preise ansehen

Fixer Preis pro Firma · keine Abrechnung pro Kopf

@@ -493,6 +498,8 @@ Die Paketübersicht steht gerade nicht zur Verfügung. Wir nennen Ihnen die aktuellen Konditionen gerne direkt.

Preise anfragen + {{-- Still "anfragen" here, and only here: with no catalogue there is + nothing to put in a checkout. --}} @else @php @@ -580,8 +587,12 @@ @endif - - {{ $plan['name'] }} anfragen + {{-- To the booking page, not to an inbox. Signing in comes + first (the route is behind auth), which is what makes + the credentials for the finished cloud go to an address + somebody has confirmed. --}} + + {{ $plan['name'] }} buchen @endforeach @@ -763,24 +774,38 @@ says it in the operator's own words — "Updates, Sicherung und Ausfälle sind Ihr Problem, auch am Wochenende" — so the closing question asks exactly that. --}} -

Erstgespräch

+

Loslegen

Wer kümmert sich um Updates, Sicherung und Ausfälle?

+ {{-- The old version promised "danach wissen Sie … wie Ihre Daten sicher + umziehen". We do not know where a visitor's data is, what shape it + is in, or whether it can be moved at all — that is a promise made + before seeing the thing it is about. What is said here instead is + what we actually control: the cloud itself is built by the machine, + and a migration is looked at first and quoted afterwards. --}}

- Bei uns: wir — nachweislich, mit Protokoll. Ein unverbindliches Gespräch dauert 20 Minuten, - danach wissen Sie, welches Paket passt und wie Ihre Daten sicher umziehen. + Bei uns: wir — nachweislich, mit Protokoll. Konto anlegen, Paket wählen, bezahlen: + Ihre Cloud entsteht automatisch, ohne Termin und ohne Wartezeit auf eine Antwort.

+ {{-- Named, because it is the one thing this page cannot promise and the + one thing a visitor with an existing system will ask about. --}} +

+ Daten aus einem bestehenden System übernehmen? Das hängt davon ab, woher sie kommen — + schreiben Sie uns kurz, was Sie einsetzen. Wir sehen es uns an und sagen Ihnen ehrlich, + ob und wie es geht, bevor irgendetwas kostet. +

+
@foreach ([ 'Antwortzeit' => 'selber Werktag', diff --git a/resources/views/livewire/admin/integrations.blade.php b/resources/views/livewire/admin/integrations.blade.php index b037a4c..5c52776 100644 --- a/resources/views/livewire/admin/integrations.blade.php +++ b/resources/views/livewire/admin/integrations.blade.php @@ -1,7 +1,33 @@ -
+

{{ __('integrations.title') }}

-

{{ __('integrations.subtitle') }}

+

{{ __('integrations.subtitle') }}

+
+ + {{-- ── The tabs ────────────────────────────────────────────────────── + This was one narrow column of six cards: an operator connecting DNS + scrolled past Stripe to reach it, and the raw .env editor sat at the + bottom of everything. Split by what a section is FOR — services we buy + from outside, our own machines, the file underneath — which is the same + axis this page was rebuilt around when it stopped being split by + storage mechanism. + + The tab lives in the query string, so a reload or a bookmark lands + where the operator was. Unlocking survives a switch: the password + confirmation is a session fact, not a property of a tab. --}} +
+ @foreach ($tabs as $name) + + @endforeach
@if ($canSecrets && ! $usable) @@ -35,6 +61,11 @@
@endif + @if ($tab === 'services') + {{-- Two columns from lg up. Each card is a form of two or three fields — + stacked, they left half the window empty and pushed monitoring below + the fold. --}} +
{{-- Zahlungen (Stripe) — vault only, no plain setting belongs here. --}} @if ($canSecrets)
@@ -90,6 +121,11 @@
@endif +
+ @endif + + @if ($tab === 'platform') +
{{-- VPN/WireGuard — no vault entry: the private hub key stays in .env. --}} @if ($canInfra)
@@ -131,7 +167,13 @@
@endif - @if ($canInfra) +
+ @endif + + {{-- One button for both tabs' plain settings. Livewire keeps the values of + a hidden field, so saving from either tab writes everything the + operator has typed — which is what they mean by "speichern". --}} + @if ($canInfra && in_array($tab, ['services', 'platform'], true))
{{ __('integrations.save_settings') }} @@ -139,7 +181,8 @@
@endif - @if ($canSecrets && $unlocked && $check !== null) + {{-- Stripe's own answer, under the Stripe card it belongs to. --}} + @if ($tab === 'services' && $canSecrets && $unlocked && $check !== null)

{{ __('secrets.check_title') }}

@@ -187,7 +230,7 @@ point. Shares the lock above: secrets.manage AND a confirmed password, because it is the one place on this page that can reach every credential the vault otherwise keeps write-only. --}} - @if ($canSecrets) + @if ($tab === 'env' && $canSecrets)

{{ __('integrations.env_title') }}

diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index cd7eb39..ddeb12b 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -109,8 +109,12 @@

{{ __('dashboard.no_instance_label') }}

{{ __('dashboard.no_instance_body') }}

+ {{-- Booking first, because "you have not ordered yet" now has an + answer on this site: pick a package, pay, and the cloud builds + itself. Before self-service the only honest next step was to + write to somebody. --}}
- {{ __('dashboard.no_instance_cta') }} + {{ __('dashboard.no_instance_order') }} {{ __('dashboard.nav.support') }}
diff --git a/resources/views/livewire/order.blade.php b/resources/views/livewire/order.blade.php new file mode 100644 index 0000000..7810dd0 --- /dev/null +++ b/resources/views/livewire/order.blade.php @@ -0,0 +1,91 @@ +
+
+

{{ __('order.title') }}

+

{{ __('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. --}} + + {{ __('order.already_customer') }} + {{ __('order.to_billing') }} + + @elseif ($plans === []) + {{ __('order.no_catalogue') }} + @else + @error('plan'){{ $message }}@enderror + +
+ @foreach ($plans as $plan) +
$plan['recommended'], + 'border-line' => ! $plan['recommended'], + ])> +
+

{{ $plan['name'] }}

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

{{ $plan['audience'] }}

+ @endif + + {{-- Gross, 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') }} +

+

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

+ @if ($setup > 0) +

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

+ @endif + + + +
+ @foreach ([ + __('order.storage') => $plan['quota_gb'].' GB', + __('order.traffic') => $plan['traffic_gb'].' GB', + __('order.seats') => $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 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 +
+ +

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

+ @endif +
diff --git a/routes/web.php b/routes/web.php index 5173f55..51dedd8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -275,6 +275,22 @@ $portal = function () { Route::get('/settings', \App\Livewire\Settings::class)->name('settings'); Route::get('/support', Support::class)->name('support'); + // Buying a package. The page is a page like any other; the POST leaves + // for Stripe and comes back through the webhook, which is the only + // place a payment is believed. + // + // Behind `verified` with everything else, deliberately: an unconfirmed + // address is not a billing detail to be caught at checkout, it is an + // account that may not belong to the person holding it — and the + // credentials for the finished cloud are sent to that address. + Route::get('/order', \App\Livewire\Order::class)->name('order'); + Route::post('/checkout', [\App\Http\Controllers\CheckoutController::class, 'start']) + // Throttled: each attempt opens a session on somebody else's API. + ->middleware('throttle:10,1') + ->name('checkout.start'); + Route::get('/checkout/done', [\App\Http\Controllers\CheckoutController::class, 'done']) + ->name('checkout.done'); + // Return from an admin impersonation session (accessible as the // customer user). POST so Laravel's CSRF middleware protects the // identity change. diff --git a/tests/Feature/Admin/EnvRestartTest.php b/tests/Feature/Admin/EnvRestartTest.php index f62acc7..08e0782 100644 --- a/tests/Feature/Admin/EnvRestartTest.php +++ b/tests/Feature/Admin/EnvRestartTest.php @@ -51,12 +51,19 @@ function pendingRequestKind(): ?string return File::exists($path) ? (json_decode(File::get($path), true)['kind'] ?? null) : null; } +/** + * The page, unlocked, with the .env tab open. + * + * The tab is where the editor lives since the page was split up; the unlock + * itself is a session fact and survives switching between them. + */ function unlockedIntegrations($owner) { return Livewire::actingAs($owner, 'operator') ->test(Integrations::class) ->set('confirmablePassword', 'password') - ->call('confirmPassword'); + ->call('confirmPassword') + ->set('tab', 'env'); } beforeEach(function () { diff --git a/tests/Feature/Admin/IntegrationsPageTest.php b/tests/Feature/Admin/IntegrationsPageTest.php index c936d4a..e0a333f 100644 --- a/tests/Feature/Admin/IntegrationsPageTest.php +++ b/tests/Feature/Admin/IntegrationsPageTest.php @@ -78,26 +78,31 @@ it('shows the settings sections but not the vault sections to an operator with o }); it('shows the vault sections but not the settings sections to an operator with only secrets.manage', function () { + // The page is tabbed now, so each half is asserted on the tab it lives on. + // What is being checked has not changed: a capability decides what is + // rendered, never which tab happens to be open. $operator = operator('Support'); $operator->givePermissionTo('secrets.manage'); - Livewire::actingAs($operator, 'operator') - ->test(Integrations::class) - ->assertOk() + $page = Livewire::actingAs($operator, 'operator')->test(Integrations::class); + + $page->assertOk() ->assertViewHas('canInfra', false) ->assertViewHas('canSecrets', true) ->assertDontSee(__('integrations.dns_zone')) - ->assertSee(__('secrets.item.stripe_secret')) - ->assertSee(__('integrations.env_title')); + ->assertSee(__('secrets.item.stripe_secret')); + + $page->set('tab', 'env')->assertSee(__('integrations.env_title')); }); it('shows both halves to the Owner, who has both capabilities', function () { - Livewire::actingAs(operator('Owner'), 'operator') - ->test(Integrations::class) - ->assertOk() + $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Integrations::class); + + $page->assertOk() ->assertSee(__('integrations.dns_zone')) - ->assertSee(__('secrets.item.stripe_secret')) - ->assertSee(__('integrations.env_title')); + ->assertSee(__('secrets.item.stripe_secret')); + + $page->set('tab', 'env')->assertSee(__('integrations.env_title')); }); // ---- The retired pages redirect rather than 404 a bookmark. ---- @@ -402,7 +407,8 @@ it('marks which .env keys the vault currently overrides', function () { $page = Livewire::actingAs($owner, 'operator') ->test(Integrations::class) ->set('confirmablePassword', 'password') - ->call('confirmPassword'); + ->call('confirmPassword') + ->set('tab', 'env'); $page->assertSee('STRIPE_SECRET') ->assertSee(__('integrations.env_vault_active')) diff --git a/tests/Feature/Admin/SettingsTabsTest.php b/tests/Feature/Admin/SettingsTabsTest.php index 7450247..e876cc7 100644 --- a/tests/Feature/Admin/SettingsTabsTest.php +++ b/tests/Feature/Admin/SettingsTabsTest.php @@ -104,3 +104,32 @@ it('keeps the standalone enrolment route, because the gate sends people there', ->assertOk() ->assertSee(__('two_factor_setup.title')); }); + +it('tabs the integrations page the same way, and keeps the raw file to the Owner', function () { + // Same treatment, same reason: one narrow column of six cards had an + // operator scrolling past Stripe to reach DNS, with the raw .env editor at + // the bottom of everything. + $owner = operator('Owner'); + + Livewire::actingAs($owner, 'operator') + ->test(App\Livewire\Admin\Integrations::class) + ->assertSet('tab', 'services') + ->assertViewHas('tabs', fn (array $tabs) => in_array('env', $tabs, true)); + + foreach (App\Livewire\Admin\Integrations::TABS as $tab) { + expect(__('integrations.tab.'.$tab))->not->toBe('integrations.tab.'.$tab); + } + + // An Admin can reach the page for the DNS zone but not the file that holds + // every credential on the machine. + $admin = operator('Admin'); + + expect($admin->can('hosts.manage'))->toBeTrue() + ->and($admin->can('secrets.manage'))->toBeFalse(); + + Livewire::withQueryParams(['tab' => 'env']) + ->actingAs($admin, 'operator') + ->test(App\Livewire\Admin\Integrations::class) + ->assertSet('tab', 'services') + ->assertViewHas('tabs', fn (array $tabs) => ! in_array('env', $tabs, true)); +}); diff --git a/tests/Feature/SelfServiceOrderTest.php b/tests/Feature/SelfServiceOrderTest.php new file mode 100644 index 0000000..ccf2d40 --- /dev/null +++ b/tests/Feature/SelfServiceOrderTest.php @@ -0,0 +1,173 @@ +stripe = new FakeStripeClient; + app()->instance(StripeClient::class, $this->stripe); + + // Every sellable price needs a Stripe id, or there is nothing to check out + // against. The catalogue sync fills these in production. + DB::table('plan_prices')->update(['stripe_price_id' => 'price_test']); +}); + +/** A signed-in, verified portal user. */ +function buyer(): User +{ + return User::factory()->create(['email_verified_at' => now()]); +} + +it('opens a Stripe checkout for the package that was chosen', function () { + $this->actingAs(buyer()) + ->post(route('checkout.start'), ['plan' => 'start']) + ->assertRedirect(); + + expect($this->stripe->checkouts)->toHaveCount(1) + ->and($this->stripe->checkouts[0]['price'])->toBe('price_test'); +}); + +it('carries exactly the metadata the webhook reads back', function () { + // plan, plan_version_id and datacenter are the contract between the + // checkout and StripeWebhookController. A missing key does not fail — it + // silently builds the wrong package. + $this->actingAs(buyer())->post(route('checkout.start'), ['plan' => 'team']); + + $meta = $this->stripe->checkouts[0]['metadata']; + + expect($meta)->toHaveKeys(['plan', 'plan_version_id', 'datacenter']) + ->and($meta['plan'])->toBe('team') + ->and($meta['plan_version_id'])->not->toBeEmpty(); +}); + +it('sends the customer to Stripe with the address their credentials will go to', function () { + $user = buyer(); + + $this->actingAs($user)->post(route('checkout.start'), ['plan' => 'start']); + + expect($this->stripe->checkouts[0]['email'])->toBe($user->email); +}); + +it('never lets a request name its own price', function () { + // The plan key comes from a form, and a form field is a string somebody can + // retype. What is charged is the catalogue's figure for the version on sale + // — nothing in the request may decide it. + $this->actingAs(buyer())->post(route('checkout.start'), [ + 'plan' => 'start', + 'amount_cents' => 1, + 'price' => 'price_of_my_choosing', + ]); + + expect($this->stripe->checkouts[0]['price'])->toBe('price_test'); +}); + +it('refuses a plan that is not on sale', function () { + $this->actingAs(buyer()) + ->post(route('checkout.start'), ['plan' => 'does-not-exist']) + ->assertSessionHasErrors('plan'); + + expect($this->stripe->checkouts)->toBeEmpty(); +}); + +it('refuses a plan that was never synced to Stripe', function () { + // A price with no Stripe id would open a checkout for nothing. + PlanPrice::query()->update(['stripe_price_id' => null]); + + $this->actingAs(buyer()) + ->post(route('checkout.start'), ['plan' => 'start']) + ->assertSessionHasErrors('plan'); + + expect($this->stripe->checkouts)->toBeEmpty(); +}); + +it('says so rather than redirecting into a broken checkout when Stripe is not connected', function () { + $this->stripe->configured = false; + + $this->actingAs(buyer()) + ->post(route('checkout.start'), ['plan' => 'start']) + ->assertSessionHasErrors('plan'); + + expect($this->stripe->checkouts)->toBeEmpty(); +}); + +it('sends an existing customer to the plan change instead of selling a second cloud', function () { + // A second checkout would build a second, empty cloud and bill for both. + // Moving up is a plan change: it prorates and the data stays where it is. + $user = buyer(); + $customer = Customer::factory()->create(['email' => $user->email]); + Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']); + + $this->actingAs($user) + ->post(route('checkout.start'), ['plan' => 'team']) + ->assertRedirect(route('billing')); + + expect($this->stripe->checkouts)->toBeEmpty(); +}); + +it('lets nobody buy anything without signing in first', function () { + // The credentials for the finished cloud are mailed to the account's + // address, so the account has to exist and be confirmed before the money + // moves — not afterwards. + $this->post(route('checkout.start'), ['plan' => 'start'])->assertRedirect(route('login')); + $this->get(route('order'))->assertRedirect(route('login')); + + expect($this->stripe->checkouts)->toBeEmpty(); +}); + +it('does not take an unverified address through checkout', function () { + $unverified = User::factory()->create(['email_verified_at' => null]); + + $this->actingAs($unverified)->post(route('checkout.start'), ['plan' => 'start']) + ->assertRedirect(route('verification.notice')); + + expect($this->stripe->checkouts)->toBeEmpty(); +}); + +it('shows the packages with the same gross prices the public sheet quotes', function () { + // A customer quoted 58,80 € out there must not meet 49 € on the page with + // the button on it. + $this->actingAs(buyer()) + ->get(route('order')) + ->assertOk() + ->assertSee('58,80') + ->assertSee(__('order.buy')); +}); + +it('promises delivery on the booking page from the estate, like everywhere else', function () { + $this->actingAs(buyer()) + ->get(route('order')) + ->assertOk() + // No host is onboarded in this test, so nothing can be immediate. + ->assertSee(__('delivery.scheduled')) + ->assertDontSee(__('delivery.immediate')); +}); + +it('points the website buttons at the booking page rather than an inbox', function () { + $content = $this->get('/')->assertOk()->getContent(); + + expect($content)->toContain(route('order')) + // The old shape: every card ending in a jump to a mailto: block. + ->and($content)->not->toContain('anfragen'); +}); + +it('does not promise a migration it has not looked at', function () { + // We do not know where a visitor's data is, what shape it is in, or whether + // it can be moved at all. "danach wissen Sie … wie Ihre Daten sicher + // umziehen" was a promise made before seeing the thing it is about. + expect($this->get('/')->getContent()) + ->not->toContain('wie Ihre Daten sicher umziehen'); +});