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. --}}
@@ -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.
+
+
+ {{-- ── 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. --}}
+
{{-- 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))
@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)
+ {{-- 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. --}}
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
+
+
+
+ {{-- A plain form post, not a Livewire action: the response
+ is a redirect to another domain, and Livewire's
+ XHR-and-swap cannot leave the site. --}}
+
+
+ @endforeach
+
+
+
+ {{ __('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');
+});