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
348
app/Console/Commands/SettleUnpaidLegacyInvoices.php
Normal file
348
app/Console/Commands/SettleUnpaidLegacyInvoices.php
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Billing\LegacySubscriptionService;
|
||||
use App\Services\Billing\ManualInvoiceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Bereinigt die Altlast aus unbezahlten Legacy-Rechnungen (Einordnung
|
||||
* 16.09.2026, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
|
||||
*
|
||||
* `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus
|
||||
* Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das
|
||||
* anhand des Zahlungsstatus der jeweiligen Quellrechnung auf:
|
||||
*
|
||||
* - `hold` → Kunde zahlt seit Jahren nicht: Bestandsschutz-Bündel kündigen,
|
||||
* offene MAN-Rechnungen des Kunden stornieren. Die Archiv-
|
||||
* Rechnungen bleiben unverändert erhalten (werden nur ausgeblendet).
|
||||
* - `pending` / `reminder` (wiederkehrend) → echte offene Forderung 2026:
|
||||
* Periode auf die unbezahlte Rechnung zurückdrehen und sofort eine
|
||||
* MAN-Rechnung ausstellen, die die Alt-Rechnung ersetzt
|
||||
* (Archiv-Status `superseded`). Ab da gilt der Knopfdruck-Workflow.
|
||||
* - `pending` / `reminder` (Einzelkauf) → abschreiben (`uncollectible`).
|
||||
* - `paid` → unangetastet.
|
||||
*
|
||||
* Wiederholbar: Bereits gekündigte, stornierte oder ersetzte Vorgänge werden
|
||||
* beim nächsten Lauf übersprungen.
|
||||
*
|
||||
* Verwendung:
|
||||
* php artisan legacy:settle-unpaid --dry-run
|
||||
* php artisan legacy:settle-unpaid
|
||||
*/
|
||||
class SettleUnpaidLegacyInvoices extends Command
|
||||
{
|
||||
protected $signature = 'legacy:settle-unpaid
|
||||
{--dry-run : Nur anzeigen, nichts schreiben}
|
||||
{--no-invoice : Offene Vorgänge nur zurückdrehen, MAN-Rechnung dem 04:30-Lauf überlassen}
|
||||
{--no-report : Keinen JSON-Report schreiben}';
|
||||
|
||||
protected $description = 'Löst Bestandsschutz-Posten aus unbezahlten Legacy-Rechnungen auf (hold kündigen, offene Vorgänge neu ausstellen, Einzelkäufe abschreiben).';
|
||||
|
||||
private const VOID_REASON_HOLD = 'Vorjahresrechnung im Altsystem nie bezahlt (hold) — Bestandsschutz beendet.';
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $report = [];
|
||||
|
||||
private bool $dryRun = false;
|
||||
|
||||
public function handle(
|
||||
LegacySubscriptionService $legacy,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): int {
|
||||
$this->dryRun = (bool) $this->option('dry-run');
|
||||
$this->report = [
|
||||
'bundles_cancelled' => [],
|
||||
'man_invoices_voided' => [],
|
||||
'reopened' => [],
|
||||
'man_invoices_issued' => [],
|
||||
'singles_written_off' => [],
|
||||
'conflicts' => [],
|
||||
'untouched_paid' => [],
|
||||
];
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||
}
|
||||
|
||||
$postsByUser = UserPaymentOption::query()
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereNull('cancelled_at')
|
||||
->with('user')
|
||||
->get()
|
||||
->groupBy('user_id');
|
||||
|
||||
foreach ($postsByUser as $userId => $posts) {
|
||||
$this->settleBundle((int) $userId, $posts, $legacy, $manualInvoices, $activityLog);
|
||||
}
|
||||
|
||||
$this->writeOffSingles();
|
||||
|
||||
$this->table(
|
||||
['Ergebnis', 'Anzahl'],
|
||||
collect($this->report)->map(fn (array $lines, string $key): array => [$key, count($lines)])->values()->all(),
|
||||
);
|
||||
|
||||
foreach ($this->report['conflicts'] as $line) {
|
||||
$this->warn('Konflikt: '.$line);
|
||||
}
|
||||
|
||||
if (! $this->option('no-report')) {
|
||||
$path = sprintf('migration/settle-unpaid-%s.json', now()->format('Ymd-His'));
|
||||
Storage::put($path, json_encode([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'dry_run' => $this->dryRun,
|
||||
...$this->report,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->info("Report: storage/app/{$path}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kunde = ein Bündel. Die Kündigung ist nur gemeinsam möglich, daher
|
||||
* wird nur gekündigt, wenn ALLE Posten des Kunden aus `hold`-Rechnungen
|
||||
* stammen; gemischte Bündel landen als Konflikt im Report.
|
||||
*
|
||||
* @param Collection<int, UserPaymentOption> $posts
|
||||
*/
|
||||
private function settleBundle(
|
||||
int $userId,
|
||||
Collection $posts,
|
||||
LegacySubscriptionService $legacy,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
$classified = $posts->map(fn (UserPaymentOption $post): array => [
|
||||
'post' => $post,
|
||||
'source' => $this->sourceInvoice($post),
|
||||
]);
|
||||
|
||||
$statuses = $classified
|
||||
->map(fn (array $entry): string => $entry['source']?->status ?? 'unknown')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$user = $posts->first()?->user;
|
||||
|
||||
if ($statuses->count() === 1 && $statuses->first() === LegacyInvoice::STATUS_HOLD) {
|
||||
$this->cancelHoldBundle($userId, $user, $classified, $legacy, $activityLog);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($classified as ['post' => $post, 'source' => $source]) {
|
||||
$status = $source?->status;
|
||||
|
||||
if ($status === 'paid') {
|
||||
$this->report['untouched_paid'][] = $this->describe($post, $source);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
|
||||
$this->reopen($post, $source, $manualInvoices, $activityLog);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// hold im gemischten Bündel oder Quelle nicht auffindbar → manuell klären.
|
||||
$this->report['conflicts'][] = $this->describe($post, $source).' · Status '.($status ?? 'unbekannt').' im gemischten Bündel';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array{post: UserPaymentOption, source: ?LegacyInvoice}> $classified
|
||||
*/
|
||||
private function cancelHoldBundle(
|
||||
int $userId,
|
||||
?User $user,
|
||||
Collection $classified,
|
||||
LegacySubscriptionService $legacy,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
foreach ($classified as ['post' => $post, 'source' => $source]) {
|
||||
$this->report['bundles_cancelled'][] = $this->describe($post, $source);
|
||||
}
|
||||
|
||||
$openInvoices = Invoice::query()
|
||||
->manual()
|
||||
->open()
|
||||
->where('user_id', $userId)
|
||||
->get();
|
||||
|
||||
foreach ($openInvoices as $invoice) {
|
||||
$this->report['man_invoices_voided'][] = sprintf('%s · User #%d · %s €', $invoice->number, $userId, $this->euro($invoice->total_cents));
|
||||
}
|
||||
|
||||
if ($this->dryRun || ! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $openInvoices, $legacy, $activityLog): void {
|
||||
$count = $legacy->cancelBundleFor($user);
|
||||
|
||||
foreach ($openInvoices as $invoice) {
|
||||
$invoice->update([
|
||||
'status' => InvoiceStatus::Void->value,
|
||||
'voided_at' => now(),
|
||||
'void_reason' => self::VOID_REASON_HOLD,
|
||||
]);
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::ManualInvoice,
|
||||
sprintf('Rechnung %s storniert: %s', $invoice->number, self::VOID_REASON_HOLD),
|
||||
$user,
|
||||
$invoice,
|
||||
['command' => $this->getName()],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
}
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::SubscriptionChange,
|
||||
sprintf('Bestandsschutz beendet (%d Posten): Quellrechnung im Altsystem nie bezahlt (hold).', $count),
|
||||
$user,
|
||||
null,
|
||||
['command' => $this->getName(), 'posts' => $count],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Periode auf die unbezahlte Alt-Rechnung zurückdrehen und die offene
|
||||
* Forderung als MAN-Rechnung neu ausstellen.
|
||||
*/
|
||||
private function reopen(
|
||||
UserPaymentOption $post,
|
||||
LegacyInvoice $source,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
// Die unbezahlte Periode beginnt am Leistungsbeginn der Alt-Rechnung;
|
||||
// dorthin wird `current_period_end` zurückgedreht, damit der MAN-Kreis
|
||||
// genau diese Periode abrechnet und den gewohnten Rhythmus fortführt.
|
||||
$periodEnd = $this->legacyPeriodBegin($source);
|
||||
$periodStart = $periodEnd->copy()->subYear();
|
||||
|
||||
$this->report['reopened'][] = $this->describe($post, $source).sprintf(' · Periode → %s..%s', $periodStart->toDateString(), $periodEnd->toDateString());
|
||||
|
||||
if ($this->dryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conditions = $post->legacy_conditions ?? [];
|
||||
$conditions['open_legacy_invoice'] = [
|
||||
'id' => $source->id,
|
||||
'number' => $source->number,
|
||||
'invoice_date' => $source->invoice_date->toDateString(),
|
||||
'total_cents' => $source->total_cents,
|
||||
];
|
||||
|
||||
$post->update([
|
||||
'legacy_conditions' => $conditions,
|
||||
'current_period_start' => $periodStart->toDateString(),
|
||||
'current_period_end' => $periodEnd->toDateString(),
|
||||
]);
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::ManualInvoice,
|
||||
sprintf('Offene Alt-Rechnung %s (%s €) wird im MAN-Kreis neu ausgestellt.', $source->number, $this->euro($source->total_cents)),
|
||||
$post->user,
|
||||
$post,
|
||||
['command' => $this->getName(), 'legacy_invoice_id' => $source->id],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
|
||||
if ($this->option('no-invoice')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = $manualInvoices->invoiceFor($post->fresh());
|
||||
|
||||
if ($invoice) {
|
||||
$this->report['man_invoices_issued'][] = sprintf('%s ersetzt %s · User #%d · %s €', $invoice->number, $source->number, $post->user_id, $this->euro($invoice->total_cents));
|
||||
} else {
|
||||
$this->report['conflicts'][] = $this->describe($post, $source).' · MAN-Rechnung nicht ausstellbar (Rechnungsadresse fehlt?) — 04:30-Lauf versucht es erneut';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbezahlte Einzelkäufe (19,95-€-PMs) sind keine Bestandsschutz-Fälle
|
||||
* und werden abgeschrieben (Entscheidung E8).
|
||||
*/
|
||||
private function writeOffSingles(): void
|
||||
{
|
||||
$singles = LegacyInvoice::query()
|
||||
->openLegacy()
|
||||
->get()
|
||||
->filter(fn (LegacyInvoice $invoice): bool => ($invoice->pdf_payload['payment_option']['type'] ?? null) === 'single');
|
||||
|
||||
foreach ($singles as $invoice) {
|
||||
$this->report['singles_written_off'][] = sprintf('%s · User #%s · %s €', $invoice->number, $invoice->user_id ?? '-', $this->euro($invoice->total_cents));
|
||||
|
||||
if (! $this->dryRun) {
|
||||
$invoice->forceFill(['status' => LegacyInvoice::STATUS_UNCOLLECTIBLE])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sourceInvoice(UserPaymentOption $post): ?LegacyInvoice
|
||||
{
|
||||
$conditions = $post->legacy_conditions ?? [];
|
||||
$number = $conditions['source_invoice_number'] ?? null;
|
||||
|
||||
if (! $number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LegacyInvoice::query()
|
||||
->where('number', $number)
|
||||
->when($conditions['legacy_portal'] ?? null, fn ($query, string $portal) => $query->where('legacy_portal', $portal))
|
||||
->first();
|
||||
}
|
||||
|
||||
private function legacyPeriodBegin(LegacyInvoice $source): Carbon
|
||||
{
|
||||
$begin = $source->raw_snapshot['service_period_begin_date'] ?? null;
|
||||
|
||||
if ($begin && ! str_starts_with((string) $begin, '0000-00-00')) {
|
||||
return Carbon::parse(substr((string) $begin, 0, 10))->startOfDay();
|
||||
}
|
||||
|
||||
return Carbon::instance($source->invoice_date)->startOfDay();
|
||||
}
|
||||
|
||||
private function describe(UserPaymentOption $post, ?LegacyInvoice $source): string
|
||||
{
|
||||
return sprintf(
|
||||
'UPO #%d · User #%d · Quelle %s (%s, %s)',
|
||||
$post->id,
|
||||
$post->user_id,
|
||||
$source?->number ?? '?',
|
||||
$source?->status ?? 'unbekannt',
|
||||
$source?->invoice_date?->toDateString() ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
private function euro(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, ',', '.');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue