presseportale/tests/Feature/Billing/AdminPaymentsPageTest.php
Kevin Adametz a00f9dc79a billing: MAN-Rechnungen fuer Ueberweisungskunden bearbeiten, hold-Altlast bereinigen
Bestandskunden aus dem Altsystem zahlen per Ueberweisung; nach dem 04:30-Lauf
fehlte bisher der gesamte Bearbeitungsweg (kein PDF, keine Mail, kein
Zahlungseingang, kein Bestandsschutz-Ende). Plan und Befund:
dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md.

Bereinigung (legacy:settle-unpaid, wiederholbar):
- Legacy-Status hold = nie bezahlt, ersatzlos erledigt. 9 daraus abgeleitete
  Bestandsschutz-Buendel werden gekuendigt, MAN-00001/00002 storniert.
  Archivzeilen bleiben erhalten, sind aber in Admin-Archiv und Kundenkonto
  standardmaessig ausgeblendet.
- pending/reminder (recurring) = offene Vorgaenge: Periode auf den
  Leistungsbeginn der unbezahlten Rechnung zurueckgedreht und als MAN-Rechnung
  neu ausgestellt (Archiv-Status superseded, replaces_legacy_invoice_number).
- pending/reminder (single) = abgeschrieben (uncollectible).
- legacy:grandfather-subscriptions wertet den Zahlungsstatus jetzt selbst aus.

Workflow in /admin/payments (ManualInvoiceDunningService, je Aktion mit
Eintrag im Aktivitaets-Verlauf):
- Rechnung senden: Mail mit PDF-Anhang, Bankverbindung aus billing.issuer,
  Hinweis auf Bestandsschutz-Ende; Zahlungsziel zaehlt ab Versand.
- Erinnerung: gleiche Mail im Mahn-Ton, Nachfrist bei verstrichener Frist.
- Bezahlt: Zahlungseingang laut Kontoauszug verbuchen.
- Bestandsschutz beenden: Rechnung storniert (nie geloescht), Buendel
  gekuendigt, weitere offene MAN-Rechnungen des Kunden storniert, optionale
  Kundenmail.
- Panel "Blockierte Vereinbarungen": faellige Posten ohne Rechnung (meist
  fehlende Rechnungsadresse) mit "Jetzt abrechnen" oder "Entfernen".

Schema: invoices bekommt user_payment_option_id, sent_at, reminded_at,
reminder_count, voided_at, void_reason, replaces_legacy_invoice_number
(inkl. Backfill der bestehenden MAN-Rechnungen). PDF-Primitiven des
Archiv-Renderers in den Trait DrawsPdf ausgelagert; MAN-PDF on-demand ueber
admin.invoices.pdf / me.invoices.manual-pdf (InvoicePolicy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:56:29 +00:00

366 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\Volt\Volt as LivewireVolt;
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());
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();
});