Kundenwunsch 06.10.2026: Ein Kunde kam über die Stripe-Rechnungsseite nicht an seine Rechnung und fragte nach Belegen und einer Rechnungs-E-Mail. - Profil ▸ Rechnungsadresse ▸ Rechnungsversand: Schalter „Rechnungs-PDF an die Konto-E-Mail" (Standard an), optionale „E-Mail für Rechnungen" mit Schalter „Rechnungen als PDF an diese Adresse senden" (Standard an). Neue Spalten an billing_addresses (klein, ohne FULLTEXT). - InvoiceDelivery entscheidet Empfänger und Anhang; gleiche Adresse wie das Konto ergibt eine Mail mit PDF. - Zahlungsbestätigung (STR) und Bestandskunden-Rechnung/-Erinnerung (MAN) folgen der Einstellung; die Fassung an die Rechnungs-E-Mail nennt das Konto, ohne Blindkopie und ohne Login-Button. - StripeInvoicePdf holt das Stripe-PDF einmal und legt es lokal ab (storage/app/private/invoices/stripe); Mailversand wiederholt bei Stripe-Ausfall (5 Versuche). - Rechnungsseite: „PDF öffnen" aus pressekonto, „Beleg bei Stripe" für den Zahlungsbeleg auf der Stripe-Seite. Suite 2.340 grün, einer übersprungen; Larastan und Pint ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
13 KiB
PHP
299 lines
13 KiB
PHP
<?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\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 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('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');
|
|
});
|