Refuse the sale instead of reaching Stripe without a key

Task 5: Billing::purchase() now checks SecretVault for a stripe.secret before
touching anything else — no customer, no plan lookup, no Order row — and the
customer reads a plain sentence instead of a 500 page. HttpStripeClient's own
secret() throws the same StripeNotConfigured exception rather than sending a
request with an empty bearer token and letting Stripe's 401 stand in for the
real cause.

isConfigured() was fixed alongside it: it used to call secret() too, which
would have turned every "is Stripe set up?" check (the checkout controller,
the catalogue sync commands) into an uncaught exception the moment a key went
missing — the opposite of what this task is for. It now reads the vault
directly.

The new guard is unconditional, including for the cart-only purchase types
(storage, addon, plan changes) that never call Stripe themselves — a cart
order nobody can ever pay for is pointless to create. That broke every
existing Billing/Cart/Downgrade test that calls purchase() without a stored
key; each affected fixture now deposits one via the new withStripeSecret()
Pest helper. A few of those tests (CustomDomainAccessTest, DowngradeTest) were
passing already but for the wrong reason — the new guard, not the check they
were named for — since both only assert an order was NOT created; they get
the same fixture fix so they still prove what they claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 11:58:01 +02:00
parent ac44717d1b
commit 32a8bd9a88
16 changed files with 200 additions and 21 deletions

View File

@ -0,0 +1,20 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Der aktive Betriebsmodus hat keinen Stripe-Schlüssel.
*
* Eine eigene Klasse, damit Aufrufer und Tests genau diesen Fall greifen können
* statt „irgendetwas ging schief" — und damit die Kasse ihn in einen Satz
* übersetzen kann, den ein Kunde lesen kann.
*/
class StripeNotConfigured extends RuntimeException
{
public static function forMode(string $mode): self
{
return new self("No Stripe secret is stored for the [{$mode}] operating mode.");
}
}

View File

