presseportale/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.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

258 lines
10 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\Console\Commands\GrandfatherLegacySubscriptions;
use App\Enums\UserPaymentOptionStatus;
use App\Models\BillingAddress;
use App\Models\Invoice;
use App\Models\LegacyInvoice;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\ManualInvoiceService;
use Illuminate\Support\Carbon;
function legacyArchiveInvoice(User $user, array $overrides = []): LegacyInvoice
{
static $legacyId = 1000;
$defaults = [
'legacy_portal' => 'presseecho',
'legacy_id' => ++$legacyId,
'user_id' => $user->id,
'legacy_user_id' => 90000 + $legacyId,
'number' => 'PE-'.$legacyId,
'amount_cents' => 4900,
'tax_cents' => 0,
'total_cents' => 4900,
'status' => 'paid',
'invoice_date' => '2025-08-01',
'raw_snapshot' => [
'is_netto' => false,
'service_period_begin_date' => '2025-08-01',
'service_period_end_date' => '2026-07-31',
],
'pdf_payload' => [
'user_payment_option' => [
'id' => 42,
'status' => 'active',
'next_due_date' => '2026-08-01',
'valid_until_date' => null,
'payment_option_id' => 1,
],
'payment_option' => [
'id' => 1,
'type' => 'recurring',
'article_number' => 'PK-01',
],
'payment_option_translation' => ['name' => 'Pressemappe klein'],
],
'imported_at' => now(),
];
return LegacyInvoice::query()->create(array_replace_recursive($defaults, $overrides));
}
beforeEach(function (): void {
Carbon::setTestNow('2026-06-12 09:00:00');
});
afterEach(function (): void {
Carbon::setTestNow();
});
test('an active recurring legacy agreement is migrated as grandfathered with the legacy rhythm', function () {
$user = User::factory()->create();
legacyArchiveInvoice($user);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
$agreement = UserPaymentOption::sole();
expect($agreement->user_id)->toBe($user->id);
expect($agreement->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($agreement->stripe_subscription_id)->toBeNull();
expect($agreement->current_period_start->toDateString())->toBe('2025-08-01');
expect($agreement->current_period_end->toDateString())->toBe('2026-08-01');
expect($agreement->legacy_conditions['interval'])->toBe('yearly');
// Legacy fakturierte brutto: 49,00 € inkl. USt → Netto-Basis 41,18 €.
expect($agreement->legacy_conditions['net_cents'])->toBe(4118);
expect($agreement->legacy_conditions['last_total_cents'])->toBe(4900);
expect($agreement->legacy_conditions['legacy_user_payment_option_id'])->toBe(42);
$catalog = PaymentOption::query()->where('article_number', 'LEGACY-PE-PK-01')->sole();
expect($catalog->is_hidden)->toBeTrue();
});
test('single-type and inactive legacy agreements stay in the archive', function () {
$user = User::factory()->create();
legacyArchiveInvoice($user, [
'pdf_payload' => ['payment_option' => ['type' => 'single']],
]);
legacyArchiveInvoice($user, [
'pdf_payload' => ['user_payment_option' => ['id' => 43, 'status' => 'canceled']],
]);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
expect(UserPaymentOption::count())->toBe(0);
});
test('the latest invoice per agreement wins', function () {
$user = User::factory()->create();
legacyArchiveInvoice($user, [
'invoice_date' => '2024-08-01',
'total_cents' => 3900,
'amount_cents' => 3900,
'raw_snapshot' => ['service_period_begin_date' => '2024-08-01', 'service_period_end_date' => '2025-07-31'],
'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2025-08-01']],
]);
legacyArchiveInvoice($user);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
$agreement = UserPaymentOption::sole();
expect($agreement->current_period_end->toDateString())->toBe('2026-08-01');
expect($agreement->legacy_conditions['last_total_cents'])->toBe(4900);
});
test('a legacy net invoice keeps its amount as the net base', function () {
$user = User::factory()->create();
legacyArchiveInvoice($user, [
'amount_cents' => 16723,
'total_cents' => 16723,
'raw_snapshot' => ['is_netto' => true],
]);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
expect(UserPaymentOption::sole()->legacy_conditions['net_cents'])->toBe(16723);
});
test('re-running updates the agreement instead of duplicating it (pre-relaunch replay)', function () {
$user = User::factory()->create();
legacyArchiveInvoice($user);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
// Kurz vor dem Relaunch kommt eine neuere Rechnung in den Snapshot.
legacyArchiveInvoice($user, [
'invoice_date' => '2026-06-10',
'raw_snapshot' => ['service_period_begin_date' => '2026-06-10', 'service_period_end_date' => '2027-06-09'],
'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2027-06-10']],
]);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
$agreement = UserPaymentOption::sole();
expect($agreement->current_period_end->toDateString())->toBe('2027-06-10');
});
test('agreements overdue beyond the grace window are skipped as stale', function () {
$user = User::factory()->create();
legacyArchiveInvoice($user, [
'invoice_date' => '2023-08-01',
'raw_snapshot' => ['service_period_begin_date' => '2023-08-01', 'service_period_end_date' => '2024-07-31'],
'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2024-08-01']],
]);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
expect(UserPaymentOption::count())->toBe(0);
});
test('dry-run writes nothing', function () {
legacyArchiveInvoice(User::factory()->create());
$this->artisan(GrandfatherLegacySubscriptions::class, ['--dry-run' => true, '--no-report' => true])
->assertSuccessful();
expect(UserPaymentOption::count())->toBe(0);
expect(PaymentOption::query()->where('article_number', 'like', 'LEGACY-%')->count())->toBe(0);
});
test('after migration the MAN circle invoices a due legacy agreement with proper VAT', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id, 'country_code' => 'DE']);
// Fällig: next_due_date liegt vor dem Stichtag (überfälliger Jahreskunde,
// Legacy-Brutto 199,00 €).
legacyArchiveInvoice($user, [
'invoice_date' => '2025-05-14',
'amount_cents' => 19900,
'tax_cents' => 0,
'total_cents' => 19900,
'raw_snapshot' => ['service_period_begin_date' => '2025-05-14', 'service_period_end_date' => '2026-05-13'],
'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2026-05-14']],
]);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
$service = app(ManualInvoiceService::class);
$due = $service->duePaymentOptions();
expect($due)->toHaveCount(1);
$invoice = $service->invoiceFor($due->first());
// DE-Kunde: Netto 167,23 € + 19 % USt = 199,00 € — Brutto bleibt wie im
// Legacy, die Steuer wird jetzt aber sauber ausgewiesen.
expect($invoice->number)->toBe('MAN-00001');
expect($invoice->amount_cents)->toBe(16723);
expect($invoice->tax_cents)->toBe(3177);
expect($invoice->total_cents)->toBe(19900);
expect($invoice->is_netto)->toBeFalse();
// Periode jährlich weitergeschaltet.
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);
});