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:
parent
f581e1eb3a
commit
a00f9dc79a
33 changed files with 3606 additions and 195 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
223
tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
Normal file
223
tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
Normal 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);
|
||||
});
|
||||
265
tests/Feature/Billing/ManualInvoiceDunningTest.php
Normal file
265
tests/Feature/Billing/ManualInvoiceDunningTest.php
Normal 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');
|
||||
});
|
||||
142
tests/Feature/Billing/ManualInvoicePdfTest.php
Normal file
142
tests/Feature/Billing/ManualInvoicePdfTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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 €');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue