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>
142 lines
5 KiB
PHP
142 lines
5 KiB
PHP
<?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');
|
|
});
|