Ask for the express request to begin, and send it to Stripe
tests / pest (push) Failing after 7m59s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Has been skipped Details

The withdrawal machinery has always read `immediate_start` off the
checkout session's metadata — StripeWebhookController stamps
`immediate_start_consent_at` from it, WithdrawalRight refunds pro rata
when it is there and in full when it is not. The checkout never put it
there. So every contract concluded through this application carried no
consent, and a withdrawal on day thirteen of a running cloud took back
the whole amount however many days the machine had run. That is the
lawful outcome without the consent, and it was the only outcome
available.

Now the checkout asks. The box is required — validated `accepted`, not
`present`, because an empty field is what an unticked checkbox sends and
a consent that can be produced by leaving something blank is not a
consent — and the metadata carries exactly the string '1' the webhook
compares against.

One box for the page rather than one per card: the sentence is the
statutory wording and four copies of it is a wall nobody reads. The forms
carry it as a hidden field bound to that box, and the buy buttons stay
disabled until it is ticked. With JavaScript off the field arrives empty
and the server refuses with the sentence explaining why — the safe
direction to fail in. Ticking a box in a browser was never the record
anyway: the record is the timestamp taken when the payment lands.

Tested at both seams and along the whole chain: refused without it,
refused for '', '0' and a word that is not yes, carried to Stripe as '1',
and a webhook carrying it lands as a timestamp on the order while one
without it lands as null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus v1.3.34
nexxo 2026-07-29 21:19:03 +02:00
parent 6d7c2bdf35
commit e0c109c449
4 changed files with 166 additions and 13 deletions

View File

@ -1 +1 @@
1.3.33
1.3.34

View File

@ -46,6 +46,16 @@ class CheckoutController extends Controller
$data = $request->validate([
'plan' => ['required', 'string', 'max:64'],
'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY],
// The consumer's express request that we begin inside the
// fourteen-day withdrawal window (FAGG §16, §356 BGB). Required,
// because the cloud starts being built the moment the payment
// lands: without this on record a withdrawal on day thirteen gets
// the WHOLE amount back, however many days the machine actually
// ran. A seller who cannot prove the consent was given cannot
// charge for the days — and the proof is a timestamp taken here.
'immediate_start' => ['accepted'],
], [
'immediate_start.accepted' => __('checkout.immediate_start_required'),
]);
$term = $data['term'] ?? Subscription::TERM_MONTHLY;
@ -102,6 +112,13 @@ class CheckoutController extends Controller
'plan' => $version->family->key,
'plan_version_id' => (string) $version->id,
'datacenter' => app(HostCapacity::class)->preferredDatacenter(),
// Exactly '1', because StripeWebhookController compares
// against exactly that: a consent that can be produced by a
// typo is not a consent. It travels through Stripe rather
// than being stamped here — the contract only exists once
// the payment does, and a consent recorded for a checkout
// somebody abandoned would be a consent to nothing.
'immediate_start' => '1',
],
customerEmail: $user?->email,
);

View File

