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