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')
{{ __('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(); +});