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('an enabled invoice email gets its own copy with pdf, the account mail follows its switch', function () { [$invoice, , $customer] = dunningFixture(); BillingAddress::factory()->create([ 'user_id' => $customer->id, 'invoice_email' => 'buchhaltung@example.com', 'invoice_pdf_to_account' => false, 'invoice_pdf_to_invoice_email' => true, ]); app(ManualInvoiceDunningService::class)->send($invoice, dunningAdmin()); Mail::assertQueuedCount(2); Mail::assertQueued(ManualInvoiceIssued::class, function (ManualInvoiceIssued $mail) use ($customer): bool { if ($mail->forInvoiceRecipient) { return false; } $mail->assertSeeInHtml('in Ihrem Konto unter Rechnungen'); return $mail->hasTo($customer->email) && ! $mail->attachPdf && $mail->attachments() === []; }); Mail::assertQueued(ManualInvoiceIssued::class, function (ManualInvoiceIssued $mail): bool { if (! $mail->forInvoiceRecipient) { return false; } $mail->assertSeeInHtml('Diese Adresse ist dort als Empfänger für Rechnungen hinterlegt'); $mail->assertHasAttachedData(app(ManualInvoicePdfRenderer::class)->render($mail->invoice), 'Rechnung-MAN-00010.pdf', ['mime' => 'application/pdf']); return $mail->hasTo('buchhaltung@example.com') && $mail->forInvoiceRecipient && ! $mail->hasBcc((string) config('legal.contact_email')); }); }); 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'); });