Let the customer buy it themselves, and tab the integrations page
tests / pest (push) Failing after 21m20s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feature/betriebsmodus v1.3.30
nexxo 2026-07-29 19:42:28 +02:00
parent a5ee7bdb67
commit f85f57152e
22 changed files with 822 additions and 32 deletions

View File

@ -1 +1 @@
1.3.29
1.3.30

View File

@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Provisioning\HostCapacity;
use App\Services\Stripe\StripeClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Buying a package, without anyone having to write an email about it.
*
* The site used to end every path in "anfragen": a mailto: link and a promise
* to answer the same working day. That is a person's afternoon per customer,
* for a product whose entire point is that the machine does the work and it
* scales exactly as far as the operator's inbox does.
*
* So: a signed-in customer picks a package, pays, and provisioning starts by
* itself. Where no host has room the order is parked rather than refused (see
* ReserveResources) that is the ONE case that still needs a human, and it
* needs them to buy a server, not to answer a mail.
*
* This class does not take money and does not create a contract. It opens a
* Stripe hosted checkout and gets out of the way; the purchase becomes real
* when Stripe says so, on the webhook, which is the only place that may believe
* a payment happened. A "thank you" page proves nothing the customer can open
* it by typing the URL.
*/
class CheckoutController extends Controller
{
/**
* Send the customer to Stripe for one package.
*
* Everything here is re-derived from the catalogue: the plan key arrives
* from a form, and a form field is a string somebody can retype. The price
* charged is the one the catalogue holds for the version on sale right now,
* never a figure carried in the request.
*/
public function start(Request $request, StripeClient $stripe, PlanCatalogue $catalogue): RedirectResponse
{
$data = $request->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();
}
}

View File

@ -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,

97
app/Livewire/Order.php Normal file
View File

@ -0,0 +1,97 @@
<?php
namespace App\Livewire;
use App\Models\Customer;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Provisioning\HostCapacity;
use App\Support\CompanyProfile;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Choose a package and pay for it, without anybody writing an email.
*
* The website used to end in "anfragen" a mailto: link and a promise to reply
* 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.
*
* The prices here are the catalogue's, gross, exactly as the public sheet shows
* them: a customer who was quoted 58,80 must not meet 49 on the page where
* they press the button, nor the other way round. The delivery promise is the
* estate's own answer (HostCapacity), so nobody is told "sofort" while a server
* still has to be bought.
*
* Nothing is charged here. The button opens a Stripe hosted checkout; the
* purchase becomes real on the webhook, which is the only place allowed to
* believe a payment happened.
*/
#[Layout('layouts.portal')]
class Order extends Component
{
public function render()
{
$user = Auth::user();
$customer = $user === null ? null : Customer::query()->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<int, array<string, mixed>>
*/
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;
}
}

View File

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

9
lang/de/checkout.php Normal file
View File

@ -0,0 +1,9 @@
<?php
// Was auf dem Weg zur Zahlung schiefgehen kann, in Sätzen für den Kunden.
return [
'thanks' => '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.',
];

View File

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

View File

@ -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)',
],
];

26
lang/de/order.php Normal file
View File

@ -0,0 +1,26 @@
<?php
// Selbst buchen statt anfragen. Die Preise stehen hier wie auf der Website —
// brutto —, weil ein Kunde, dem draußen 58,80 € genannt wurde, auf der Seite
// mit dem Knopf nicht plötzlich 49 € lesen darf.
return [
'title' => '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.',
];

9
lang/en/checkout.php Normal file
View File

@ -0,0 +1,9 @@
<?php
// What can go wrong on the way to paying, in sentences for the customer.
return [
'thanks' => '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.',
];

View File

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

View File

@ -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)',
],
];

26
lang/en/order.php Normal file
View File

@ -0,0 +1,26 @@
<?php
// Buy it yourself instead of asking for a quote. Prices are gross here, exactly
// as on the public sheet: somebody quoted 58,80 € out there must not read
// 49 € on the page with the button on it.
return [
'title' => '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.',
];

View File

