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>
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\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();
|
||
});
|