presseportale/tests/Feature/Billing/BookingsPageTest.php
Kevin Adametz fe8bd2b931 Buchungsseite: Verlauf mit Kauftyp, Erfolgsmeldungen grün
„Eingelöste Käufe" nennt jetzt Kauftyp und PM-Titel (z. B.
„Depublikation: <Titel>") über das neue kompakte shortLabel() am
SinglePurchaseType. Die Checkout-Erfolgsbanner (Buchung, Tarifwechsel,
vorgemerkter Wechsel) und die Extra-PM-Bestätigung nutzen das
etablierte grüne Success-Muster (ok-soft) statt Hub-Blau.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:25:24 +00:00

450 lines
17 KiB
PHP

<?php
use App\Enums\SinglePurchaseType;
use App\Enums\UserPaymentOptionStatus;
use App\Models\BillingAddress;
use App\Models\BookingConfirmation;
use App\Models\LegalDocument;
use App\Models\Plan;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\CreditWalletService;
use App\Services\Billing\StripeCheckoutService;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function bookingsTestCustomer(): User
{
$user = User::factory()->create();
$user->assignRole('customer');
return $user;
}
test('the bookings page renders the active plans with checkout links', function () {
/** @var TestCase $this */
Plan::factory()->create([
'name' => 'Business',
'slug' => 'business',
'monthly_price_cents' => 4900,
'yearly_price_cents' => 49000,
'press_release_quota' => 10,
'daily_limit' => 2,
]);
Plan::factory()->inactive()->create(['name' => 'Versteckt']);
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Business')
->assertSee('49 €')
->assertSee('490 €')
->assertSee('Pressemitteilungen pro Monat')
->assertSee('max. 2 Veröffentlichungen pro Tag')
->assertSee('2 Monate gratis')
->assertDontSee('Versteckt')
// Buchung läuft über das Bestätigungs-Modal; der Checkout-Link
// erscheint erst nach der Unternehmer-Bestätigung (T1neu).
->call('selectBooking', 'subscription', 'business', 'monthly')
->assertDontSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'monthly']), false)
->set('entrepreneurConfirmed', true)
->assertSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'monthly']), false);
});
test('the plan card renders the maintained benefits list', function () {
/** @var TestCase $this */
Plan::factory()->create([
'name' => 'Business',
'slug' => 'business',
'press_release_quota' => 10,
'benefits' => ['Günstigere Extra-Pressemitteilungen', 'Boost-Platzierung vergünstigt'],
]);
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Günstigere Extra-Pressemitteilungen')
->assertSee('Boost-Platzierung vergünstigt');
});
test('the single pm block links to its checkout', function () {
/** @var TestCase $this */
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Einzel-Pressemitteilung — ohne Abo')
->assertSee('19 €')
->call('selectBooking', 'single_pm')
->assertSee('Buchung bestätigen')
->assertSee('zzgl. 19 % USt.')
->set('entrepreneurConfirmed', true)
->assertSee(route('me.checkout.single-pm'), false);
});
test('without any booking the current tariff card is hidden', function () {
/** @var TestCase $this */
$this->actingAs(bookingsTestCustomer());
LivewireVolt::test('customer.bookings')
->assertDontSee('Aktueller Tarif')
->assertSee('Den passenden Tarif wählen')
->assertSee('Noch kein Buchungsverlauf');
});
test('a subscriber sees the current plan and the manage button', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
$plan = Plan::factory()->create([
'name' => 'Pro',
'press_release_quota' => 25,
'stripe_price_id_monthly' => 'price_test_m_pro',
]);
subscribeUserToPlan($user, $plan);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Ihr aktueller Tarif')
->assertSee('Abo verwalten')
->assertSee('Pressemitteilungen pro Monat')
->assertSee(route('me.checkout.billing-portal'), false);
});
test('a grandfathered legacy user sees the bestandsschutz package with portal and unlimited quota', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
UserPaymentOption::factory()->create([
'user_id' => $user->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'current_period_end' => now()->addMonths(3),
'grandfathered_until' => now()->addYear(),
'legacy_conditions' => [
'name' => 'Presseverteiler Premium',
'legacy_portal' => 'presseecho',
'net_cents' => 100000,
'interval' => 'yearly',
],
]);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Ihr Bestandsschutz')
->assertSee('Bestandsschutz')
->assertSee('Presseverteiler Premium')
// Portal wird jetzt angezeigt.
->assertSee('Presseecho')
->assertSee('Unbegrenzte');
});
test('open and consumed single purchases appear in the card and history', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
SinglePurchase::factory()->paid()->create(['user_id' => $user->id]);
SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]);
// Eingelöste Käufe nennen Kauftyp und PM-Titel (z. B. Depublikation).
$release = PressRelease::factory()->create([
'user_id' => $user->id,
'title' => 'Depublizierte Beispiel-Mitteilung',
]);
SinglePurchase::factory()->consumed()->create([
'user_id' => $user->id,
'type' => SinglePurchaseType::Depublication,
'press_release_id' => $release->id,
]);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Einzel-Pressemitteilung verfügbar')
->assertSee('eingelöst am')
->assertSee('Depublikation: Depublizierte Beispiel-Mitteilung');
});
test('the billing portal redirects without an active subscription', function () {
/** @var TestCase $this */
$this->actingAs(bookingsTestCustomer())
->get(route('me.checkout.billing-portal'))
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
});
test('the billing portal forwards a subscriber to stripe', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
$user->forceFill(['stripe_id' => 'cus_test_portal'])->save();
$plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_portal']);
subscribeUserToPlan($user, $plan);
$this->mock(StripeCheckoutService::class, function ($mock) {
$mock->shouldReceive('billingPortalUrl')
->once()
->andReturn('https://billing.stripe.com/p/session/test');
});
$this->actingAs($user)
->get(route('me.checkout.billing-portal'))
->assertRedirect('https://billing.stripe.com/p/session/test');
});
test('the checkout success banner is shown after returning from stripe', function () {
/** @var TestCase $this */
$this->actingAs(bookingsTestCustomer())
->get(route('me.bookings.index', ['checkout' => 'erfolg']))
->assertOk()
->assertSee('Vielen Dank für Ihre Buchung!');
});
test('the wallet block shows the balance and the credit pack topup links', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
app(CreditWalletService::class)->credit($user, 30);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Credit-Wallet & Add-ons')
->assertSee('30')
->assertSee('27 Credits') // Bonus-Paket
->assertSee('Preisliste')
->assertSee('Was kostet wie viel')
->assertDontSee('Prüfkontingent') // Phase 2 — vorerst ausgeblendet
// Paket-Kauf läuft über das Bestätigungs-Modal (inkl. T1neu-Checkbox).
->call('selectBooking', 'credit_pack', 'p25')
->set('entrepreneurConfirmed', true)
->assertSee(route('me.checkout.credit-topup', ['pack' => 'p25']), false);
});
test('the extra pm card is hidden without credits and without a subscription', function () {
/** @var TestCase $this */
$this->actingAs(bookingsTestCustomer());
LivewireVolt::test('customer.bookings')
->assertDontSee('Aus Guthaben buchen') // Extra-PM-Karte ausgeblendet
->assertSee('Was kostet wie viel'); // Preis ergibt sich aus der Preisliste
});
test('the extra pm card appears once the wallet has credits', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
app(CreditWalletService::class)->credit($user, 20);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Aus Guthaben buchen');
});
test('a missing billing address shows the proactive banner with a profile link', function () {
/** @var TestCase $this */
$this->actingAs(bookingsTestCustomer());
LivewireVolt::test('customer.bookings')
->assertSee('Rechnungsadresse fehlt')
->assertSee(route('me.profile'), false);
});
test('with a billing address the banner is gone and the booking modal offers the stripe link', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertDontSee('Rechnungsadresse fehlt')
->call('selectBooking', 'single_pm')
->assertSee('Kostenpflichtig buchen')
->assertSee('gewerblichen oder selbständigen beruflichen Tätigkeit')
->set('entrepreneurConfirmed', true)
->assertSee(route('me.checkout.single-pm'), false);
});
test('buying an extra pm from the wallet succeeds with enough credits', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
app(CreditWalletService::class)->credit($user, 25);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->call('buyExtraPm')
->assertSee('Extra-Pressemitteilung gebucht');
expect($user->fresh()->creditBalance())->toBe(6); // 25 - 19 (Einzel-Tier)
expect($user->singlePurchases()->grantingSubmission()->count())->toBe(1);
});
test('buying an extra pm without enough credits shows the mini checkout hint', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
app(CreditWalletService::class)->credit($user, 8);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->call('buyExtraPm')
->assertSee('Bitte laden Sie mindestens 11 Credits nach');
expect($user->singlePurchases()->count())->toBe(0);
});
test('checking the entrepreneur box records a booking confirmation with the agb version', function () {
/** @var TestCase $this */
// T1neu (AGB v0.6 § 1 Abs. 4): Die Pflicht-Checkbox protokolliert
// Zeitpunkt + AGB-Version — das B2B-Gate stützt sich auf diesen Nachweis.
// E3: Zusätzlich wird die konkrete Rechtstext-Fassung referenziert.
$this->seed(LegalDocumentSeeder::class);
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->call('selectBooking', 'single_pm')
->set('entrepreneurConfirmed', true);
$confirmation = BookingConfirmation::query()->sole();
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
expect($confirmation->user_id)->toBe($user->id);
expect($confirmation->booking_type)->toBe('single_pm');
expect($confirmation->legal_document_id)->toBe($agb->id);
expect($confirmation->agb_version)->toBe($agb->version);
expect($confirmation->confirmed_at)->not->toBeNull();
});
test('selecting a new booking resets the entrepreneur confirmation checkbox', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->call('selectBooking', 'single_pm')
->set('entrepreneurConfirmed', true)
->call('selectBooking', 'single_pm')
->assertSet('entrepreneurConfirmed', false);
});
test('a subscriber sees swap buttons on other plans and the swap confirmation modal', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
// Preise gepinnt: Das Ziel ist teurer (Upgrade), damit das Modal
// deterministisch den Sofort-Wechsel zeigt.
$currentPlan = Plan::factory()->create([
'name' => 'Starter',
'slug' => 'starter',
'monthly_price_cents' => 1900,
'stripe_price_id_monthly' => 'price_test_m_starter',
]);
$targetPlan = Plan::factory()->create([
'name' => 'Business',
'slug' => 'business',
'monthly_price_cents' => 3900,
'stripe_price_id_monthly' => 'price_test_m_business',
]);
subscribeUserToPlan($user, $currentPlan);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Zu diesem Tarif wechseln')
->assertSee('Ihr aktueller Tarif')
->call('selectBooking', 'subscription', $targetPlan->slug, 'monthly')
->assertSet('booking.is_swap', true)
->assertSet('booking.url', route('me.checkout.swap', ['planSlug' => 'business', 'interval' => 'monthly']))
->assertSee('Tarifwechsel bestätigen')
->assertSee('Kostenpflichtig wechseln');
});
test('a swap booking for a subscriber on grace period announces the revoked cancellation', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
$currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_grace']);
$targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_grace2']);
subscribeUserToPlan($user, $currentPlan);
$user->subscriptions()->first()->update(['ends_at' => now()->addMonth()]);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->call('selectBooking', 'subscription', $targetPlan->slug, 'monthly')
->assertSet('booking.resumes_cancellation', true)
->assertSee('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.');
});
test('a swap to a cheaper plan is announced as a scheduled change at period end', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
$currentPlan = Plan::factory()->create([
'monthly_price_cents' => 5900,
'stripe_price_id_monthly' => 'price_test_m_down1',
]);
$targetPlan = Plan::factory()->create([
'monthly_price_cents' => 1900,
'stripe_price_id_monthly' => 'price_test_m_down2',
]);
subscribeUserToPlan($user, $currentPlan);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->call('selectBooking', 'subscription', $targetPlan->slug, 'monthly')
->assertSet('booking.is_downgrade', true)
->assertSee('zum Ende der bereits bezahlten Abrechnungsperiode')
->assertSee('Tarifwechsel vormerken')
->assertDontSee('Kostenpflichtig wechseln');
});
test('a scheduled plan change is shown on the current plan card', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
$currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_sched1']);
$targetPlan = Plan::factory()->create([
'name' => 'Kleiner Tarif',
'stripe_price_id_monthly' => 'price_test_m_sched2',
]);
subscribeUserToPlan($user, $currentPlan);
$user->subscriptions()->first()->forceFill([
'scheduled_stripe_price' => 'price_test_m_sched2',
'scheduled_change_at' => now()->addDays(9),
'stripe_schedule_id' => 'sub_sched_test',
])->save();
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->assertSee('Tarifwechsel zu Kleiner Tarif am')
->assertSee(now()->addDays(9)->format('d.m.Y'));
});