seed(RolesAndPermissionsSeeder::class); config()->set('billing.change_price_cents', 1000); }); function changeCustomer(): User { $user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]); $user->assignRole('customer'); BillingAddress::factory()->create(['user_id' => $user->id]); // B2B-Gate: zeitnahe Unternehmer-Bestätigung (wie im Änderungs-Modal). BookingConfirmation::query()->create([ 'user_id' => $user->id, 'booking_type' => 'press_release_change', 'agb_version' => (string) config('legal.agb_version'), 'confirmed_at' => now(), ]); return $user; } function publishedChangeReleaseFor(User $user): PressRelease { return PressRelease::factory() ->published() ->forPortal(Portal::Businessportal24) ->create([ 'user_id' => $user->id, 'published_at' => now()->subDay(), 'title' => 'Solaranlage in Hamburg eroeffnet und in Betrieb', ]); } function approvedChangeRequestFor(PressRelease $release, User $user): LegalRequest { return LegalRequest::query()->create([ 'press_release_id' => $release->id, 'portal' => $release->portal?->value, 'type' => LegalRequestType::Change->value, 'status' => LegalRequestStatus::Open->value, 'requester_email' => (string) $user->email, 'message' => 'Kostenpflichtige Änderung (Spur D) über das Kunden-Panel eingereicht.', 'payload' => [ 'source' => 'self_service', 'new_values' => ['title' => 'Solaranlage in Muenchen eroeffnet und in Vollbetrieb'], 'ki_check' => ['classification' => 'green', 'reasons' => []], ], 'verified_at' => now(), 'verification_method' => LegalRequestVerificationMethod::LoggedIn->value, ]); } test('the change checkout refuses without an approved precheck request', function () { /** @var TestCase $this */ $user = changeCustomer(); $release = publishedChangeReleaseFor($user); $this->actingAs($user) ->get(route('me.checkout.change', $release->id)) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); expect(SinglePurchase::count())->toBe(0); }); test('a red-rejected precheck request does not unlock the checkout', function () { /** @var TestCase $this */ $user = changeCustomer(); $release = publishedChangeReleaseFor($user); $request = approvedChangeRequestFor($release, $user); $request->forceFill([ 'status' => LegalRequestStatus::Rejected->value, 'payload' => array_merge($request->payload, ['ki_check' => ['classification' => 'red', 'reasons' => ['Unzulässiges Wort.']]]), ])->save(); $this->actingAs($user) ->get(route('me.checkout.change', $release->id)) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); expect(SinglePurchase::count())->toBe(0); }); test('the change checkout creates a pending purchase, links the request and reuses pendings', function () { /** @var TestCase $this */ $user = changeCustomer(); $release = publishedChangeReleaseFor($user); $legalRequest = approvedChangeRequestFor($release, $user); $checkout = Mockery::mock(Checkout::class); $checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test')); $this->mock(StripeCheckoutService::class, function ($mock) use ($checkout) { // Zweimal: der zweite Aufruf verwendet den offenen Kauf wieder. $mock->shouldReceive('forChange')->twice()->andReturn($checkout); }); $this->actingAs($user) ->get(route('me.checkout.change', $release->id)) ->assertRedirect('https://checkout.stripe.com/c/pay/test'); $purchase = SinglePurchase::sole(); expect($purchase->type)->toBe(SinglePurchaseType::Change); expect($purchase->status)->toBe(SinglePurchaseStatus::Pending); expect($purchase->price_cents)->toBe(1000); expect($purchase->press_release_id)->toBe($release->id); expect($legalRequest->fresh()->single_purchase_id)->toBe($purchase->id); // Abgebrochener Checkout blockiert nicht: erneuter Aufruf nutzt den // Pending-Kauf wieder statt einen zweiten anzulegen. $this->actingAs($user) ->get(route('me.checkout.change', $release->id)) ->assertRedirect('https://checkout.stripe.com/c/pay/test'); expect(SinglePurchase::count())->toBe(1); }); test('the paid webhook applies the change immediately with snapshot, lock and consumed purchase', function () { /** @var TestCase $this */ Mail::fake(); Queue::fake(); $user = changeCustomer(); $release = publishedChangeReleaseFor($user); $legalRequest = approvedChangeRequestFor($release, $user); $purchase = SinglePurchase::query()->create([ 'user_id' => $user->id, 'type' => SinglePurchaseType::Change->value, 'status' => SinglePurchaseStatus::Pending->value, 'price_cents' => 1000, 'currency' => 'EUR', 'press_release_id' => $release->id, ]); $legalRequest->forceFill(['single_purchase_id' => $purchase->id])->save(); $event = new WebhookReceived([ 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_test_change', 'payment_intent' => 'pi_test_change', 'payment_status' => 'paid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ]], ]); app(ProcessStripeWebhook::class)->handle($event); app(ProcessStripeWebhook::class)->handle($event); // Replay bleibt folgenlos. $release->refresh(); $legalRequest->refresh(); expect($release->title)->toBe('Solaranlage in Muenchen eroeffnet und in Vollbetrieb') ->and($release->isSyncLocked())->toBeTrue() ->and($legalRequest->status)->toBe(LegalRequestStatus::Resolved) ->and($legalRequest->executed_at)->not->toBeNull() ->and($legalRequest->redactionSnapshots()->count())->toBe(1) ->and($legalRequest->redactionSnapshots()->first()->encrypted_before['fields']['title'])->toBe('Solaranlage in Hamburg eroeffnet und in Betrieb'); expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); expect($purchase->fresh()->consumed_at)->not->toBeNull(); // Der Käufer erhält genau eine Bestätigung (Replay löst keine zweite aus). Mail::assertQueued(PressReleaseChangeApplied::class, function (PressReleaseChangeApplied $mail) use ($user, $release) { return $mail->hasTo($user->email) && $mail->pressRelease->is($release) && $mail->isPaidChange; }); Mail::assertQueuedCount(1); });