diff --git a/VERSION b/VERSION index 55a4dd6..49e16b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.33 +1.3.34 diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 60c2088..159766f 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -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, ); diff --git a/resources/views/livewire/order.blade.php b/resources/views/livewire/order.blade.php index 7810dd0..4d2f5c7 100644 --- a/resources/views/livewire/order.blade.php +++ b/resources/views/livewire/order.blade.php @@ -18,6 +18,16 @@ @else @error('plan'){{ $message }}@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. --}} +
@foreach ($plans as $plan)
@csrf + {{-- The consent, carried from the one box below. --}} + {{-- The wording is the legal one and not a nicety: the button that concludes a paid contract has to say that it costs money. --}} - + {{ __('order.buy') }} @@ -84,6 +97,21 @@ @endforeach
+ {{-- 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. --}} + + + @error('immediate_start') + {{ $message }} + @enderror +
+

{{ __('order.terms') }}

diff --git a/tests/Feature/SelfServiceOrderTest.php b/tests/Feature/SelfServiceOrderTest.php index 5285aa7..fbf01d1 100644 --- a/tests/Feature/SelfServiceOrderTest.php +++ b/tests/Feature/SelfServiceOrderTest.php @@ -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(); +});