seed(RolesAndPermissionsSeeder::class); }); function paymentsPageAdmin(): User { $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); return $admin; } test('the payments page shows subscriptions with plan name and mrr', function () { /** @var TestCase $this */ $customer = User::factory()->create(['name' => 'Abo Kunde']); $plan = Plan::factory()->create([ 'name' => 'Business', 'monthly_price_cents' => 4900, 'stripe_price_id_monthly' => 'price_test_m_biz', ]); subscribeUserToPlan($customer, $plan); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->assertSee('Aktive Abos') ->assertSee('MRR (netto)') ->assertSee('49,00 €') ->assertSee('Abo Kunde') ->assertSee('Business') ->assertSee('monatlich'); }); test('a yearly subscription contributes one twelfth to the mrr', function () { /** @var TestCase $this */ $customer = User::factory()->create(); $plan = Plan::factory()->create([ 'monthly_price_cents' => 4900, 'yearly_price_cents' => 49000, 'stripe_price_id_monthly' => 'price_test_m_y', 'stripe_price_id_yearly' => 'price_test_y_y', ]); subscribeUserToPlan($customer, $plan, 'yearly'); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->assertSee('40,83 €') ->assertSee('jährlich'); }); test('single purchases appear with type and status', function () { /** @var TestCase $this */ $customer = User::factory()->create(['name' => 'Einzel Käufer']); SinglePurchase::factory()->paid()->create(['user_id' => $customer->id]); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->assertSee('Einzel Käufer') ->assertSee('Einzel-Pressemitteilung') ->assertSee('Bezahlt') ->assertSee('19,00 €'); }); test('local invoices appear with number and circle badge', function () { /** @var TestCase $this */ $customer = User::factory()->create(); Invoice::factory()->create([ 'user_id' => $customer->id, 'number' => 'STR-2026-000042', 'status' => InvoiceStatus::Paid->value, 'paid_at' => now(), 'stripe_invoice_id' => 'in_test_123', ]); Invoice::factory()->create([ 'user_id' => $customer->id, 'number' => 'MAN-2026-000007', 'status' => InvoiceStatus::Open->value, 'stripe_invoice_id' => null, ]); $this->actingAs(paymentsPageAdmin()); // Default ist der Bearbeitungsstapel (offene MAN-Rechnungen) … LivewireVolt::test('admin.payments.index') ->assertSee('MAN-2026-000007') ->assertSee('Manuell (MAN)') ->assertDontSee('STR-2026-000042') // … der Filter „Alle" zeigt auch den STR-Spiegel. ->set('invoiceFilter', 'all') ->assertSee('STR-2026-000042') ->assertSee('Stripe (STR)') ->assertSee('MAN-2026-000007'); }); test('paid invoices of the last 30 days are summed as revenue', function () { /** @var TestCase $this */ Invoice::factory()->create([ 'status' => InvoiceStatus::Paid->value, 'paid_at' => now()->subDays(5), 'total_cents' => 11900, ]); // Außerhalb des 30-Tage-Fensters: erscheint in der Tabelle, // zählt aber nicht in die Umsatz-KPI (sonst stünde dort 1.118,00 €). Invoice::factory()->create([ 'status' => InvoiceStatus::Paid->value, 'paid_at' => now()->subDays(60), 'total_cents' => 99900, ]); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->assertSee('Umsatz 30 Tage') ->assertSee('119,00 €') ->assertDontSee('1.118,00 €'); }); test('the search filters all panels by user name or email', function () { /** @var TestCase $this */ $match = User::factory()->create(['name' => 'Maria Treffer']); $other = User::factory()->create(['name' => 'Olaf Anders']); SinglePurchase::factory()->paid()->create(['user_id' => $match->id]); SinglePurchase::factory()->paid()->create(['user_id' => $other->id]); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->set('search', 'Maria') ->assertSee('Maria Treffer') ->assertDontSee('Olaf Anders'); }); test('the payments page is not accessible for customers', function () { /** @var TestCase $this */ $customer = User::factory()->create(['is_active' => true]); $customer->assignRole('customer'); $this->actingAs($customer) ->get(route('admin.payments.index')) ->assertForbidden(); }); /** * Offene MAN-Rechnung samt Bestandsschutz-Posten eines Überweisungskunden. * * @return array{0: Invoice, 1: UserPaymentOption, 2: User} */ function openManualInvoice(array $overrides = []): array { $customer = User::factory()->create(['name' => 'Ulla Überweisung']); $agreement = UserPaymentOption::factory()->create([ 'user_id' => $customer->id, 'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly'])->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, 'stripe_subscription_id' => null, 'cancelled_at' => null, 'current_period_start' => today(), 'current_period_end' => today()->addYear(), 'legacy_conditions' => ['name' => 'Unbegrenzte PMs', 'legacy_portal' => 'businessportal24', 'interval' => 'yearly', 'net_cents' => 16723], ]); $invoice = Invoice::factory()->create([ 'user_id' => $customer->id, 'user_payment_id' => null, 'user_payment_option_id' => $agreement->id, 'invoice_billing_address_id' => InvoiceBillingAddress::factory(), 'number' => 'MAN-00021', 'status' => InvoiceStatus::Open->value, 'total_cents' => 19900, 'stripe_invoice_id' => null, 'sent_at' => null, 'due_date' => today()->addDays(14), ...$overrides, ]); return [$invoice, $agreement, $customer]; } test('an unsent MAN invoice offers send, paid and terminate; sending mails the customer and sets the due date', function () { /** @var TestCase $this */ Mail::fake(); [$invoice, , $customer] = openManualInvoice(); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->assertSee('Offene MAN-Rechnungen') ->assertSee('MAN-00021') ->assertSee('noch nicht gesendet') ->assertSee('Rechnung senden') ->assertSee('Bestandsschutz beenden') ->assertSee(route('admin.invoices.pdf', $invoice)) ->call('sendInvoice', $invoice->id) ->assertSet('notificationType', 'success') ->assertSee('gesendet '.today()->format('d.m.Y')) ->assertSee('zahlbar bis '.today()->addDays(14)->format('d.m.Y')) ->assertSee('Erinnerung'); Mail::assertQueued(ManualInvoiceIssued::class, fn (ManualInvoiceIssued $mail): bool => $mail->hasTo($customer->email)); expect($invoice->fresh()->sent_at)->not->toBeNull(); }); test('a sent MAN invoice can be reminded and marked as paid with a payment date', function () { /** @var TestCase $this */ Mail::fake(); [$invoice] = openManualInvoice(['sent_at' => now()->subDays(20), 'due_date' => today()->subDays(6)]); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->assertSee('Überfällig') ->call('remindInvoice', $invoice->id) ->assertSet('notificationType', 'success') ->assertSee('erinnert '.today()->format('d.m.Y').' (1×)') ->set('paidAtInput', today()->subDay()->toDateString()) ->call('markInvoicePaid', $invoice->id) ->assertSet('notificationType', 'success') // Bezahlt → der Bearbeitungsstapel ist leer … ->assertSee('Keine offenen MAN-Rechnungen') ->set('invoiceFilter', 'all') ->assertSee('MAN-00021') ->assertSee('bezahlt: '.today()->subDay()->format('d.m.Y')); Mail::assertQueued(ManualInvoiceIssued::class, fn (ManualInvoiceIssued $mail): bool => $mail->isReminder); expect($invoice->fresh()->status)->toBe(InvoiceStatus::Paid); }); test('a future payment date is rejected by validation', function () { /** @var TestCase $this */ [$invoice] = openManualInvoice(['sent_at' => now()]); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->set('paidAtInput', 'kein-datum') ->call('markInvoicePaid', $invoice->id) ->assertHasErrors(['paidAtInput']); expect($invoice->fresh()->status)->toBe(InvoiceStatus::Open); }); test('terminating from the page voids the invoice, cancels the bundle and honours the notify checkbox', function () { /** @var TestCase $this */ Mail::fake(); [$invoice, $agreement, $customer] = openManualInvoice(['sent_at' => now()->subDays(20), 'due_date' => today()->subDays(6)]); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->assertSee('Ulla Überweisung') ->set('terminateNotify', false) ->set('terminateNote', 'Nicht erreichbar.') ->call('terminateInvoice', $invoice->id) ->assertSet('notificationType', 'success') ->assertSee('1 Bestandsschutz-Posten gekündigt') ->assertSee('Keine offenen MAN-Rechnungen') ->assertSee('Keine laufenden Bestandsschutz-Posten'); expect($invoice->fresh()->status)->toBe(InvoiceStatus::Void); expect($invoice->fresh()->void_reason)->toContain('Nicht erreichbar.'); expect($agreement->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled); Mail::assertNotQueued(GrandfatheringTerminated::class); }); test('service refusals surface as an error notification instead of an exception', function () { /** @var TestCase $this */ Mail::fake(); [$invoice] = openManualInvoice(); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->call('remindInvoice', $invoice->id) ->assertSet('notificationType', 'error') ->assertSee('noch nicht versendet'); Mail::assertNothingQueued(); }); test('due agreements the daily run could not invoice are listed as blocked and can be invoiced once the address exists', function () { /** @var TestCase $this */ $customer = User::factory()->create(['name' => 'Bianca Blockiert']); $agreement = UserPaymentOption::factory()->create([ 'user_id' => $customer->id, 'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly', 'price_cents' => 4118])->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, 'stripe_subscription_id' => null, 'cancelled_at' => null, 'current_period_start' => today()->subYear()->subMonth(), 'current_period_end' => today()->subMonth(), 'legacy_conditions' => ['name' => 'Kleine Mappe', 'legacy_portal' => 'presseecho', 'interval' => 'yearly', 'net_cents' => 4118], ]); $this->actingAs(paymentsPageAdmin()); $page = LivewireVolt::test('admin.payments.index') ->assertSee('Blockierte Vereinbarungen') ->assertSee('Bianca Blockiert') ->assertSee('Keine Rechnungsadresse') ->call('invoiceNow', $agreement->id) ->assertSet('notificationType', 'error'); BillingAddress::factory()->create(['user_id' => $customer->id, 'country_code' => 'DE']); $page->call('invoiceNow', $agreement->id) ->assertSet('notificationType', 'success') ->assertDontSee('Blockierte Vereinbarungen') ->assertSee('MAN-00001') ->assertSee('noch nicht gesendet'); expect(Invoice::query()->manual()->open()->where('user_payment_option_id', $agreement->id)->exists())->toBeTrue(); }); test('a blocked agreement can be removed without invoicing', function () { /** @var TestCase $this */ Mail::fake(); $customer = User::factory()->create(['name' => 'Rita Rückläufer']); $agreement = UserPaymentOption::factory()->create([ 'user_id' => $customer->id, 'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly'])->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, 'stripe_subscription_id' => null, 'cancelled_at' => null, 'current_period_start' => today()->subYear()->subMonth(), 'current_period_end' => today()->subMonth(), 'legacy_conditions' => ['name' => 'Unbegrenzte PMs', 'legacy_portal' => 'businessportal24', 'interval' => 'yearly', 'net_cents' => 16723], ]); $this->actingAs(paymentsPageAdmin()); LivewireVolt::test('admin.payments.index') ->assertSee('Blockierte Vereinbarungen') ->assertSee('Rita Rückläufer') ->assertSee('Entfernen') ->set('abandonNote', 'Nicht erreichbar.') ->call('abandonAgreement', $agreement->id) ->assertSet('notificationType', 'success') ->assertSee('keine Rechnung gestellt') ->assertDontSee('Blockierte Vereinbarungen') ->assertSee('Keine laufenden Bestandsschutz-Posten'); expect($agreement->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled); expect(Invoice::count())->toBe(0); Mail::assertNothingQueued(); });