@ -41,9 +41,14 @@
Was wir tun, können Sie nachweisen: mit Protokoll, Prüfbericht und AV-Vertrag.
</p>
{{-- 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. --}}
<div class="mt-9 flex flex-wrap justify-center gap-3">
<x-ui.button href="#pricing" variant="ink" size="lg">Preise ansehen</x-ui.button>
<x-ui.button href="#contact" variant="secondary" size="lg">Erstgespräch vereinbaren</x-ui.button>
<x-ui.button href="{{ route('order') }}" variant="ink" size="lg">Jetzt buchen</x-ui.button>
<x-ui.button href="#pricing" variant="secondary" size="lg">Preise ansehen</x-ui.button>
</div>
<p class="mt-4 text-sm text-muted">Fixer Preis pro Firma · keine Abrechnung pro Kopf</p>
</div>
@ -493,6 +498,8 @@
Die Paketübersicht steht gerade nicht zur Verfügung. Wir nennen Ihnen die aktuellen Konditionen gerne direkt.
</p>
<x-ui.button href="#contact" variant="ink" size="lg" class="mt-7">Preise anfragen</x-ui.button>
{{-- Still "anfragen" here, and only here: with no catalogue there is
nothing to put in a checkout. --}}
</div>
@else
@php
@ -580,8 +587,12 @@
@endif
</ul>
<x-ui.button href="#contact" :variant="$plan['recommended'] ? 'ink' : 'secondary'" class="mt-7 w-full">
{{ $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. --}}
<x-ui.button href="{{ route('order') }}" :variant="$plan['recommended'] ? 'ink' : 'secondary'" class="mt-7 w-full">
{{ $plan['name'] }} buchen
</x-ui.button>
</div>
@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. --}}
<p class="lbl !text-white/70">Erstgespräch</p>
<p class="lbl !text-white/70">Loslegen</p>
<h2 class="mx-auto mt-4 max-w-[22ch] text-[clamp(2rem,4.4vw,3.4rem)] font-bold leading-[1.06] tracking-[-0.035em] text-white">
Wer kümmert sich um Updates, Sicherung und Ausfälle?
</h2>
{{-- 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. --}}
<p class="mx-auto mt-6 max-w-[54ch] text-md leading-relaxed text-white/85">
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.
</p>
<div class="mt-9 flex flex-wrap justify-center gap-3">
<a href="mailto:office&#64;clupilot.com" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] bg-white px-[22px] text-[15px] font-semibold leading-none text-accent-active transition hover:bg-accent-subtle">
<x-ui.icon name="mail" class="size-4" />Erstgespräch anfragen
<a href="{{ route('order') }}" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] bg-white px-[22px] text-[15px] font-semibold leading-none text-accent-active transition hover:bg-accent-subtle">
Jetzt buchen
</a>
<a href="#pricing" class="inline-flex min-h-[46px] items-center justify-center rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
Preise ansehen
<a href="mailto:office&#64;clupilot.com" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
<x-ui.icon name="mail" class="size-4" />Frage stellen
</a>
</div>
{{-- Named, because it is the one thing this page cannot promise and the
one thing a visitor with an existing system will ask about. --}}
<p class="mx-auto mt-6 max-w-[60ch] text-sm leading-relaxed text-white/70">
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.
</p>
<dl class="mx-auto mt-14 grid max-w-[760px] grid-cols-2 gap-y-8 border-t border-white/20 pt-10 sm:grid-cols-4">
@foreach ([
'Antwortzeit' => 'selber Werktag',

View File

@ -1,7 +1,33 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="mx-auto max-w-[1120px] space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('integrations.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('integrations.subtitle') }}</p>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('integrations.subtitle') }}</p>
</div>
{{-- ── 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. --}}
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
@foreach ($tabs as $name)
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
@class([
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
'border-accent-active text-ink' => $tab === $name,
'border-transparent text-muted hover:text-ink' => $tab !== $name,
])>
<x-ui.icon :name="['services' => 'plug', 'platform' => 'server', 'env' => 'file-text'][$name] ?? 'settings'"
class="size-4" />{{ __('integrations.tab.'.$name) }}
</button>
@endforeach
</div>
@if ($canSecrets && ! $usable)
@ -35,6 +61,11 @@
</div>
@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. --}}
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
{{-- Zahlungen (Stripe) vault only, no plain setting belongs here. --}}
@if ($canSecrets)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
@ -90,6 +121,11 @@
</div>
@endif
</div>
@endif
@if ($tab === 'platform')
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
{{-- VPN/WireGuard no vault entry: the private hub key stays in .env. --}}
@if ($canInfra)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
@ -131,7 +167,13 @@
</div>
@endif
@if ($canInfra)
</div>
@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))
<div class="flex justify-end">
<x-ui.button variant="primary" wire:click="saveInfra" wire:loading.attr="disabled" wire:target="saveInfra">
{{ __('integrations.save_settings') }}
@ -139,7 +181,8 @@
</div>
@endif
@if ($canSecrets && $unlocked && $check !== null)
{{-- Stripe's own answer, under the Stripe card it belongs to. --}}
@if ($tab === 'services' && $canSecrets && $unlocked && $check !== null)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('secrets.check_title') }}</h2>
@ -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)
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('integrations.env_title') }}</h2>

