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 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 { 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'), buying('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'), buying('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'), buying('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'), buying('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'), buying('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'), buying('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'), buying('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'), buying('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'), buying('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'), buying('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 sign-in rather than an inbox', function () { // Booking happens in the panel, where everything already works. The website // does not need a second checkout — it needs to get somebody to the door. $content = $this->get('/')->assertOk()->getContent(); expect($content)->toContain(route('login')) // Not straight at /order: that is behind auth, so an anonymous visitor // would be bounced by a middleware redirect from a page they never // asked for. ->and($content)->not->toContain('href="'.route('order').'"') // And not back to the old shape: every card ending in a jump to a // mailto: block. ->and($content)->not->toContain('anfragen'); }); 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'); }); 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(); }); it('asks for the consent before the packages, not after them', function () { // The consent used to sit at the bottom with every buy button disabled until // it was ticked, so the page arrived looking broken. Read in the order it // applies: the condition first, the choice second — and the button stays // enabled, with the reason it will not go through said next to it. $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php')); $consentAt = strpos($page, "__('checkout.immediate_start')"); $gridAt = strpos($page, 'lg:grid-cols-3'); expect($consentAt)->toBeLessThan($gridAt) // No disabled button: a disabled control explains nothing. ->and($page)->not->toContain('x-bind:disabled="!immediateStart"') ->and($page)->toContain("__('order.consent_first')"); }); it('lays five packages out as three and two', function () { // A fifth card alone on a row of four reads as a mistake. expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php'))) ->toContain('lg:grid-cols-3') ->not->toContain('lg:grid-cols-4'); }); it('still refuses the checkout when the box was not ticked', function () { // The layout changed; the rule did not. The server is the gate. $this->actingAs(buyer()) ->post(route('checkout.start'), ['plan' => 'start']) ->assertSessionHasErrors('immediate_start'); expect($this->stripe->checkouts)->toBeEmpty(); }); it('renders inside the app shell, not the bare sign-in one', function () { // layouts.portal is what the sign-in pages use: no navigation, no page // padding. In it, this page sat flush against the top and bottom of the // window with no way back to anything. expect(Illuminate\Support\Facades\File::get(app_path('Livewire/Order.php'))) ->toContain("#[Layout('layouts.portal-app')]"); // And the shell is actually there: the navigation proves it. $this->actingAs(buyer()) ->get(route('order')) ->assertOk() ->assertSee(__('dashboard.nav.overview')); });