@ -17,6 +17,7 @@ use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\StorageAllowance;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\DiskUsageProbe;
use App\Services\Secrets\SecretVault;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
@ -60,6 +61,15 @@ class Billing extends Component
*/
public function purchase(string $type, ?string $key = null, int $quantity = 1): void
{
// Vor allem anderen: ohne Schlüssel des aktiven Modus entsteht kein
// Auftrag. Nach der Zahlung wäre es zu spät, und ein halb angelegter
// Auftrag ohne Stripe-Sitzung ist genau die Leiche, die niemand findet.
if (blank(app(SecretVault::class)->get('stripe.secret'))) {
$this->addError('purchase', __('billing.stripe_not_configured'));
return;
}
$customer = $this->requireCustomer();
if ($customer === null) {
return;

View File

@ -2,7 +2,9 @@
namespace App\Services\Stripe;
use App\Exceptions\StripeNotConfigured;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
@ -16,7 +18,12 @@ class HttpStripeClient implements StripeClient
{
public function isConfigured(): bool
{
return filled($this->secret());
// Reads the vault directly rather than through secret(): that method
// now THROWS when the key is missing (see below), and every caller
// here — the checkout controller, the catalogue sync commands — asks
// this question to decide whether to proceed gracefully. Routing it
// through secret() would turn "is it configured?" into an exception.
return filled(app(SecretVault::class)->get('stripe.secret'));
}
public function createCheckoutSession(
@ -336,13 +343,9 @@ class HttpStripeClient implements StripeClient
private function request(?string $idempotencyKey = null): PendingRequest
{
$secret = (string) $this->secret();
if (blank($secret)) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
$request = Http::withToken($secret)->acceptJson()->timeout(20);
// secret() itself throws when the key is missing, so there is nothing
// blank left to check here — see its docblock.
$request = Http::withToken($this->secret())->acceptJson()->timeout(20);
// Stripe replays the original response for a repeated key instead of
// creating a second object. That covers the gap this cannot close on
@ -355,16 +358,25 @@ class HttpStripeClient implements StripeClient
}
/**
* The key in force: the one stored in the console if there is one, else the
* environment.
* The key in force: the one stored in the console for the active operating
* mode. Throws rather than returning blank see StripeNotConfigured.
*
* Resolved HERE, at the point of use, rather than overlaid onto config at
* boot an overlay would add a query to every request including the public
* site, and a queue worker would keep whatever was true when it started.
*/
private function secret(): ?string
private function secret(): string
{
return app(SecretVault::class)->get('stripe.secret');
$secret = app(SecretVault::class)->get('stripe.secret');
// Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen
// und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel
// zuordnet. Hier abzubrechen sagt, was fehlt.
if (blank($secret)) {
throw StripeNotConfigured::forMode(OperatingMode::current()->value);
}
return $secret;
}
private function url(string $path): string

View File

@ -126,6 +126,10 @@ return [
'pending_change_note' => 'Wechsel auf :plan zum :date vorgemerkt. Bis dahin ändert sich nichts an Ihrem Paket.',
'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.',
'mock_note' => 'Zahlung & Bereitstellung folgen nach Anbindung des Zahlungsanbieters.',
// Bewusst ohne Grund: weder "Stripe-Schlüssel fehlt" noch der aktive
// Betriebsmodus gehören dem Kunden mitgeteilt — das ist eine
// Betreiberangelegenheit und steht auf der Bereitschaftsseite.
'stripe_not_configured' => 'Die Bezahlung ist derzeit nicht eingerichtet. Bitte versuchen Sie es später erneut.',
'plan' => [
'start' => 'Start',

View File

@ -126,6 +126,10 @@ return [
'pending_change_note' => 'A move to :plan is booked for :date. Nothing about your package changes until then.',
'purchased' => 'Purchase noted — we activate it after payment.',
'mock_note' => 'Payment & fulfillment follow once the payment provider is connected.',
// Deliberately without a reason: neither "the Stripe key is missing" nor
// which operating mode is active is the customer's to know — that is an
// operator matter and belongs on the readiness page instead.
'stripe_not_configured' => 'Payment is not set up at the moment. Please try again later.',
'plan' => [
'start' => 'Start',

View File

@ -1,4 +1,4 @@
<?php use Illuminate\Support\Number; ?>
<?php ?>
<div class="space-y-6" x-data="{ msgs: @js(['purchased' => __('billing.purchased')]) }">
@php $loc = app()->getLocale(); $eur = fn ($c) => Number::currency($c / 100, in: 'EUR', locale: $loc); @endphp
@ -7,6 +7,13 @@
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
</div>
{{-- purchase() refuses before touching anything when the active mode has
no Stripe key a sentence here, not a 500 page, and not a cart entry
nobody can ever pay for. --}}
@error('purchase')
<x-ui.alert variant="danger">{{ $message }}</x-ui.alert>
@enderror
{{-- Current plan --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-line bg-surface-2 px-6 py-4">

View File

@ -51,6 +51,10 @@ function entitlementShopper(string $plan = 'team'): array
$order->subscription->update(['instance_id' => $instance->id]);
// The tests that buy through the shop call Billing::purchase(), which
// since Task 5 refuses outright without a Stripe key for the active mode.
withStripeSecret();
return [$customer, $user, $order->subscription->fresh()];
}

View File

@ -46,6 +46,12 @@ function domainCustomer(string $plan, array $instanceAttributes = []): array
'instance_id' => $instance->id,
]);
// One test below calls Billing::purchase(), which since Task 5 refuses
// outright without a Stripe key for the active mode. Without this, that
// test would see the same "no order" result for the wrong reason — the
// Stripe guard, not the plan-restriction it names — and prove nothing.
withStripeSecret();
return [$customer, $user, $instance, $subscription];
}

View File

@ -33,6 +33,13 @@ use Livewire\Livewire;
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
// Every test here calls Billing::purchase() to place the order it then
// marks paid by hand. Since Task 5, that call refuses outright without a
// Stripe key for the active mode — binding the fake StripeClient above
// does not help, because the guard reads the vault directly rather than
// going through the StripeClient interface.
withStripeSecret();
});
/** A customer on a live contract, with a machine. */

View File

@ -45,6 +45,11 @@ function pendingChangeCustomer(string $plan = 'business'): array
// due date arrives against this id, so there has to be one.
$subscription->update(['instance_id' => $instance->id, 'stripe_subscription_id' => 'sub_'.$customer->id]);
// These tests call Billing::purchase() to book the downgrade/upgrade,
// which since Task 5 refuses outright without a Stripe key for the
// active mode.
withStripeSecret();
return [$customer, $user, $subscription->fresh(), $instance];
}

View File

@ -65,6 +65,11 @@ function storageFixture(string $plan = 'team', array $instanceAttributes = []):
$subscription->update(['instance_id' => $instance->id]);
// Some of these tests book storage packs through Billing::purchase(),
// which since Task 5 refuses outright without a Stripe key for the
// active mode.
withStripeSecret();
return [
'host' => $host,
'order' => $order,

View File

@ -14,6 +14,10 @@ function billingSetup(string $plan = 'start'): array
$order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => $plan]);
Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => $plan, 'status' => 'active']);
// Several of these tests call Billing::purchase(), which since Task 5
// refuses outright without a Stripe key for the active mode.
withStripeSecret();
return compact('user', 'customer');
}