@ -18,6 +18,16 @@
@else
@error('plan')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
{{-- One consent for the page, not one per card: the sentence is the
law's own wording and four copies of it is a wall nobody reads.
The forms each carry it as a hidden field bound to this one box.
The server rule is the real gate (CheckoutController validates
`accepted`), so with JavaScript off the field arrives empty and the
checkout is refused with the sentence explaining why the safe
direction to fail in. Ticking a box in a browser is not the record
anyway: the record is the timestamp taken when the payment lands. --}}
<div x-data="{ immediateStart: false }">
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($plans as $plan)
<div @class([
@ -73,10 +83,13 @@
<form method="POST" action="{{ route('checkout.start') }}" class="mt-6">
@csrf
<input type="hidden" name="plan" value="{{ $plan['key'] }}" />
{{-- The consent, carried from the one box below. --}}
<input type="hidden" name="immediate_start" x-bind:value="immediateStart ? '1' : ''" />
{{-- 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">
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full"
x-bind:disabled="!immediateStart">
{{ __('order.buy') }}
</x-ui.button>
</form>
@ -84,6 +97,21 @@
@endforeach
</div>
{{-- The withdrawal consent. Without it on record a withdrawal on day
thirteen gets the WHOLE amount back however long the machine ran,
because a seller who cannot prove the consent was given cannot
charge for the days it was given for. --}}
<label class="mt-5 flex max-w-[80ch] cursor-pointer items-start gap-3 rounded-lg border border-line bg-surface-2 p-4">
<input type="checkbox" x-model="immediateStart"
class="mt-0.5 size-4 shrink-0 rounded border-line-strong text-accent-active focus:ring-accent-active" />
<span class="text-xs leading-relaxed text-body">{{ __('checkout.immediate_start') }}</span>
</label>
@error('immediate_start')
<x-ui.alert variant="error" class="mt-3">{{ $message }}</x-ui.alert>
@enderror
</div>
<p class="max-w-[80ch] text-xs leading-relaxed text-muted">
{{ __('order.terms') }}
</p>

View File

@ -26,6 +26,19 @@ beforeEach(function () {
DB::table('plan_prices')->update(['stripe_price_id' => 'price_test']);
});
/**
* A checkout post, consent and all.
*
* The express request that we begin inside the withdrawal window is required
* (FAGG §16): without it on record a withdrawal on day thirteen takes back the
* WHOLE amount however long the machine ran, because a seller who cannot prove
* the consent cannot charge for the days.
*/
function buying(string $plan): array
{
return ['plan' => $plan, 'immediate_start' => '1'];
}
/** A signed-in, verified portal user. */
function buyer(): User
{
@ -34,7 +47,7 @@ function buyer(): User
it('opens a Stripe checkout for the package that was chosen', function () {
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start'])
->post(route('checkout.start'), buying('start'))
->assertRedirect();
expect($this->stripe->checkouts)->toHaveCount(1)
@ -45,7 +58,7 @@ 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']);
$this->actingAs(buyer())->post(route('checkout.start'), buying('team'));
$meta = $this->stripe->checkouts[0]['metadata'];
@ -57,7 +70,7 @@ it('carries exactly the metadata the webhook reads back', function () {
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']);
$this->actingAs($user)->post(route('checkout.start'), buying('start'));
expect($this->stripe->checkouts[0]['email'])->toBe($user->email);
});
@ -66,8 +79,7 @@ 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',
$this->actingAs(buyer())->post(route('checkout.start'), buying('start') + [
'amount_cents' => 1,
'price' => 'price_of_my_choosing',
]);
@ -77,7 +89,7 @@ it('never lets a request name its own price', function () {
it('refuses a plan that is not on sale', function () {
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'does-not-exist'])
->post(route('checkout.start'), buying('does-not-exist'))
->assertSessionHasErrors('plan');
expect($this->stripe->checkouts)->toBeEmpty();
@ -88,7 +100,7 @@ it('refuses a plan that was never synced to Stripe', function () {
PlanPrice::query()->update(['stripe_price_id' => null]);
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start'])
->post(route('checkout.start'), buying('start'))
->assertSessionHasErrors('plan');
expect($this->stripe->checkouts)->toBeEmpty();
@ -98,7 +110,7 @@ it('says so rather than redirecting into a broken checkout when Stripe is not co
$this->stripe->configured = false;
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start'])
->post(route('checkout.start'), buying('start'))
->assertSessionHasErrors('plan');
expect($this->stripe->checkouts)->toBeEmpty();
@ -112,7 +124,7 @@ it('sends an existing customer to the plan change instead of selling a second cl
Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']);
$this->actingAs($user)
->post(route('checkout.start'), ['plan' => 'team'])
->post(route('checkout.start'), buying('team'))
->assertRedirect(route('billing'));
expect($this->stripe->checkouts)->toBeEmpty();
@ -122,7 +134,7 @@ 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->post(route('checkout.start'), buying('start'))->assertRedirect(route('login'));
$this->get(route('order'))->assertRedirect(route('login'));
expect($this->stripe->checkouts)->toBeEmpty();
@ -131,7 +143,7 @@ it('lets nobody buy anything without signing in first', function () {
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'])
$this->actingAs($unverified)->post(route('checkout.start'), buying('start'))
->assertRedirect(route('verification.notice'));
expect($this->stripe->checkouts)->toBeEmpty();
@ -178,3 +190,99 @@ it('does not promise a migration it has not looked at', function () {
expect($this->get('/')->getContent())
->not->toContain('wie Ihre Daten sicher umziehen');
});
it('refuses to open a checkout without the express request to begin at once', function () {
// The gap this closes: the webhook has always read `immediate_start` off the
// session metadata, and the checkout never put it there. So every contract
// was concluded WITHOUT recorded consent, and every withdrawal — even on day
// thirteen of a running cloud — had to refund the full amount.
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start'])
->assertSessionHasErrors('immediate_start');
expect($this->stripe->checkouts)->toBeEmpty();
});
it('does not take a box that was merely present as a yes', function () {
// "accepted", not "present": an empty field is what an unticked checkbox
// sends, and a consent that can be produced by leaving something blank is
// not a consent.
foreach (['', '0', 'nein'] as $value) {
$this->actingAs(buyer())
->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => $value])
->assertSessionHasErrors('immediate_start');
}
expect($this->stripe->checkouts)->toBeEmpty();
});
it('carries the consent to Stripe as exactly the string the webhook compares against', function () {
// StripeWebhookController tests `=== '1'` on purpose: a consent that can be
// produced by a typo is not a consent. Anything else here silently becomes
// "no consent" months later, at the one moment it matters.
$this->actingAs(buyer())->post(route('checkout.start'), buying('start'));
expect($this->stripe->checkouts[0]['metadata']['immediate_start'])->toBe('1');
});
it('puts the consent in front of the customer, in the law own words', function () {
// On the page, once, next to the buttons — not buried in terms nobody
// opens. The sentence is the statutory one, and the buy buttons stay
// disabled until it is ticked.
$this->actingAs(buyer())
->get(route('order'))
->assertOk()
->assertSee(__('checkout.immediate_start'))
->assertSee('immediate_start', false);
});
it('lands as a timestamp on the order, which is what a withdrawal is measured against', function () {
// The whole chain, end to end: the checkout puts `immediate_start` on the
// session, Stripe hands it back on the webhook, and the order carries the
// moment the consumer asked us to begin. Without that timestamp
// WithdrawalRight refunds everything — the two ends were wired to each
// other through a field nobody was sending.
Illuminate\Support\Facades\Queue::fake();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_consent',
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_evt_consent',
'payment_status' => 'paid',
'customer_details' => ['email' => 'kunde@example.test', 'name' => 'Kanzlei Berger'],
'amount_total' => 4900,
'currency' => 'eur',
// Exactly what CheckoutController::start() sends.
'metadata' => ['plan' => 'start', 'datacenter' => 'fsn', 'immediate_start' => '1'],
]],
])->assertOk();
$order = App\Models\Order::query()->where('stripe_event_id', 'cs_evt_consent')->first();
expect($order)->not->toBeNull()
->and($order->immediate_start_consent_at)->not->toBeNull();
});
it('records no consent for a session that never carried one', function () {
// The other direction, and the one that must stay safe: an old session, or
// a checkout opened outside this application, records nothing — and a
// withdrawal against it refunds in full.
Illuminate\Support\Facades\Queue::fake();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_no_consent',
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_evt_no_consent',
'payment_status' => 'paid',
'customer_details' => ['email' => 'zweiter@example.test', 'name' => 'Zweite Kanzlei'],
'amount_total' => 4900,
'currency' => 'eur',
'metadata' => ['plan' => 'start', 'datacenter' => 'fsn'],
]],
])->assertOk();
expect(App\Models\Order::query()->where('stripe_event_id', 'cs_evt_no_consent')->first()?->immediate_start_consent_at)
->toBeNull();
});