Let the customer buy it themselves, and tab the integrations page
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
parent
a5ee7bdb67
commit
f85f57152e
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
],
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
],
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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@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@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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue