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>
This commit is contained in:
Kevin Adametz 2026-09-16 10:56:29 +00:00
parent f581e1eb3a
commit a00f9dc79a
33 changed files with 3606 additions and 195 deletions

View file

@ -68,6 +68,46 @@ test('admin sees legacy invoice archive overview with filters and mapping hints'
->assertSee('BP-2002');
});
test('never-paid hold invoices are hidden by default and can be shown on demand', function () {
$customer = User::factory()->create(['is_active' => true]);
LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 3001,
'user_id' => $customer->id,
'legacy_user_id' => 601,
'number' => 'BP-HOLD-3001',
'amount_cents' => 19900,
'total_cents' => 19900,
'status' => 'hold',
'invoice_date' => now()->subYear(),
'imported_at' => now(),
]);
LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 3002,
'user_id' => $customer->id,
'legacy_user_id' => 601,
'number' => 'BP-REMINDER-3002',
'amount_cents' => 19900,
'total_cents' => 19900,
'status' => 'reminder',
'invoice_date' => now()->subMonths(2),
'imported_at' => now(),
]);
LivewireVolt::test('admin.invoices.index')
->assertSee('BP-REMINDER-3002')
->assertDontSee('BP-HOLD-3001')
->assertSee('1 erledigte hold-Rechnungen')
->set('showHold', true)
->assertSee('BP-HOLD-3001')
->set('showHold', false)
->set('statusFilter', 'hold')
->assertSee('BP-HOLD-3001')
->assertDontSee('BP-REMINDER-3002');
});
test('admin opens generated legacy invoice pdf inline', function () {
/** @var TestCase $this */
$invoice = LegacyInvoice::query()->create([

View file

@ -1,11 +1,19 @@
<?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;
@ -94,11 +102,16 @@ test('local invoices appear with number and circle badge', function () {
$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')
->assertSee('Manuell (MAN)');
->assertSee('MAN-2026-000007');
});
test('paid invoices of the last 30 days are summed as revenue', function () {
@ -148,3 +161,206 @@ test('the payments page is not accessible for customers', function () {
->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();
});

View file

@ -206,3 +206,53 @@ test('after migration the MAN circle invoices a due legacy agreement with proper
expect($due->first()->fresh()->current_period_end->toDateString())->toBe('2027-05-14');
expect(Invoice::count())->toBe(1);
});
test('a never-paid (hold) latest invoice yields no grandfathering', function () {
$user = User::factory()->create();
legacyArchiveInvoice($user, ['status' => 'hold']);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])
->expectsOutputToContain('Übersprungen (hold, nie bezahlt)')
->assertSuccessful();
expect(UserPaymentOption::count())->toBe(0);
});
test('an open (reminder) latest invoice is grandfathered with the unpaid period due and the MAN circle re-issues it', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id, 'country_code' => 'DE']);
// Legacy stellte am 30.06.2026 die Periode 14.06.2026–13.06.2027 in
// Rechnung (199,00 € brutto), im Altsystem bereits gemahnt, nie bezahlt.
$legacy = legacyArchiveInvoice($user, [
'number' => 'BP-42489-10',
'legacy_portal' => 'businessportal24',
'status' => 'reminder',
'invoice_date' => '2026-06-30',
'total_cents' => 19900,
'raw_snapshot' => ['service_period_begin_date' => '2026-06-14', 'service_period_end_date' => '2027-06-13'],
'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2027-06-14']],
]);
Carbon::setTestNow('2026-09-16 09:00:00');
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
$agreement = UserPaymentOption::sole();
expect($agreement->status)->toBe(UserPaymentOptionStatus::Grandfathered);
// Fällig ist der Leistungsbeginn der unbezahlten Rechnung, nicht 2027.
expect($agreement->current_period_end->toDateString())->toBe('2026-06-14');
expect($agreement->current_period_start->toDateString())->toBe('2025-06-14');
expect($agreement->legacy_conditions['open_legacy_invoice']['number'])->toBe('BP-42489-10');
$service = app(ManualInvoiceService::class);
$invoice = $service->invoiceFor($agreement);
expect($invoice)->not->toBeNull();
expect($invoice->replaces_legacy_invoice_number)->toBe('BP-42489-10');
expect($invoice->user_payment_option_id)->toBe($agreement->id);
expect($invoice->total_cents)->toBe(19900);
expect($agreement->fresh()->current_period_end->toDateString())->toBe('2027-06-14');
expect($agreement->fresh()->legacy_conditions)->not->toHaveKey('open_legacy_invoice');
expect($legacy->fresh()->status)->toBe(LegacyInvoice::STATUS_SUPERSEDED);
expect($legacy->fresh()->raw_snapshot['superseded_by_invoice_number'])->toBe($invoice->number);
});

View file

@ -0,0 +1,223 @@
<?php
use App\Console\Commands\SettleUnpaidLegacyInvoices;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\ActivityEvent;
use App\Models\BillingAddress;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\LegacyInvoice;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use Illuminate\Support\Carbon;
/**
* Bestandsschutz-Posten samt Quellrechnung im Archiv, so wie die
* Grandfather-Migration ihn vor der Korrektur angelegt hat.
*
* @return array{0: UserPaymentOption, 1: LegacyInvoice}
*/
function settleFixture(User $user, string $status, array $invoiceOverrides = [], array $postOverrides = []): array
{
static $legacyId = 5000;
$legacyId++;
$legacy = LegacyInvoice::query()->create(array_replace_recursive([
'legacy_portal' => 'businessportal24',
'legacy_id' => $legacyId,
'user_id' => $user->id,
'legacy_user_id' => 70000 + $legacyId,
'number' => 'BP-'.$legacyId,
'amount_cents' => 19900,
'tax_cents' => 0,
'total_cents' => 19900,
'status' => $status,
'invoice_date' => '2026-06-30',
'raw_snapshot' => [
'is_netto' => false,
'service_period_begin_date' => '2026-06-14',
'service_period_end_date' => '2027-06-13',
],
'pdf_payload' => [
'user_payment_option' => ['id' => $legacyId, 'status' => 'active', 'next_due_date' => '2027-06-14'],
'payment_option' => ['id' => 7, 'type' => 'recurring', 'article_number' => 'BP-01'],
'payment_option_translation' => ['name' => 'Unbegrenzte PMs'],
],
'imported_at' => now(),
], $invoiceOverrides));
$post = UserPaymentOption::factory()->create(array_replace([
'user_id' => $user->id,
'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly'])->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => null,
'stripe_subscription_id' => null,
'cancelled_at' => null,
'current_period_start' => '2026-06-14',
'current_period_end' => '2027-06-14',
'legacy_conditions' => [
'legacy_portal' => 'businessportal24',
'interval' => 'yearly',
'net_cents' => 16723,
'name' => 'Unbegrenzte PMs',
'source_invoice_number' => $legacy->number,
],
], $postOverrides));
return [$post, $legacy];
}
function manInvoiceFor(UserPaymentOption $post, string $number): Invoice
{
return Invoice::factory()->create([
'user_id' => $post->user_id,
'user_payment_option_id' => $post->id,
'user_payment_id' => null,
'invoice_billing_address_id' => InvoiceBillingAddress::factory(),
'number' => $number,
'status' => InvoiceStatus::Open->value,
'stripe_invoice_id' => null,
'total_cents' => 19900,
]);
}
beforeEach(function (): void {
Carbon::setTestNow('2026-09-16 09:00:00');
});
afterEach(function (): void {
Carbon::setTestNow();
});
test('hold-derived bundles are cancelled and their open MAN invoices voided', function () {
$user = User::factory()->create();
[$post] = settleFixture($user, 'hold');
$invoice = manInvoiceFor($post, 'MAN-00001');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect($post->fresh()->cancelled_at)->not->toBeNull();
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Void);
expect($invoice->fresh()->voided_at)->not->toBeNull();
expect($invoice->fresh()->void_reason)->toContain('nie bezahlt');
expect(ActivityEvent::query()->where('type', 'manual_invoice')->count())->toBe(1);
expect(ActivityEvent::query()->where('type', 'subscription_change')->count())->toBe(1);
});
test('the hold archive rows stay untouched', function () {
$user = User::factory()->create();
[, $legacy] = settleFixture($user, 'hold');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($legacy->fresh()->status)->toBe('hold');
expect(LegacyInvoice::count())->toBe(1);
});
test('paid-derived posts are left alone', function () {
$user = User::factory()->create();
[$post] = settleFixture($user, 'paid');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($post->fresh()->current_period_end->toDateString())->toBe('2027-06-14');
expect(Invoice::count())->toBe(0);
});
test('open reminder posts are rewound and re-issued as MAN invoices replacing the legacy one', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id, 'country_code' => 'DE']);
[$post, $legacy] = settleFixture($user, 'reminder');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
$invoice = Invoice::query()->manual()->sole();
expect($invoice->status)->toBe(InvoiceStatus::Open);
expect($invoice->user_payment_option_id)->toBe($post->id);
expect($invoice->replaces_legacy_invoice_number)->toBe($legacy->number);
expect($invoice->invoice_date->toDateString())->toBe('2026-09-16');
expect($invoice->amount_cents)->toBe(16723);
expect($invoice->total_cents)->toBe(19900);
// Rhythmus bleibt: die neu ausgestellte Periode endet wie im Altsystem 2027.
expect($post->fresh()->current_period_start->toDateString())->toBe('2026-06-14');
expect($post->fresh()->current_period_end->toDateString())->toBe('2027-06-14');
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($legacy->fresh()->status)->toBe(LegacyInvoice::STATUS_SUPERSEDED);
});
test('an open post without billing address is rewound and reported as conflict for the daily run', function () {
$user = User::factory()->create();
[$post, $legacy] = settleFixture($user, 'pending');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])
->expectsOutputToContain('Konflikt')
->assertSuccessful();
expect(Invoice::count())->toBe(0);
expect($post->fresh()->current_period_end->toDateString())->toBe('2026-06-14');
expect($post->fresh()->legacy_conditions['open_legacy_invoice']['number'])->toBe($legacy->number);
expect($legacy->fresh()->status)->toBe('pending');
});
test('a mixed bundle (hold + paid) is not cancelled but reported', function () {
$user = User::factory()->create();
[$holdPost] = settleFixture($user, 'hold');
[$paidPost] = settleFixture($user, 'paid', ['legacy_portal' => 'presseecho'], []);
$paidPost->update(['legacy_conditions' => array_merge($paidPost->legacy_conditions, ['legacy_portal' => 'presseecho'])]);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])
->expectsOutputToContain('gemischten Bündel')
->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($paidPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
});
test('open single purchases are written off', function () {
$user = User::factory()->create();
$single = LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 9001,
'user_id' => $user->id,
'legacy_user_id' => 79001,
'number' => 'BP-9001',
'amount_cents' => 1995,
'tax_cents' => 0,
'total_cents' => 1995,
'status' => 'pending',
'invoice_date' => '2026-07-08',
'raw_snapshot' => [],
'pdf_payload' => ['payment_option' => ['type' => 'single'], 'user_payment_option' => ['id' => 1, 'status' => 'active']],
'imported_at' => now(),
]);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($single->fresh()->status)->toBe(LegacyInvoice::STATUS_UNCOLLECTIBLE);
});
test('dry-run writes nothing and a second real run is a no-op', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id]);
[$holdPost] = settleFixture($user, 'hold');
$other = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $other->id]);
settleFixture($other, 'reminder');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--dry-run' => true, '--no-report' => true])->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect(Invoice::count())->toBe(0);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect(Invoice::count())->toBe(1);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect(Invoice::count())->toBe(1);
expect(UserPaymentOption::query()->where('status', UserPaymentOptionStatus::Cancelled->value)->count())->toBe(1);
});

View file

@ -0,0 +1,265 @@
<?php
use App\Enums\EmailSuppressionReason;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Exceptions\ManualInvoiceActionException;
use App\Mail\GrandfatheringTerminated;
use App\Mail\ManualInvoiceIssued;
use App\Models\ActivityEvent;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\LegacyInvoice;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\ManualInvoiceDunningService;
use App\Services\Billing\ManualInvoicePdfRenderer;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
beforeEach(function (): void {
Carbon::setTestNow('2026-09-16 10:00:00');
Mail::fake();
});
afterEach(function (): void {
Carbon::setTestNow();
});
/**
* @return array{0: Invoice, 1: UserPaymentOption, 2: User}
*/
function dunningFixture(array $invoiceOverrides = []): array
{
$customer = User::factory()->create(['name' => 'Karla Kunde']);
$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' => '2026-09-16',
'current_period_end' => '2027-09-16',
'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-00010',
'status' => InvoiceStatus::Open->value,
'amount_cents' => 16723,
'tax_cents' => 3177,
'total_cents' => 19900,
'invoice_date' => '2026-09-16',
'due_date' => '2026-09-30',
'stripe_invoice_id' => null,
'sent_at' => null,
...$invoiceOverrides,
]);
return [$invoice, $agreement, $customer];
}
function dunningAdmin(): User
{
return User::factory()->create(['name' => 'Admin Alma']);
}
test('sending an invoice mails the pdf, sets the due date from today and records the activity', function () {
Carbon::setTestNow('2026-09-20 10:00:00');
[$invoice, , $customer] = dunningFixture();
$sent = app(ManualInvoiceDunningService::class)->send($invoice, dunningAdmin());
expect($sent->sent_at)->not->toBeNull();
expect($sent->due_date->toDateString())->toBe('2026-10-04');
expect($sent->status)->toBe(InvoiceStatus::Open);
Mail::assertQueued(ManualInvoiceIssued::class, function (ManualInvoiceIssued $mail) use ($customer, $invoice): bool {
$mail->assertHasTo($customer->email);
$mail->assertHasBcc(config('legal.contact_email'));
$mail->assertHasSubject('Ihre Rechnung MAN-00010 — Bestandsschutz auf pressekonto');
$mail->assertSeeInHtml('Rechnungsnummer MAN-00010');
$mail->assertSeeInHtml('04.10.2026');
$mail->assertSeeInHtml('Bestandsschutz');
$mail->assertSeeInHtml('DE96 4805 0161 0065 0356 02');
$mail->assertHasAttachedData(app(ManualInvoicePdfRenderer::class)->render($invoice->fresh()), 'Rechnung-MAN-00010.pdf', ['mime' => 'application/pdf']);
return $mail->isReminder === false;
});
$event = ActivityEvent::query()->where('type', 'manual_invoice')->sole();
expect($event->description)->toContain('MAN-00010')->toContain('04.10.2026');
expect($event->actor_label)->toBe('Admin Alma');
});
test('a sent invoice cannot be sent twice but can be reminded', function () {
[$invoice] = dunningFixture(['sent_at' => now()->subDays(20), 'due_date' => '2026-09-10']);
$service = app(ManualInvoiceDunningService::class);
expect(fn () => $service->send($invoice, dunningAdmin()))
->toThrow(ManualInvoiceActionException::class, 'bereits am');
$reminded = $service->remind($invoice, dunningAdmin());
expect($reminded->reminder_count)->toBe(1);
expect($reminded->reminded_at)->not->toBeNull();
// Frist war verstrichen → Nachfrist 7 Tage ab heute.
expect($reminded->due_date->toDateString())->toBe('2026-09-23');
Mail::assertQueued(ManualInvoiceIssued::class, function (ManualInvoiceIssued $mail): bool {
$mail->assertHasSubject('Zahlungserinnerung — Rechnung MAN-00010');
$mail->assertSeeInHtml('keinen Zahlungseingang');
return $mail->isReminder === true;
});
});
test('a reminder keeps a due date that is still in the future', function () {
[$invoice] = dunningFixture(['sent_at' => now()->subDay(), 'due_date' => '2026-09-29']);
$reminded = app(ManualInvoiceDunningService::class)->remind($invoice, dunningAdmin());
expect($reminded->due_date->toDateString())->toBe('2026-09-29');
});
test('an unsent invoice cannot be reminded', function () {
[$invoice] = dunningFixture();
expect(fn () => app(ManualInvoiceDunningService::class)->remind($invoice, dunningAdmin()))
->toThrow(ManualInvoiceActionException::class, 'noch nicht versendet');
Mail::assertNothingQueued();
});
test('a suppressed recipient blocks sending with a clear message and leaves the invoice untouched', function () {
[$invoice, , $customer] = dunningFixture();
app(EmailSuppressionService::class)->suppress($customer->email, EmailSuppressionReason::HardBounce, 'test');
expect(fn () => app(ManualInvoiceDunningService::class)->send($invoice, dunningAdmin()))
->toThrow(ManualInvoiceActionException::class, 'Sperrliste');
expect($invoice->fresh()->sent_at)->toBeNull();
expect($invoice->fresh()->due_date->toDateString())->toBe('2026-09-30');
Mail::assertNothingQueued();
});
test('stripe and non-open invoices are rejected', function () {
[$stripeInvoice] = dunningFixture(['stripe_invoice_id' => 'in_123', 'number' => 'STR-00001']);
[$paidInvoice] = dunningFixture(['status' => InvoiceStatus::Paid->value, 'number' => 'MAN-00011']);
$service = app(ManualInvoiceDunningService::class);
expect(fn () => $service->send($stripeInvoice, dunningAdmin()))->toThrow(ManualInvoiceActionException::class, 'Stripe');
expect(fn () => $service->markPaid($paidInvoice, now(), dunningAdmin()))->toThrow(ManualInvoiceActionException::class, 'nicht mehr offen');
});
test('marking as paid stores status and payment date', function () {
[$invoice] = dunningFixture(['sent_at' => now()->subDays(3)]);
$paid = app(ManualInvoiceDunningService::class)->markPaid($invoice, Carbon::parse('2026-09-15'), dunningAdmin());
expect($paid->status)->toBe(InvoiceStatus::Paid);
expect($paid->paid_at->toDateString())->toBe('2026-09-15');
expect(ActivityEvent::query()->where('type', 'manual_invoice')->sole()->description)->toContain('bezahlt');
expect(fn () => app(ManualInvoiceDunningService::class)->markPaid(dunningFixture(['number' => 'MAN-00012'])[0], now()->addDay(), dunningAdmin()))
->toThrow(ManualInvoiceActionException::class, 'Zukunft');
});
test('terminating for non-payment voids the invoice and siblings, cancels the bundle and notifies the customer', function () {
[$invoice, $agreement, $customer] = dunningFixture(['sent_at' => now()->subDays(20), 'due_date' => '2026-09-10']);
$sibling = Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'invoice_billing_address_id' => InvoiceBillingAddress::factory(),
'number' => 'MAN-00013',
'status' => InvoiceStatus::Open->value,
'stripe_invoice_id' => null,
]);
$secondPost = UserPaymentOption::factory()->create([
'user_id' => $customer->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'stripe_subscription_id' => null,
'cancelled_at' => null,
]);
$foreignInvoice = dunningFixture(['number' => 'MAN-00099'])[0];
$cancelled = app(ManualInvoiceDunningService::class)->terminateForNonPayment($invoice, dunningAdmin(), notifyCustomer: true, note: 'Telefonisch nicht erreichbar.');
expect($cancelled)->toBe(2);
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Void);
expect($invoice->fresh()->voided_at)->not->toBeNull();
expect($invoice->fresh()->void_reason)->toContain('10.09.2026')->toContain('Telefonisch nicht erreichbar.');
expect($sibling->fresh()->status)->toBe(InvoiceStatus::Void);
expect($foreignInvoice->fresh()->status)->toBe(InvoiceStatus::Open);
expect($agreement->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect($secondPost->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
Mail::assertQueued(GrandfatheringTerminated::class, function (GrandfatheringTerminated $mail) use ($customer): bool {
$mail->assertHasTo($customer->email);
$mail->assertSeeInHtml('MAN-00010');
$mail->assertSeeInHtml('bleiben unverändert online');
return true;
});
expect(ActivityEvent::query()->where('type', 'manual_invoice')->count())->toBe(1);
expect(ActivityEvent::query()->where('type', 'subscription_change')->count())->toBe(1);
});
test('terminating without notification sends no mail', function () {
[$invoice] = dunningFixture(['sent_at' => now()->subDays(20)]);
app(ManualInvoiceDunningService::class)->terminateForNonPayment($invoice, dunningAdmin(), notifyCustomer: false);
Mail::assertNotQueued(GrandfatheringTerminated::class);
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Void);
});
test('abandoning a blocked agreement cancels the bundle, writes off the open legacy invoice and sends no mail', function () {
[$invoice, $agreement, $customer] = dunningFixture();
$legacy = LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 8801,
'user_id' => $customer->id,
'legacy_user_id' => 78801,
'number' => '48505-5',
'amount_cents' => 19900,
'tax_cents' => 0,
'total_cents' => 19900,
'status' => 'reminder',
'invoice_date' => '2026-06-30',
'raw_snapshot' => [],
'pdf_payload' => [],
'imported_at' => now(),
]);
$agreement->update(['legacy_conditions' => array_merge($agreement->legacy_conditions, [
'open_legacy_invoice' => ['id' => $legacy->id, 'number' => $legacy->number],
])]);
$cancelled = app(ManualInvoiceDunningService::class)->abandonAgreement($agreement, dunningAdmin(), 'E-Mail nicht erreichbar.');
expect($cancelled)->toBe(1);
expect($agreement->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect($legacy->fresh()->status)->toBe(LegacyInvoice::STATUS_UNCOLLECTIBLE);
// Eine eventuell schon erzeugte offene MAN-Rechnung wird mitstorniert.
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Void);
expect($invoice->fresh()->void_reason)->toContain('E-Mail nicht erreichbar.');
Mail::assertNothingQueued();
$event = ActivityEvent::query()->where('type', 'subscription_change')->sole();
expect($event->description)->toContain('entfernt')->toContain('48505-5');
});
test('a stripe agreement cannot be abandoned here', function () {
[, $agreement] = dunningFixture();
$agreement->update(['stripe_subscription_id' => 'sub_123']);
expect(fn () => app(ManualInvoiceDunningService::class)->abandonAgreement($agreement, dunningAdmin()))
->toThrow(ManualInvoiceActionException::class, 'Stripe');
});