View File

@ -109,8 +109,12 @@
<section class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise">
<h2 class="text-lg font-semibold text-ink">{{ __('dashboard.no_instance_label') }}</h2>
<p class="mt-2 max-w-prose text-md text-muted">{{ __('dashboard.no_instance_body') }}</p>
{{-- 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. --}}
<div class="mt-6 flex flex-wrap gap-2.5">
<x-ui.button variant="ink" :href="route('billing')" wire:navigate>{{ __('dashboard.no_instance_cta') }}</x-ui.button>
<x-ui.button variant="ink" :href="route('order')" wire:navigate>{{ __('dashboard.no_instance_order') }}</x-ui.button>
<x-ui.button variant="secondary" :href="route('support')" wire:navigate>{{ __('dashboard.nav.support') }}</x-ui.button>
</div>
</section>

View File

@ -0,0 +1,91 @@
<div class="mx-auto max-w-[1120px] space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('order.title') }}</h1>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('order.subtitle') }}</p>
</div>
@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. --}}
<x-ui.alert variant="info">
{{ __('order.already_customer') }}
<a href="{{ route('billing') }}" class="font-semibold underline">{{ __('order.to_billing') }}</a>
</x-ui.alert>
@elseif ($plans === [])
<x-ui.alert variant="warning">{{ __('order.no_catalogue') }}</x-ui.alert>
@else
@error('plan')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($plans as $plan)
<div @class([
'flex flex-col rounded-lg border bg-surface p-6 shadow-xs animate-rise',
'border-ink shadow-md' => $plan['recommended'],
'border-line' => ! $plan['recommended'],
])>
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-ink">{{ $plan['name'] }}</h2>
@if ($plan['recommended'])
<span class="rounded-pill bg-accent-active px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-on-accent">{{ __('order.recommended') }}</span>
@endif
</div>
@if ($plan['audience'])
<p class="mt-1 text-xs text-muted">{{ $plan['audience'] }}</p>
@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. --}}
<p class="mt-5 flex items-baseline gap-1.5">
<span class="font-mono text-2xl font-semibold tabular-nums text-ink">{{ Number::currency($plan['gross'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}</span>
<span class="text-xs text-muted">{{ __('order.per_month') }}</span>
</p>
<p class="mt-1 text-xs text-muted">
{{ __('order.incl_vat', ['rate' => $vat]) }} ·
{{ __('order.net') }} {{ Number::currency($plan['net'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}
</p>
@if ($setup > 0)
<p class="mt-1 text-xs text-muted">
{{ __('order.setup', ['amount' => Number::currency($setup / 100, in: $plan['currency'], locale: app()->getLocale())]) }}
</p>
@endif
<x-ui.delivery :state="$plan['delivery']" class="mt-4" />
<dl class="mt-5 flex-1 space-y-2 border-t border-line pt-4 text-sm">
@foreach ([
__('order.storage') => $plan['quota_gb'].' GB',
__('order.traffic') => $plan['traffic_gb'].' GB',
__('order.seats') => $plan['seats'],
] as $term => $value)
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ $term }}</dt>
<dd class="font-mono tabular-nums text-ink">{{ $value }}</dd>
</div>
@endforeach
</dl>
{{-- 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. --}}
<form method="POST" action="{{ route('checkout.start') }}" class="mt-6">
@csrf
<input type="hidden" name="plan" value="{{ $plan['key'] }}" />
{{-- The wording is the legal one and not a nicety:
the button that concludes a paid contract has to
say that it costs money. --}}
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full">
{{ __('order.buy') }}
</x-ui.button>
</form>
</div>
@endforeach
</div>
<p class="max-w-[80ch] text-xs leading-relaxed text-muted">
{{ __('order.terms') }}
</p>
@endif
</div>

View File

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

View File

@ -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 () {

View File

@ -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'))

View File

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

View File

@ -0,0 +1,173 @@
<?php
use App\Models\Customer;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\DB;
/**
* A customer buys a package themselves.
*
* Every path on the site 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. Now: sign in, pick, pay, and provisioning
* starts on the webhook. The one case that still needs a person is no capacity,
* and it needs them to buy a server, not to answer a mail.
*/
beforeEach(function () {
$this->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</x-ui.button>');
});
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');
});