View File

@ -3,14 +3,21 @@
use App\Livewire\Billing;
use App\Livewire\ConfirmRemoveOrder;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
function cartCustomer(): array
{
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
// Some of these tests call Billing::purchase(), which since Task 5
// refuses outright without a Stripe key for the active mode.
withStripeSecret();
return [$customer, $user];
}
@ -73,7 +80,7 @@ it('refuses to remove an order that is already paid', function () {
it('keeps at most one plan change in the cart', function () {
[$customer, $user] = cartCustomer();
App\Models\Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']);
Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']);
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'upgrade', 'business');
@ -88,7 +95,7 @@ it('keeps at most one plan change in the cart', function () {
it('still lets add-ons stack', function () {
[$customer, $user] = cartCustomer();
App\Models\Instance::factory()->for($customer)->create(['plan' => 'team', 'status' => 'active']);
Instance::factory()->for($customer)->create(['plan' => 'team', 'status' => 'active']);
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'storage');
@ -127,7 +134,7 @@ it('separates what recurs from a one-off top-up', function () {
// Formatted through the same helper the view uses, or the non-breaking
// space in the currency makes this a test about whitespace.
$expected = __('billing.cart.recurring_note', [
'amount' => Illuminate\Support\Number::currency(12.00, in: 'EUR', locale: 'de'),
'amount' => Number::currency(12.00, in: 'EUR', locale: 'de'),
]);
Livewire\Livewire::actingAs($user)->test(Billing::class)->assertSee($expected);
@ -143,7 +150,7 @@ it('keeps the recurring figure consistent with the total to the cent', function
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 3, 'status' => 'pending']);
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 3, 'status' => 'pending']);
$expected = Illuminate\Support\Number::currency(0.08, in: 'EUR', locale: 'de');
$expected = Number::currency(0.08, in: 'EUR', locale: 'de');
Livewire\Livewire::actingAs($user)->test(Billing::class)
->assertSee($expected) // total gross
@ -188,7 +195,7 @@ it('accepts a verified value stored in display form', function () {
config()->set('provisioning.tax.rate_percent', 20);
config()->set('provisioning.tax.seller_country', 'AT');
[$customer, ] = cartCustomer();
[$customer] = cartCustomer();
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
// A verifier may hand back the number the way it prints it; spaces are
@ -200,7 +207,7 @@ it('accepts a verified value stored in display form', function () {
]);
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue()
->and($customer->fresh()->vat_id_verified_at)->toBeInstanceOf(Illuminate\Support\Carbon::class)
->and($customer->fresh()->vat_id_verified_at)->toBeInstanceOf(Carbon::class)
->and(Order::query()->first()->fresh()->grossCents())->toBe(1000);
});
@ -208,7 +215,7 @@ it('will not let a typed-in VAT ID zero the tax', function () {
config()->set('provisioning.tax.rate_percent', 20);
config()->set('provisioning.tax.seller_country', 'AT');
[$customer, ] = cartCustomer();
[$customer] = cartCustomer();
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
// Plausible-looking but unverified: still domestic VAT.

View File

@ -0,0 +1,62 @@
<?php
use App\Exceptions\StripeNotConfigured;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Order;
use App\Models\User;
use App\Services\Stripe\HttpStripeClient;
use App\Support\OperatingMode;
use Livewire\Livewire;
/**
* Fehlt der Schlüssel des aktiven Modus, entsteht kein Auftrag und der Kunde
* liest einen Satz statt einer 500er-Seite.
*
* Die Ausnahme ist eine KONKRETE Klasse. `toThrow(Throwable::class)` würde hier
* jeden beliebigen Fehler durchgehen lassen, auch einen Tippfehler im Test.
*/
it('refuses to reach stripe without a key for the active mode', function () {
OperatingMode::set(OperatingMode::Test);
expect(fn () => app(HttpStripeClient::class)->createPrice('prod_x', 100, 'EUR', 'month'))
->toThrow(StripeNotConfigured::class);
});
it('does not create an order when the key is missing', function () {
OperatingMode::set(OperatingMode::Test);
$customer = Customer::factory()->create();
// A portal user is matched to its customer by email, not by a foreign
// key — see the existing Billing feature tests (e.g. StorageAllowanceTest).
$user = User::factory()->create(['email' => $customer->email]);
// 'team' is a real, sellable plan (seeded by the plan-catalogue migration)
// that ranks above the customer's implicit 'start' plan — a genuine
// upgrade the purchase() guards would otherwise accept. A made-up key
// would be refused by the existing plan-validity check regardless of
// whether the Stripe guard works at all, and would prove nothing.
//
// assertHasErrors(), not assertHasNoErrors(): the guard reports through
// addError('purchase', …) — the same page-blocking pattern order.blade.php
// already uses for "cannot proceed" — and Livewire's test harness folds
// any non-empty error bag into the call's result. Asserting no errors
// here would contradict the very behaviour this test exists to prove.
Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'upgrade', 'team')
->assertHasErrors('purchase');
// Der Punkt: nach der Zahlung wäre es zu spät. Es darf gar nichts entstehen.
expect(Order::count())->toBe(0);
});
it('says why instead of failing silently', function () {
OperatingMode::set(OperatingMode::Test);
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'upgrade', 'team')
->assertSee(__('billing.stripe_not_configured'));
});

View File

@ -19,7 +19,6 @@ use Livewire\Livewire;
* disabled button that does not name the obstacle is the thing people ring
* about.
*/
function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
{
$user = User::factory()->create();
@ -44,6 +43,12 @@ function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
]);
}
// One test below calls Billing::purchase(), which since Task 5 refuses
// outright without a Stripe key for the active mode. Without this, that
// test would see the same "no order" result for the wrong reason — the
// Stripe guard, not the seat block it names — and prove nothing.
withStripeSecret();
return [$user, $customer, $instance];
}

View File

@ -11,6 +11,7 @@ use App\Services\Monitoring\FakeMonitoringClient;
use App\Services\Monitoring\MonitoringClient;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Ssh\RemoteShell;
use App\Services\Traefik\FakeTraefikWriter;
@ -117,6 +118,22 @@ function admin(): Operator
return Operator::factory()->role('Owner')->create(['password' => 'password']);
}
/**
* Deposits a Stripe key for whichever operating mode is current, so
* Billing::purchase() refusing outright without one since Task 5 actually
* reaches the code a test is about instead of stopping before anything is
* touched.
*
* Deliberately NOT a global beforeEach(): CheckoutWithoutStripeKeyTest and
* StripeStrictModeTest are precisely about the absence of this key, and a
* suite-wide fixture would quietly defeat them. Call this only from a test's
* own fixture/beforeEach where a purchase is expected to go through.
*/
function withStripeSecret(): void
{
app(SecretVault::class)->put('stripe.secret', 'sk_test_fixture', Operator::factory()->create());
}
/**
* Undo the mailbox-seeding migration's baseline, for tests written before it.
*