View file

@ -0,0 +1,142 @@
<?php
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\ManualInvoicePdfRenderer;
use Database\Seeders\RolesAndPermissionsSeeder;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function manualPdfInvoice(array $overrides = []): Invoice
{
$user = User::factory()->create();
$agreement = UserPaymentOption::factory()->create([
'user_id' => $user->id,
'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly'])->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'stripe_subscription_id' => null,
'current_period_start' => '2026-08-07',
'current_period_end' => '2027-08-07',
'legacy_conditions' => ['name' => 'Unbegrenzte PMs', 'legacy_portal' => 'businessportal24', 'interval' => 'yearly', 'net_cents' => 16723],
]);
return Invoice::factory()->create([
'user_id' => $user->id,
'user_payment_id' => null,
'user_payment_option_id' => $agreement->id,
'invoice_billing_address_id' => InvoiceBillingAddress::factory()->create([
'company' => 'Muster GmbH',
'name' => 'Erika Muster',
'salutation_key' => 'mrs',
'address1' => 'Beispielweg 1',
'postal_code' => '33739',
'city' => 'Bielefeld',
'country_code' => 'DE',
'vat_id' => 'DE123456789',
])->id,
'number' => 'MAN-00042',
'status' => InvoiceStatus::Open->value,
'amount_cents' => 16723,
'tax_cents' => 3177,
'total_cents' => 19900,
'is_netto' => false,
'tax_note' => null,
'invoice_date' => '2026-09-16',
'due_date' => '2026-09-30',
'stripe_invoice_id' => null,
'replaces_legacy_invoice_number' => '42489-10',
...$overrides,
]);
}
/**
* Textläufe aus dem PDF-Stream (nur zum Prüfen des Inhalts).
*/
function pdfTextRuns(string $pdf): string
{
preg_match_all('/\((.*?)\) Tj/', $pdf, $matches);
return implode("\n", array_map(fn (string $run): string => mb_convert_encoding($run, 'UTF-8', 'Windows-1252'), $matches[1]));
}
test('the MAN invoice pdf carries issuer, recipient, service period, amounts, due date and bank details', function () {
$invoice = manualPdfInvoice();
$pdf = app(ManualInvoicePdfRenderer::class)->render($invoice);
$text = pdfTextRuns($pdf);
expect($pdf)->toStartWith('%PDF-1.4');
expect($text)
->toContain('adametz.media')
->toContain('Muster GmbH')
->toContain('Frau Erika Muster')
->toContain('USt-ID: DE123456789')
->toContain('Unbegrenzte PMs auf Businessportal24')
->toContain('Leistungszeitraum: 07.08.2026 - 06.08.2027')
->toContain('Rechnungsnummer: MAN-00042')
->toContain('Ersetzt die Rechnung Nr. 42489-10')
->toContain('167,23')
->toContain('MwSt. 19%:')
->toContain('199,00')
->toContain('bis zum 30.09.2026')
->toContain('IBAN: DE96 4805 0161 0065 0356 02')
->toContain('Rechnungsnummer MAN-00042');
});
test('a tax exempt invoice omits the VAT row and prints the tax note', function () {
$invoice = manualPdfInvoice([
'tax_cents' => 0,
'total_cents' => 16723,
'is_netto' => true,
'tax_note' => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge, Art. 196 MwStSystRL).',
]);
$text = pdfTextRuns(app(ManualInvoicePdfRenderer::class)->render($invoice));
expect($text)->not->toContain('MwSt.')
->toContain('Reverse Charge');
});
test('the customer downloads the own MAN pdf, not foreign or stripe invoices', function () {
/** @var TestCase $this */
$invoice = manualPdfInvoice();
$invoice->user->assignRole('customer');
$stranger = User::factory()->create(['is_active' => true]);
$stranger->assignRole('customer');
$this->actingAs($invoice->user)
->get(route('me.invoices.manual-pdf', $invoice))
->assertOk()
->assertHeader('Content-Type', 'application/pdf');
$this->actingAs($stranger)
->get(route('me.invoices.manual-pdf', $invoice))
->assertForbidden();
$stripeInvoice = manualPdfInvoice(['stripe_invoice_id' => 'in_test_123', 'number' => 'STR-00001']);
$stripeInvoice->user->assignRole('customer');
$this->actingAs($stripeInvoice->user)
->get(route('me.invoices.manual-pdf', $stripeInvoice))
->assertNotFound();
});
test('billing admins download any MAN pdf via the admin route', function () {
/** @var TestCase $this */
$invoice = manualPdfInvoice();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.invoices.pdf', $invoice))
->assertOk()
->assertHeader('Content-Type', 'application/pdf');
});

View file

@ -135,11 +135,26 @@ test('customer sees only own legacy invoices', function () {
'imported_at' => now(),
]);
// Nie bezahlte Alt-Rechnung (hold): erledigt, erscheint dem Kunden nicht.
LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 1003,
'user_id' => $customer->id,
'legacy_user_id' => 501,
'number' => 'RE-CUSTOMER-HOLD',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'hold',
'invoice_date' => now()->subYears(2),
'imported_at' => now(),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.invoices')
->assertSee('RE-CUSTOMER-001')
->assertDontSee('RE-OTHER-001')
->assertDontSee('RE-CUSTOMER-HOLD')
->assertSee('99,00 €');
});