- dev/roadmap.md neu: alle offenen Punkte aus Plänen, Audits und Protokollen, gegen Code und Git abgeglichen (Stand v1.7.0) - Abgeschlossenes ins Archiv: 2026-09-01 → archive/2026-09-nutzergewinnung, 2026-09-11-audit → archive/2026-09-audit, lagacy-rechnungen → archive/legacy-rechnungen, Deploy-Protokoll v1.1.0, Umami-Snippets, Go-Live-Roadmap vom 20.07. - Aktiv bleiben eigene Ordner bildrechte/ (Plan Fassung 2, Decision-Update) und changelog/ (Decision-Update Changelog) - dev/README.md als Ordnerkarte neu, Verweise und zwei Code-Kommentare auf die neuen Pfade Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
358 lines
13 KiB
PHP
358 lines
13 KiB
PHP
<?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/archive/legacy-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' => [],
|
|
'already_reissued' => [],
|
|
];
|
|
|
|
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;
|
|
}
|
|
|
|
// Ein früherer Lauf hat die Forderung schon als MAN-Rechnung neu
|
|
// ausgestellt — der Posten bleibt Bestandsschutz, die Quelle trägt
|
|
// `superseded`. Kein Konflikt, nichts zu tun.
|
|
if ($status === LegacyInvoice::STATUS_SUPERSEDED) {
|
|
$this->report['already_reissued'][] = $this->describe($post, $source).' · ersetzt durch '.($source->raw_snapshot['superseded_by_invoice_number'] ?? '?');
|
|
|
|
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, ',', '.');
|
|
}
|
|
}
|