- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)
Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
14 KiB
PHP
366 lines
14 KiB
PHP
<?php
|
||
|
||
use App\Enums\InvoiceStatus;
|
||
use App\Enums\UserPaymentOptionStatus;
|
||
use App\Mail\GrandfatheringTerminated;
|
||
use App\Mail\ManualInvoiceIssued;
|
||
use App\Models\BillingAddress;
|
||
use App\Models\Invoice;
|
||
use App\Models\InvoiceBillingAddress;
|
||
use App\Models\PaymentOption;
|
||
use App\Models\Plan;
|
||
use App\Models\SinglePurchase;
|
||
use App\Models\User;
|
||
use App\Models\UserPaymentOption;
|
||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Livewire\Livewire;
|
||
use Tests\TestCase;
|
||
|
||
beforeEach(function (): void {
|
||
/** @var TestCase $this */
|
||
$this->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());
|
||
|
||
Livewire::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());
|
||
|
||
Livewire::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());
|
||
|
||
Livewire::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) …
|
||
Livewire::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());
|
||
|
||
Livewire::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());
|
||
|
||
Livewire::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());
|
||
|
||
Livewire::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());
|
||
|
||
Livewire::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());
|
||
|
||
Livewire::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());
|
||
|
||
Livewire::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());
|
||
|
||
Livewire::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 = Livewire::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());
|
||
|
||
Livewire::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();
|
||
});
|