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>
306 lines
12 KiB
PHP
306 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Enums\InvoiceStatus;
|
|
use App\Exceptions\ManualInvoiceActionException;
|
|
use App\Mail\GrandfatheringTerminated;
|
|
use App\Mail\ManualInvoiceIssued;
|
|
use App\Models\Invoice;
|
|
use App\Models\LegacyInvoice;
|
|
use App\Models\User;
|
|
use App\Models\UserPaymentOption;
|
|
use App\Services\ActivityLog;
|
|
use App\Services\Mail\EmailSuppressionService;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
/**
|
|
* Bearbeitungsweg einer MAN-Rechnung nach der Ausstellung (Legacy-
|
|
* Überweisungskunden, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md):
|
|
*
|
|
* senden → (erinnern) → als bezahlt markieren
|
|
* → Bestandsschutz beenden (Rechnung storniert, Bündel gekündigt)
|
|
*
|
|
* Jede Aktion ist ein Admin-Klick, läuft transaktional und hinterlässt
|
|
* einen Eintrag im Aktivitäts-Verlauf. Die Zahlungsfrist zählt ab Versand
|
|
* (Entscheidung E1), nicht ab dem 04:30-Lauf.
|
|
*/
|
|
class ManualInvoiceDunningService
|
|
{
|
|
public function __construct(
|
|
private readonly EmailSuppressionService $suppressions,
|
|
private readonly LegacySubscriptionService $legacy,
|
|
private readonly ActivityLog $activityLog,
|
|
) {}
|
|
|
|
/**
|
|
* Erstversand: Rechnung per Mail mit PDF, Zahlungsziel ab heute.
|
|
*/
|
|
public function send(Invoice $invoice, User $actor): Invoice
|
|
{
|
|
$this->assertOpenManual($invoice);
|
|
$this->assertDeliverable($invoice);
|
|
|
|
if ($invoice->sent_at !== null) {
|
|
throw new ManualInvoiceActionException(sprintf(
|
|
'Rechnung %s wurde bereits am %s versendet — bitte „Erinnerung senden" verwenden.',
|
|
$invoice->number,
|
|
$invoice->sent_at->format('d.m.Y'),
|
|
));
|
|
}
|
|
|
|
$dueDate = today()->addDays((int) config('billing.manual_due_days', 14));
|
|
|
|
DB::transaction(function () use ($invoice, $actor, $dueDate): void {
|
|
$invoice->update([
|
|
'sent_at' => now(),
|
|
'due_date' => $dueDate,
|
|
]);
|
|
|
|
Mail::to($invoice->user)->queue(new ManualInvoiceIssued($invoice->user, $invoice));
|
|
|
|
$this->activityLog->record(
|
|
ActivityType::ManualInvoice,
|
|
sprintf('Rechnung %s an %s gesendet, zahlbar bis %s.', $invoice->number, $invoice->user->email, $dueDate->format('d.m.Y')),
|
|
$invoice->user,
|
|
$invoice,
|
|
['action' => 'send', 'due_date' => $dueDate->toDateString()],
|
|
actorLabel: $actor->name,
|
|
);
|
|
});
|
|
|
|
return $invoice->refresh();
|
|
}
|
|
|
|
/**
|
|
* Zahlungserinnerung mit kurzer Nachfrist. Setzt die Frist nur neu, wenn
|
|
* die ursprüngliche bereits verstrichen ist.
|
|
*/
|
|
public function remind(Invoice $invoice, User $actor): Invoice
|
|
{
|
|
$this->assertOpenManual($invoice);
|
|
$this->assertDeliverable($invoice);
|
|
|
|
if ($invoice->sent_at === null) {
|
|
throw new ManualInvoiceActionException(sprintf(
|
|
'Rechnung %s wurde noch nicht versendet — bitte zuerst „Rechnung senden".',
|
|
$invoice->number,
|
|
));
|
|
}
|
|
|
|
$graceDays = (int) config('billing.reminder_due_days', 7);
|
|
$dueDate = $invoice->due_date !== null && $invoice->due_date->isAfter(today())
|
|
? $invoice->due_date
|
|
: today()->addDays($graceDays);
|
|
|
|
DB::transaction(function () use ($invoice, $actor, $dueDate): void {
|
|
$invoice->update([
|
|
'reminded_at' => now(),
|
|
'reminder_count' => $invoice->reminder_count + 1,
|
|
'due_date' => $dueDate,
|
|
]);
|
|
|
|
Mail::to($invoice->user)->queue(new ManualInvoiceIssued($invoice->user, $invoice, isReminder: true));
|
|
|
|
$this->activityLog->record(
|
|
ActivityType::ManualInvoice,
|
|
sprintf('Zahlungserinnerung (%d.) zu Rechnung %s gesendet, zahlbar bis %s.', $invoice->reminder_count, $invoice->number, $dueDate->format('d.m.Y')),
|
|
$invoice->user,
|
|
$invoice,
|
|
['action' => 'remind', 'due_date' => $dueDate->toDateString(), 'reminder_count' => $invoice->reminder_count],
|
|
actorLabel: $actor->name,
|
|
);
|
|
});
|
|
|
|
return $invoice->refresh();
|
|
}
|
|
|
|
/**
|
|
* Zahlungseingang per Kontoauszug verbucht.
|
|
*/
|
|
public function markPaid(Invoice $invoice, Carbon $paidAt, User $actor): Invoice
|
|
{
|
|
$this->assertOpenManual($invoice);
|
|
|
|
if ($paidAt->isAfter(now())) {
|
|
throw new ManualInvoiceActionException('Das Zahlungsdatum darf nicht in der Zukunft liegen.');
|
|
}
|
|
|
|
DB::transaction(function () use ($invoice, $actor, $paidAt): void {
|
|
$invoice->update([
|
|
'status' => InvoiceStatus::Paid->value,
|
|
'paid_at' => $paidAt,
|
|
]);
|
|
|
|
$this->activityLog->record(
|
|
ActivityType::ManualInvoice,
|
|
sprintf('Rechnung %s als bezahlt markiert (Zahlungseingang %s, %s €).', $invoice->number, $paidAt->format('d.m.Y'), number_format($invoice->total_cents / 100, 2, ',', '.')),
|
|
$invoice->user,
|
|
$invoice,
|
|
['action' => 'mark_paid', 'paid_at' => $paidAt->toDateString()],
|
|
actorLabel: $actor->name,
|
|
);
|
|
});
|
|
|
|
return $invoice->refresh();
|
|
}
|
|
|
|
/**
|
|
* Keine Zahlung: Rechnung stornieren, Bestandsschutz-Bündel des Kunden
|
|
* kündigen, weitere offene MAN-Rechnungen desselben Kunden ebenfalls
|
|
* stornieren (ein Kunde, ein Bündel, ein Vorgang).
|
|
*
|
|
* @return int Anzahl der gekündigten Bestandsschutz-Posten
|
|
*/
|
|
public function terminateForNonPayment(Invoice $invoice, User $actor, bool $notifyCustomer = true, ?string $note = null): int
|
|
{
|
|
$this->assertOpenManual($invoice);
|
|
|
|
$user = $invoice->user;
|
|
$reason = trim('Bestandsschutz beendet: keine Zahlung bis zum Zahlungsziel'
|
|
.($invoice->due_date ? ' ('.$invoice->due_date->format('d.m.Y').')' : '')
|
|
.'.'.($note ? ' '.$note : ''));
|
|
|
|
return DB::transaction(function () use ($invoice, $user, $actor, $reason, $notifyCustomer): int {
|
|
$siblings = Invoice::query()
|
|
->manual()
|
|
->open()
|
|
->where('user_id', $user->id)
|
|
->whereKeyNot($invoice->getKey())
|
|
->get();
|
|
|
|
foreach ([$invoice, ...$siblings] as $open) {
|
|
$open->update([
|
|
'status' => InvoiceStatus::Void->value,
|
|
'voided_at' => now(),
|
|
'void_reason' => $reason,
|
|
]);
|
|
}
|
|
|
|
$cancelled = $this->legacy->cancelBundleFor($user);
|
|
|
|
$this->activityLog->record(
|
|
ActivityType::ManualInvoice,
|
|
sprintf(
|
|
'Rechnung %s storniert%s. %s',
|
|
$invoice->number,
|
|
$siblings->isNotEmpty() ? ' (sowie '.$siblings->pluck('number')->implode(', ').')' : '',
|
|
$reason,
|
|
),
|
|
$user,
|
|
$invoice,
|
|
['action' => 'terminate', 'voided' => [$invoice->number, ...$siblings->pluck('number')->all()]],
|
|
actorLabel: $actor->name,
|
|
);
|
|
|
|
$this->activityLog->record(
|
|
ActivityType::SubscriptionChange,
|
|
sprintf('Bestandsschutz beendet (%d Posten): keine Zahlung zu Rechnung %s.', $cancelled, $invoice->number),
|
|
$user,
|
|
null,
|
|
['action' => 'terminate_grandfathering', 'invoice' => $invoice->number, 'posts' => $cancelled],
|
|
actorLabel: $actor->name,
|
|
);
|
|
|
|
if ($notifyCustomer && ! $this->suppressions->isSuppressed($user->email)) {
|
|
Mail::to($user)->queue(new GrandfatheringTerminated($user, $invoice));
|
|
}
|
|
|
|
return $cancelled;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Blockierte Vereinbarung ohne Abrechnung entfernen (Kunde nicht mehr
|
|
* erreichbar, keine Rechnungsadresse o. ä.): Bündel kündigen, offene
|
|
* MAN-Rechnungen des Kunden stornieren und eine noch offene Alt-Rechnung
|
|
* im Archiv abschreiben. Keine Kundenmail.
|
|
*
|
|
* @return int Anzahl der gekündigten Bestandsschutz-Posten
|
|
*/
|
|
public function abandonAgreement(UserPaymentOption $option, User $actor, ?string $note = null): int
|
|
{
|
|
$user = $option->user;
|
|
|
|
if (! $user) {
|
|
throw new ManualInvoiceActionException('Die Vereinbarung hat keinen zugeordneten Kunden.');
|
|
}
|
|
|
|
if ($option->stripe_subscription_id !== null) {
|
|
throw new ManualInvoiceActionException('Stripe-Abos werden über das Stripe-Dashboard beendet.');
|
|
}
|
|
|
|
$reason = trim('Vereinbarung ohne Abrechnung entfernt (nicht abrechenbar).'.($note ? ' '.$note : ''));
|
|
|
|
return DB::transaction(function () use ($option, $user, $actor, $reason): int {
|
|
$openInvoices = Invoice::query()->manual()->open()->where('user_id', $user->id)->get();
|
|
|
|
foreach ($openInvoices as $open) {
|
|
$open->update([
|
|
'status' => InvoiceStatus::Void->value,
|
|
'voided_at' => now(),
|
|
'void_reason' => $reason,
|
|
]);
|
|
}
|
|
|
|
$legacyNumbers = [];
|
|
|
|
foreach (UserPaymentOption::query()->where('user_id', $user->id)->get() as $post) {
|
|
$openLegacy = $post->legacy_conditions['open_legacy_invoice'] ?? null;
|
|
$legacyInvoice = isset($openLegacy['id']) ? LegacyInvoice::query()->find($openLegacy['id']) : null;
|
|
|
|
if ($legacyInvoice && in_array($legacyInvoice->status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
|
|
$legacyInvoice->forceFill(['status' => LegacyInvoice::STATUS_UNCOLLECTIBLE])->save();
|
|
$legacyNumbers[] = $legacyInvoice->number;
|
|
}
|
|
}
|
|
|
|
$cancelled = $this->legacy->cancelBundleFor($user);
|
|
|
|
$this->activityLog->record(
|
|
ActivityType::SubscriptionChange,
|
|
sprintf(
|
|
'Bestandsschutz entfernt (%d Posten). %s%s%s',
|
|
$cancelled,
|
|
$reason,
|
|
$legacyNumbers ? ' Alt-Rechnung abgeschrieben: '.implode(', ', $legacyNumbers).'.' : '',
|
|
$openInvoices->isNotEmpty() ? ' Storniert: '.$openInvoices->pluck('number')->implode(', ').'.' : '',
|
|
),
|
|
$user,
|
|
$option,
|
|
['action' => 'abandon', 'posts' => $cancelled, 'legacy_invoices' => $legacyNumbers, 'voided' => $openInvoices->pluck('number')->all()],
|
|
actorLabel: $actor->name,
|
|
);
|
|
|
|
return $cancelled;
|
|
});
|
|
}
|
|
|
|
private function assertOpenManual(Invoice $invoice): void
|
|
{
|
|
if (! $invoice->isManual()) {
|
|
throw new ManualInvoiceActionException(sprintf('Rechnung %s ist eine Stripe-Rechnung — Stripe übernimmt Versand und Zahlungseinzug.', $invoice->number));
|
|
}
|
|
|
|
if ($invoice->status !== InvoiceStatus::Open) {
|
|
throw new ManualInvoiceActionException(sprintf('Rechnung %s ist nicht mehr offen (%s).', $invoice->number, $invoice->status->label()));
|
|
}
|
|
|
|
if (! $invoice->user) {
|
|
throw new ManualInvoiceActionException(sprintf('Rechnung %s hat keinen zugeordneten Kunden.', $invoice->number));
|
|
}
|
|
}
|
|
|
|
private function assertDeliverable(Invoice $invoice): void
|
|
{
|
|
if ($this->suppressions->isSuppressed($invoice->user->email)) {
|
|
throw new ManualInvoiceActionException(sprintf(
|
|
'Die Adresse %s steht auf der Mail-Sperrliste (Rückläufer). Bitte zuerst die Adresse klären oder den Bestandsschutz beenden.',
|
|
$invoice->user->email,
|
|
));
|
|
}
|
|
}
|
|
}
|