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:
Kevin Adametz 2026-09-16 10:56:29 +00:00
parent ada41cb067
commit 36691a5b13
33 changed files with 3606 additions and 195 deletions

View file

@ -21,6 +21,13 @@ use Illuminate\Support\Facades\Storage;
* = active` und eine nicht zu weit zurückliegende `next_due_date` trägt.
* Alle anderen Rechnungen bleiben unangetastet im Archiv.
*
* Zahlungsstatus der jüngsten Rechnung (Einordnung 16.09.2026):
* - `paid` → Bestandsschutz mit der laufenden Periode.
* - `pending`/`reminder` → Bestandsschutz, aber die Rechnung ist offen: die
* Periode wird auf den Leistungsbeginn dieser Rechnung gesetzt, sodass der
* MAN-Kreis sie neu ausstellt (`legacy_conditions.open_legacy_invoice`).
* - `hold` → nie bezahlt, kein Bestandsschutz (übersprungen).
*
* Die migrierte Vereinbarung trägt `current_period_end = next_due_date` —
* damit stellt `billing:generate-manual-invoices` (MAN-Kreis) die nächste
* Rechnung zum gewohnten Rhythmus mit den Beträgen der letzten
@ -68,12 +75,24 @@ class GrandfatherLegacySubscriptions extends Command
'created' => [],
'updated' => [],
'stale_skipped' => [],
'unpaid_skipped' => [],
'reopened_unpaid' => [],
'immediately_due' => [],
];
foreach ($candidates as $candidate) {
$nextDue = $candidate['next_due_date'];
if ($candidate['source_status'] === LegacyInvoice::STATUS_HOLD) {
$report['unpaid_skipped'][] = $this->describe($candidate);
continue;
}
if ($candidate['open_legacy_invoice'] !== null) {
$report['reopened_unpaid'][] = $this->describe($candidate);
}
if ($nextDue->lessThan($staleBefore)) {
$report['stale_skipped'][] = $this->describe($candidate);
@ -120,6 +139,8 @@ class GrandfatherLegacySubscriptions extends Command
['Neu angelegt', count($report['created'])],
['Aktualisiert (Re-Run)', count($report['updated'])],
['Übersprungen (stale)', count($report['stale_skipped'])],
['Übersprungen (hold, nie bezahlt)', count($report['unpaid_skipped'])],
['Offene Alt-Rechnung → MAN neu', count($report['reopened_unpaid'])],
['Davon sofort fällig', count($report['immediately_due'])],
],
);
@ -197,6 +218,22 @@ class GrandfatherLegacySubscriptions extends Command
? Carbon::parse($snapshot['service_period_begin_date'])->startOfDay()
: $nextDue->copy()->subYear();
$openLegacyInvoice = null;
// Offene Rechnung: Die zuletzt fakturierte Periode ist noch nicht
// bezahlt → Fälligkeit auf deren Leistungsbeginn setzen, damit der
// MAN-Kreis genau diese Periode neu ausstellt.
if (in_array($invoice->status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
$openLegacyInvoice = [
'id' => $invoice->id,
'number' => $invoice->number,
'invoice_date' => $invoice->invoice_date->toDateString(),
'total_cents' => $invoice->total_cents,
];
$nextDue = $periodStart->copy();
$periodStart = $nextDue->copy()->subYear();
}
return [
'user_id' => $invoice->user_id,
'legacy_portal' => $invoice->legacy_portal->value,
@ -207,6 +244,8 @@ class GrandfatherLegacySubscriptions extends Command
'valid_until_date' => isset($upo['valid_until_date']) && $upo['valid_until_date']
? Carbon::parse($upo['valid_until_date'])->startOfDay()
: null,
'source_status' => $invoice->status,
'open_legacy_invoice' => $openLegacyInvoice,
'legacy_conditions' => [
'legacy_portal' => $invoice->legacy_portal->value,
'legacy_user_payment_option_id' => (int) $upo['id'],
@ -219,6 +258,7 @@ class GrandfatherLegacySubscriptions extends Command
'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false),
'source_invoice_number' => $invoice->number,
'source_invoice_date' => $invoice->invoice_date->toDateString(),
...($openLegacyInvoice ? ['open_legacy_invoice' => $openLegacyInvoice] : []),
],
];
}

View 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, ',', '.');
}
}

View file

@ -29,6 +29,7 @@ enum ActivityType: string
case Correction = 'correction';
case ContentRemoval = 'content_removal';
case MailSuppressed = 'mail_suppressed';
case ManualInvoice = 'manual_invoice';
public function label(): string
{
@ -53,6 +54,7 @@ enum ActivityType: string
self::Correction => 'Korrektur',
self::ContentRemoval => 'Entfernung (410)',
self::MailSuppressed => 'Mail-Sperre',
self::ManualInvoice => 'Rechnung (MAN)',
};
}
@ -77,6 +79,7 @@ enum ActivityType: string
self::Correction => 'blue',
self::ContentRemoval => 'red',
self::MailSuppressed => 'orange',
self::ManualInvoice => 'amber',
};
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Eine Aktion auf einer MAN-Rechnung (senden, erinnern, bezahlt, beenden)
* ist im aktuellen Zustand nicht zulässig. Die Meldung ist für die
* Admin-Oberfläche formuliert.
*/
class ManualInvoiceActionException extends RuntimeException {}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use App\Services\Billing\ManualInvoicePdfRenderer;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
/**
* PDF einer MAN-Rechnung (on-demand gerendert). STR-Rechnungen haben ihren
* Beleg bei Stripe (`StripeInvoiceRedirectController`) — hier 404.
*/
class InvoicePdfController extends Controller
{
public function __invoke(Invoice $invoice, ManualInvoicePdfRenderer $renderer): Response
{
abort_unless($invoice->isManual(), 404);
Gate::authorize('downloadPdf', $invoice);
return $renderer->inlineResponse($invoice);
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Mail;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Bestätigung an den Kunden, dass der Bestandsschutz wegen ausbleibender
* Zahlung beendet und die offene Rechnung storniert wurde — inkl. Hinweis,
* wie es weitergeht (bestehende PMs bleiben online, neue nur über Tarife).
*
* Transaktionsmail: bewusst ohne Opt-out.
*/
class GrandfatheringTerminated extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public string $bookingUrl;
public function __construct(
public readonly User $user,
public readonly Invoice $invoice,
) {
$this->bookingUrl = route('me.bookings.index');
}
public function envelope(): Envelope
{
$bcc = (string) config('legal.contact_email');
return new Envelope(
subject: 'Ihr Bestandsschutz auf pressekonto ist beendet — Rechnung '.$this->invoice->number.' storniert',
bcc: $bcc !== '' ? [$bcc] : [],
);
}
public function content(): Content
{
return new Content(view: 'emails.billing.grandfathering-terminated');
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Mail;
use App\Models\Invoice;
use App\Models\User;
use App\Services\Billing\ManualInvoicePdfRenderer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Rechnung an einen Legacy-Überweisungskunden (MAN-Kreis) mit PDF im
* Anhang, Bankverbindung, Zahlungsziel und dem Hinweis, dass der
* Bestandsschutz ohne Zahlung zum Zahlungsziel endet. Als Zahlungs-
* erinnerung (`$isReminder`) mit gleichem Inhalt im Mahn-Ton.
*
* Transaktionsmail: bewusst ohne Opt-out. Die Bounce-Sperrliste greift
* über `GateOutgoingMail`; der Service prüft sie zusätzlich vorab.
*/
class ManualInvoiceIssued extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public string $invoicesUrl;
public string $bookingUrl;
public string $serviceName;
/** @var array{0: string, 1: string}|null */
public ?array $servicePeriod;
/** @var array<string, string> */
public array $issuer;
public function __construct(
public readonly User $user,
public readonly Invoice $invoice,
public readonly bool $isReminder = false,
) {
$renderer = app(ManualInvoicePdfRenderer::class);
$this->invoicesUrl = route('me.invoices.index');
$this->bookingUrl = route('me.bookings.index');
$this->serviceName = $renderer->serviceName($invoice);
$this->servicePeriod = $renderer->servicePeriod($invoice);
$this->issuer = array_map(fn (mixed $value): string => (string) $value, (array) config('billing.issuer', []));
}
public function envelope(): Envelope
{
$bcc = (string) config('legal.contact_email');
$subject = $this->isReminder
? 'Zahlungserinnerung — Rechnung '.$this->invoice->number
: 'Ihre Rechnung '.$this->invoice->number.' — Bestandsschutz auf pressekonto';
return new Envelope(
subject: $subject,
bcc: $bcc !== '' ? [$bcc] : [],
);
}
public function content(): Content
{
return new Content(view: 'emails.billing.manual-invoice-issued');
}
/**
* @return array<int, Attachment>
*/
public function attachments(): array
{
$renderer = app(ManualInvoicePdfRenderer::class);
return [
Attachment::fromData(fn (): string => $renderer->render($this->invoice), $renderer->filename($this->invoice))
->withMime('application/pdf'),
];
}
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\InvoiceStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -15,6 +16,7 @@ class Invoice extends Model
protected $fillable = [
'user_id',
'user_payment_id',
'user_payment_option_id',
'invoice_billing_address_id',
'number',
'status',
@ -27,6 +29,12 @@ class Invoice extends Model
'invoice_date',
'due_date',
'paid_at',
'sent_at',
'reminded_at',
'reminder_count',
'voided_at',
'void_reason',
'replaces_legacy_invoice_number',
'stripe_invoice_id',
'pdf_path',
];
@ -42,6 +50,10 @@ class Invoice extends Model
'invoice_date' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
'sent_at' => 'datetime',
'reminded_at' => 'datetime',
'reminder_count' => 'integer',
'voided_at' => 'datetime',
'deleted_at' => 'datetime',
];
}
@ -62,6 +74,16 @@ class Invoice extends Model
return $this->belongsTo(UserPayment::class, 'user_payment_id');
}
/**
* Abgerechnete Zahlungsvereinbarung (MAN-Kreis). Für STR-Rechnungen NULL.
*
* @return BelongsTo<UserPaymentOption, $this>
*/
public function userPaymentOption(): BelongsTo
{
return $this->belongsTo(UserPaymentOption::class);
}
/**
* @return BelongsTo<InvoiceBillingAddress, $this>
*/
@ -69,4 +91,37 @@ class Invoice extends Model
{
return $this->belongsTo(InvoiceBillingAddress::class);
}
/**
* MAN-Kreis: lokal ausgestellte Rechnung, per Überweisung zu zahlen.
*/
public function isManual(): bool
{
return $this->stripe_invoice_id === null;
}
public function isOverdue(): bool
{
return $this->status === InvoiceStatus::Open
&& $this->due_date !== null
&& $this->due_date->isBefore(today());
}
/**
* Rechnungen des manuellen Kreises (ohne Stripe-Beleg).
*/
public function scopeManual(Builder $query): Builder
{
return $query->whereNull('stripe_invoice_id');
}
public function scopeOpen(Builder $query): Builder
{
return $query->where('status', InvoiceStatus::Open->value);
}
public function scopeOverdue(Builder $query): Builder
{
return $this->scopeOpen($query)->whereDate('due_date', '<', today());
}
}

View file

@ -9,6 +9,25 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LegacyInvoice extends Model
{
/**
* Legacy-Status: nie bezahlt, überfällig, Kunde meist nicht mehr
* erreichbar. Gilt als ersatzlos erledigt (Einordnung 16.09.2026) und
* wird in Kunden- und Admin-Ansicht standardmäßig ausgeblendet.
*/
public const STATUS_HOLD = 'hold';
/** Offene Alt-Rechnung, im Altsystem noch nicht gemahnt. */
public const STATUS_PENDING = 'pending';
/** Offene Alt-Rechnung, im Altsystem bereits gemahnt. */
public const STATUS_REMINDER = 'reminder';
/** Durch eine MAN-Rechnung des neuen Systems ersetzt (Phase B). */
public const STATUS_SUPERSEDED = 'superseded';
/** Abgeschrieben — Forderung wird nicht weiter verfolgt. */
public const STATUS_UNCOLLECTIBLE = 'uncollectible';
public $timestamps = false;
protected $fillable = [
@ -65,4 +84,27 @@ class LegacyInvoice extends Model
{
return $query->orderByDesc('invoice_date')->orderByDesc('id');
}
/**
* Ohne die erledigten `hold`-Rechnungen (Default in allen Ansichten).
*/
public function scopeWithoutHold(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query->whereNull('status')->orWhere('status', '!=', self::STATUS_HOLD);
});
}
/**
* Noch offene Alt-Vorgänge, die im neuen System abgearbeitet werden.
*/
public function scopeOpenLegacy(Builder $query): Builder
{
return $query->whereIn('status', [self::STATUS_PENDING, self::STATUS_REMINDER]);
}
public function isHold(): bool
{
return $this->status === self::STATUS_HOLD;
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Authorization\Permission;
use App\Models\Invoice;
use App\Models\User;
/**
* Rechnungen des neuen Rechnungsausgangs (STR/MAN). Kunden sehen ihre
* eigenen Belege, die Bearbeitung (senden, bezahlt, stornieren) ist an
* `billing:manage` gebunden.
*/
class InvoicePolicy
{
public function before(User $user): ?bool
{
return $user->is_super_admin ? true : null;
}
public function view(User $user, Invoice $invoice): bool
{
if ($user->can(Permission::BillingManage)) {
return true;
}
return $invoice->user_id === $user->id;
}
public function downloadPdf(User $user, Invoice $invoice): bool
{
return $invoice->isManual() && $this->view($user, $invoice);
}
public function manage(User $user, Invoice $invoice): bool
{
return $user->can(Permission::BillingManage);
}
}

View file

@ -0,0 +1,177 @@
<?php
namespace App\Services\Billing\Concerns;
use Illuminate\Support\Str;
/**
* Minimaler PDF-Zeichensatz (PDF 1.4, Helvetica, WinAnsi) ohne externe
* Bibliothek — gemeinsam genutzt vom Archiv-Renderer (Legacy) und dem
* MAN-Rechnungs-Renderer. Koordinaten in PDF-Punkten, Ursprung unten links.
*/
trait DrawsPdf
{
protected const PAGE_WIDTH = 595;
protected const PAGE_HEIGHT = 842;
protected const LEFT = 91;
protected const RIGHT = 524;
/**
* @param array<int, array{0: string, 1: string, 2: bool}|null> $rows
*/
protected function invoiceTable(string &$content, float $x, float $y, array $rows): float
{
$width = 275.0;
$rowHeight = 22.0;
$valueX = $x + 160;
$content .= $this->line($x, $y, $x + $width, $y, 0.5);
foreach (array_values(array_filter($rows)) as [$label, $value, $bold]) {
$y -= $rowHeight;
$content .= $this->text($x, $y + 7, $label, 9, $bold ? 'F2' : 'F1');
$content .= $this->text($valueX, $y + 7, $value, 9, $bold ? 'F2' : 'F1');
$content .= $this->line($x, $y, $x + $width, $y, 0.5);
}
return $y;
}
protected function formatEuro(float $amount): string
{
return number_format($amount, 2, ',', '.').' €';
}
protected function buildPdf(string $content): string
{
$objects = [
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n",
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
"6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
];
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $object) {
$offsets[] = strlen($pdf);
$pdf .= $object;
}
$xrefOffset = strlen($pdf);
$pdf .= "xref\n0 ".(count($objects) + 1)."\n";
$pdf .= "0000000000 65535 f \n";
foreach (array_slice($offsets, 1) as $offset) {
$pdf .= sprintf("%010d 00000 n \n", $offset);
}
$pdf .= "trailer\n<< /Size ".(count($objects) + 1)." /Root 1 0 R >>\n";
$pdf .= "startxref\n{$xrefOffset}\n%%EOF\n";
return $pdf;
}
protected function escapePdfText(string $text): string
{
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
}
protected function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1', string $align = 'left'): string
{
if ($align === 'center') {
$x = (self::PAGE_WIDTH - $this->textWidth($text, $size)) / 2;
}
return sprintf(
"BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n",
$font,
$size,
$x,
$y,
$this->escapePdfText($text),
);
}
protected function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string
{
return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2);
}
protected function wrappedText(
string &$content,
float $x,
float $y,
string $text,
int $size,
float $width,
float $lineHeight = 12,
string $font = 'F1',
): float {
foreach ($this->wrap($text, $width, $size) as $line) {
if ($line !== '') {
$content .= $this->text($x, $y, $line, $size, $font);
}
$y -= $lineHeight;
}
return $y;
}
/**
* @return list<string>
*/
protected function wrap(string $text, float $width, int $size): array
{
$lines = [];
$maxCharacters = max(8, (int) floor($width / ($size * 0.48)));
foreach ($this->splitLines($text) as $paragraph) {
if ($paragraph === '') {
$lines[] = '';
continue;
}
$line = '';
foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) {
$candidate = trim($line.' '.$word);
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
$lines[] = $line;
$line = $word;
continue;
}
$line = $candidate;
}
if ($line !== '') {
$lines[] = $line;
}
}
return $lines;
}
/**
* @return list<string>
*/
protected function splitLines(string $text): array
{
return explode("\n", str_replace(["\r\n", "\r"], "\n", $text));
}
protected function textWidth(string $text, int $size): float
{
return Str::length($text) * $size * 0.48;
}
}

View file

@ -3,19 +3,14 @@
namespace App\Services\Billing;
use App\Models\LegacyInvoice;
use App\Services\Billing\Concerns\DrawsPdf;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class LegacyInvoicePdfRenderer
{
private const PAGE_WIDTH = 595;
private const PAGE_HEIGHT = 842;
private const LEFT = 91;
private const RIGHT = 524;
use DrawsPdf;
public function inlineResponse(LegacyInvoice $invoice): Response
{
@ -241,26 +236,6 @@ class LegacyInvoicePdfRenderer
);
}
/**
* @param array<int, array{0: string, 1: string, 2: bool}|null> $rows
*/
private function invoiceTable(string &$content, float $x, float $y, array $rows): float
{
$width = 275.0;
$rowHeight = 22.0;
$valueX = $x + 160;
$content .= $this->line($x, $y, $x + $width, $y, 0.5);
foreach (array_values(array_filter($rows)) as [$label, $value, $bold]) {
$y -= $rowHeight;
$content .= $this->text($x, $y + 7, $label, 9, $bold ? 'F2' : 'F1');
$content .= $this->text($valueX, $y + 7, $value, 9, $bold ? 'F2' : 'F1');
$content .= $this->line($x, $y, $x + $width, $y, 0.5);
}
return $y;
}
/**
* @param array<string, mixed> $data
* @return list<string>
@ -328,140 +303,4 @@ class LegacyInvoicePdfRenderer
return Carbon::parse((string) $value)->format('d.m.Y');
}
private function formatEuro(float $amount): string
{
return number_format($amount, 2, ',', '.').' €';
}
private function buildPdf(string $content): string
{
$objects = [
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n",
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
"6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
];
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $object) {
$offsets[] = strlen($pdf);
$pdf .= $object;
}
$xrefOffset = strlen($pdf);
$pdf .= "xref\n0 ".(count($objects) + 1)."\n";
$pdf .= "0000000000 65535 f \n";
foreach (array_slice($offsets, 1) as $offset) {
$pdf .= sprintf("%010d 00000 n \n", $offset);
}
$pdf .= "trailer\n<< /Size ".(count($objects) + 1)." /Root 1 0 R >>\n";
$pdf .= "startxref\n{$xrefOffset}\n%%EOF\n";
return $pdf;
}
private function escapePdfText(string $text): string
{
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
}
private function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1', string $align = 'left'): string
{
if ($align === 'center') {
$x = (self::PAGE_WIDTH - $this->textWidth($text, $size)) / 2;
}
return sprintf(
"BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n",
$font,
$size,
$x,
$y,
$this->escapePdfText($text),
);
}
private function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string
{
return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2);
}
private function wrappedText(
string &$content,
float $x,
float $y,
string $text,
int $size,
float $width,
float $lineHeight = 12,
string $font = 'F1',
): float {
foreach ($this->wrap($text, $width, $size) as $line) {
if ($line !== '') {
$content .= $this->text($x, $y, $line, $size, $font);
}
$y -= $lineHeight;
}
return $y;
}
/**
* @return list<string>
*/
private function wrap(string $text, float $width, int $size): array
{
$lines = [];
$maxCharacters = max(8, (int) floor($width / ($size * 0.48)));
foreach ($this->splitLines($text) as $paragraph) {
if ($paragraph === '') {
$lines[] = '';
continue;
}
$line = '';
foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) {
$candidate = trim($line.' '.$word);
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
$lines[] = $line;
$line = $word;
continue;
}
$line = $candidate;
}
if ($line !== '') {
$lines[] = $line;
}
}
return $lines;
}
/**
* @return list<string>
*/
private function splitLines(string $text): array
{
return explode("\n", str_replace(["\r\n", "\r"], "\n", $text));
}
private function textWidth(string $text, int $size): float
{
return Str::length($text) * $size * 0.48;
}
}

View file

@ -0,0 +1,306 @@
<?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,
));
}
}
}

View file

@ -0,0 +1,267 @@
<?php
namespace App\Services\Billing;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Services\Billing\Concerns\DrawsPdf;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Rechnungs-PDF für den manuellen Kreis (MAN-): Legacy-Überweisungskunden.
*
* Wird on-demand aus dem Rechnungsdatensatz gerendert (kein Ablage-Pfad),
* wie das Archiv-PDF. Aussteller, Bankverbindung und Steuerangaben kommen
* aus `billing.issuer`; Empfänger aus dem eingefrorenen Adress-Snapshot der
* Rechnung; Leistung und Zeitraum aus der abgerechneten Vereinbarung.
*/
class ManualInvoicePdfRenderer
{
use DrawsPdf;
public function inlineResponse(Invoice $invoice): Response
{
return response($this->render($invoice), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$this->filename($invoice).'"',
'Cache-Control' => 'private, max-age=0, must-revalidate',
]);
}
public function render(Invoice $invoice): string
{
$invoice->loadMissing(['invoiceBillingAddress', 'userPaymentOption.paymentOption', 'user']);
$content = '';
$this->drawHeader($content);
$this->drawBody($content, $invoice);
$this->drawFooter($content);
return $this->buildPdf($content);
}
public function filename(Invoice $invoice): string
{
$number = preg_replace('/[^A-Za-z0-9._-]/', '-', Str::ascii((string) $invoice->number)) ?: (string) $invoice->id;
return "Rechnung-{$number}.pdf";
}
/**
* Leistungsbezeichnung wie sie auf Rechnung und in der Mail erscheint.
*/
public function serviceName(Invoice $invoice): string
{
$option = $invoice->userPaymentOption;
$conditions = $option?->legacy_conditions ?? [];
$name = $conditions['name']
?? $option?->paymentOption?->article_number
?? 'Leistung laut Vereinbarung';
$portal = match ($conditions['legacy_portal'] ?? null) {
'presseecho' => 'Presseecho',
'businessportal24' => 'Businessportal24',
default => null,
};
return $portal ? "{$name} auf {$portal}" : (string) $name;
}
/**
* Abgerechneter Leistungszeitraum. Nach dem Ausstellen zeigt die
* Vereinbarung bereits auf die fakturierte Periode (Start = alter
* Periodenschluss), deshalb reicht ihr aktueller Stand.
*
* @return array{0: string, 1: string}|null
*/
public function servicePeriod(Invoice $invoice): ?array
{
$option = $invoice->userPaymentOption;
if (! $option?->current_period_start || ! $option->current_period_end) {
return null;
}
return [
$option->current_period_start->format('d.m.Y'),
$option->current_period_end->copy()->subDay()->format('d.m.Y'),
];
}
private function drawHeader(string &$content): void
{
$issuer = $this->issuer();
$content .= $this->text(0, 790, $issuer['name'], 21, 'F2', align: 'center');
$content .= $this->line(180, 778, 415, 778, 0.7);
$content .= $this->text(0, 762, implode(', ', array_filter([
$issuer['name'],
$issuer['owner'],
$issuer['street'],
trim($issuer['postal_code'].' '.$issuer['city']),
])), 9, align: 'center');
$content .= $this->text(0, 748, $issuer['website'], 10, 'F2', align: 'center');
}
private function drawBody(string &$content, Invoice $invoice): void
{
$invoiceDate = $invoice->invoice_date?->format('d.m.Y') ?? 'n/a';
$y = 700.0;
$content .= $this->text(self::LEFT, $y, 'Rechnungsdatum: '.$invoiceDate, 9);
$y -= 34;
foreach ($this->addressLines($invoice->invoiceBillingAddress) as $line) {
$content .= $this->text(self::LEFT, $y, $line, 9);
$y -= 13;
}
$y -= 26;
$content .= $this->text(self::LEFT, $y, 'Leistung:', 9);
$y -= 15;
$y = $this->wrappedText($content, self::LEFT, $y, $this->serviceName($invoice), 10, 400, 13, 'F2');
$y -= 3;
if ($period = $this->servicePeriod($invoice)) {
$content .= $this->text(self::LEFT, $y, 'Leistungszeitraum: '.$period[0].' - '.$period[1], 9);
} else {
$content .= $this->text(self::LEFT, $y, 'Leistungsdatum: '.$invoiceDate, 9);
}
$y -= 17;
$content .= $this->text(self::LEFT, $y, 'Rechnungsnummer: '.$invoice->number, 10, 'F2');
$y -= 13;
if ($invoice->replaces_legacy_invoice_number) {
$content .= $this->text(self::LEFT, $y, 'Ersetzt die Rechnung Nr. '.$invoice->replaces_legacy_invoice_number.' aus dem bisherigen System.', 8);
$y -= 13;
}
$y -= 21;
$taxLabel = $invoice->is_netto
? null
: sprintf('MwSt. %d%%:', (int) round(config('billing.vat_rate', 0.19) * 100));
$y = $this->invoiceTable($content, self::LEFT, $y, [
['Rechnungsstellung:', $invoiceDate, false],
['Netto:', $this->formatEuro($invoice->amount_cents / 100), false],
$taxLabel ? [$taxLabel, $this->formatEuro($invoice->tax_cents / 100), false] : null,
['Rechnungsbetrag:', $this->formatEuro($invoice->total_cents / 100), true],
['Zahlbar bis:', $invoice->due_date?->format('d.m.Y') ?? '-', true],
]);
$y -= 22;
foreach ($this->paymentLines($invoice) as $line) {
$y = $this->wrappedText($content, self::LEFT, $y, $line, 9, 430, 12);
if ($line === '') {
$y += 3;
}
}
}
private function drawFooter(string &$content): void
{
$issuer = $this->issuer();
$content .= $this->line(self::LEFT, 76, self::RIGHT, 76, 0.5);
$columns = [
[implode("\n", array_filter([$issuer['name'], $issuer['owner'], $issuer['street'], trim($issuer['postal_code'].' '.$issuer['city'])])), 91, 62],
[implode("\n", array_filter(['Tel: '.$issuer['phone'], 'Mail: '.$issuer['email'], 'Site: '.$issuer['website']])), 165, 62],
[implode("\n", ['Bankverbindung', $issuer['bank_name'], 'IBAN: '.$issuer['iban'], 'BIC: '.$issuer['bic']]), 292, 62],
[implode("\n", array_filter(['Steuernummer:', $issuer['tax_number'], 'USt-ID: '.config('billing.own_vat_id')])), 445, 62],
];
foreach ($columns as [$text, $x, $y]) {
$this->wrappedText($content, $x, $y, $text, 6, 105, 8);
}
}
/**
* @return list<string>
*/
private function addressLines(?InvoiceBillingAddress $address): array
{
if (! $address) {
return [];
}
$salutation = config('salutations.items.'.$address->salutation_key.'.de');
$salutation = in_array($address->salutation_key, ['mr', 'mrs'], true) ? $salutation : null;
$person = trim(implode(' ', array_filter([$salutation, $address->title, $address->name])));
$lines = [
$address->company,
$address->company === $address->name ? null : $person,
$address->address1,
$address->address2,
trim((string) $address->postal_code.' '.(string) $address->city),
$this->countryName($address->country_code),
$address->vat_id ? 'USt-ID: '.$address->vat_id : null,
];
return array_values(array_filter($lines, fn (?string $line): bool => filled($line)));
}
/**
* @return list<string>
*/
private function paymentLines(Invoice $invoice): array
{
$issuer = $this->issuer();
$dueDate = $invoice->due_date?->format('d.m.Y');
$lines = [
$dueDate
? 'Bitte überweisen Sie den Rechnungsbetrag bis zum '.$dueDate.' auf unser Geschäftskonto:'
: 'Bitte überweisen Sie den Rechnungsbetrag auf unser Geschäftskonto:',
'',
'Kontoinhaber: '.$issuer['account_holder'],
'IBAN: '.$issuer['iban'],
'BIC: '.$issuer['bic'],
'Bank: '.$issuer['bank_name'],
'',
'Als Verwendungszweck geben Sie bitte "Rechnungsnummer '.$invoice->number.'" an.',
'',
'Bitte ignorieren Sie diese Zahlungsaufforderung, falls der Betrag bereits entrichtet wurde.',
];
if ($invoice->tax_note) {
$lines[] = '';
$lines[] = $invoice->tax_note;
}
return $lines;
}
private function countryName(?string $countryCode): ?string
{
return match (strtoupper((string) $countryCode)) {
'DE' => 'Deutschland',
'AT' => 'Österreich',
'CH' => 'Schweiz',
'NL' => 'Niederlande',
'BE' => 'Belgien',
'FR' => 'Frankreich',
'IT' => 'Italien',
'ES' => 'Spanien',
'PL' => 'Polen',
'LU' => 'Luxemburg',
'LI' => 'Liechtenstein',
'GB' => 'Vereinigtes Königreich',
'US' => 'USA',
'' => null,
default => strtoupper((string) $countryCode),
};
}
/**
* @return array<string, string>
*/
private function issuer(): array
{
return array_map(fn (mixed $value): string => (string) $value, (array) config('billing.issuer', []));
}
}

View file

@ -6,6 +6,7 @@ use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\LegacyInvoice;
use App\Models\UserPaymentOption;
use App\Services\Billing\Concerns\LocksModelForUpdate;
use Illuminate\Support\Carbon;
@ -125,8 +126,12 @@ class ManualInvoiceService
'vat_id' => $billingAddress->vat_id,
]);
$conditions = $lockedOption->legacy_conditions ?? [];
$openLegacyInvoice = $conditions['open_legacy_invoice'] ?? null;
$invoice = Invoice::query()->create([
'user_id' => $user->id,
'user_payment_option_id' => $lockedOption->id,
'invoice_billing_address_id' => $addressSnapshot->id,
'number' => $this->numbers->nextManualNumber(),
'status' => InvoiceStatus::Open->value,
@ -138,9 +143,18 @@ class ManualInvoiceService
'tax_note' => $treatment->taxNote(),
'invoice_date' => $asOf,
'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)),
'replaces_legacy_invoice_number' => $openLegacyInvoice['number'] ?? null,
]);
// Eine offene Alt-Rechnung (Phase B, `legacy:settle-unpaid`) ist
// mit dieser MAN-Rechnung ersetzt — Vermerk einmalig verbrauchen.
if ($openLegacyInvoice) {
unset($conditions['open_legacy_invoice']);
$this->markLegacyInvoiceSuperseded($openLegacyInvoice, $invoice);
}
$lockedOption->update([
'legacy_conditions' => $conditions ?: null,
'current_period_start' => $lockedOption->current_period_end,
'current_period_end' => $interval === 'yearly'
? $lockedOption->current_period_end->copy()->addYear()
@ -151,6 +165,28 @@ class ManualInvoiceService
});
}
/**
* @param array{number?: string, id?: int} $openLegacyInvoice
*/
private function markLegacyInvoiceSuperseded(array $openLegacyInvoice, Invoice $invoice): void
{
$legacyInvoice = isset($openLegacyInvoice['id'])
? LegacyInvoice::query()->find($openLegacyInvoice['id'])
: null;
if (! $legacyInvoice) {
return;
}
$legacyInvoice->forceFill([
'status' => LegacyInvoice::STATUS_SUPERSEDED,
'raw_snapshot' => array_merge($legacyInvoice->raw_snapshot ?? [], [
'superseded_by_invoice_number' => $invoice->number,
'superseded_at' => now()->toDateString(),
]),
])->save();
}
private function billingInterval(UserPaymentOption $option): ?string
{
$interval = $option->legacy_conditions['interval']

View file

@ -36,9 +36,42 @@ return [
'invoice_number_padding' => 5,
// Zahlungsziel für Rechnungen des manuellen Kreises (Tage).
// Zahlungsziel für Rechnungen des manuellen Kreises (Tage). Zählt ab
// Versand der Rechnung an den Kunden, nicht ab dem 04:30-Lauf.
'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14),
// Nachfrist einer Zahlungserinnerung, wenn das Zahlungsziel bereits
// verstrichen ist (Tage).
'reminder_due_days' => env('BILLING_REMINDER_DUE_DAYS', 7),
/*
|--------------------------------------------------------------------------
| Rechnungsaussteller (MAN-Kreis)
|--------------------------------------------------------------------------
|
| Absender, Bankverbindung und Steuerangaben auf MAN-Rechnungen und in
| der Zahlungsaufforderung. Gilt für beide Portale (Entscheidung
| 16.09.2026); die Alt-Rechnungen im Archiv behalten ihren jeweiligen
| Aussteller.
|
*/
'issuer' => [
'name' => env('BILLING_ISSUER_NAME', 'adametz.media'),
'owner' => env('BILLING_ISSUER_OWNER', 'Kevin Adametz'),
'street' => env('BILLING_ISSUER_STREET', 'In der Lake 4'),
'postal_code' => env('BILLING_ISSUER_POSTAL_CODE', '33739'),
'city' => env('BILLING_ISSUER_CITY', 'Bielefeld'),
'phone' => env('BILLING_ISSUER_PHONE', '+49 5206 7076721'),
'email' => env('BILLING_ISSUER_EMAIL', 'info@pressekonto.com'),
'website' => env('BILLING_ISSUER_WEBSITE', 'www.pressekonto.de'),
'bank_name' => env('BILLING_ISSUER_BANK_NAME', 'Sparkasse Bielefeld'),
'iban' => env('BILLING_ISSUER_IBAN', 'DE96 4805 0161 0065 0356 02'),
'bic' => env('BILLING_ISSUER_BIC', 'SPBIDE3BXXX'),
'account_holder' => env('BILLING_ISSUER_ACCOUNT_HOLDER', 'adametz.media, Kevin Adametz'),
'tax_number' => env('BILLING_ISSUER_TAX_NUMBER', '349 / 5001 / 4350'),
],
/*
|--------------------------------------------------------------------------
| Einzel-Pressemitteilung (Pay-per-Release)

View file

@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Bearbeitungsweg für MAN-Rechnungen (Legacy-Überweisungskunden): Versand-
* und Mahnhistorie, Storno-Vermerk und der Link zur abgerechneten
* Zahlungsvereinbarung. `invoices` ist klein und ohne FULLTEXT — der
* ALTER läuft inplace.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table): void {
$table->foreignId('user_payment_option_id')
->nullable()
->after('user_payment_id')
->constrained('user_payment_options')
->nullOnDelete();
$table->timestamp('sent_at')->nullable()->after('paid_at');
$table->timestamp('reminded_at')->nullable()->after('sent_at');
$table->unsignedTinyInteger('reminder_count')->default(0)->after('reminded_at');
$table->timestamp('voided_at')->nullable()->after('reminder_count');
$table->string('void_reason')->nullable()->after('voided_at');
// MAN-Rechnung ersetzt eine offene Alt-Rechnung aus dem Archiv
// (Phase B: reminder/pending-Vorgänge werden neu ausgestellt).
$table->string('replaces_legacy_invoice_number', 64)->nullable()->after('void_reason');
});
$this->backfillAgreementLinks();
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table): void {
$table->dropConstrainedForeignId('user_payment_option_id');
$table->dropColumn([
'sent_at',
'reminded_at',
'reminder_count',
'voided_at',
'void_reason',
'replaces_legacy_invoice_number',
]);
});
}
/**
* Bestehende MAN-Rechnungen an ihre Vereinbarung hängen — eindeutig nur,
* wenn der Kunde genau eine abrechenbare Nicht-Stripe-Vereinbarung hat.
*/
private function backfillAgreementLinks(): void
{
$invoices = DB::table('invoices')
->whereNull('stripe_invoice_id')
->whereNull('user_payment_option_id')
->get(['id', 'user_id']);
foreach ($invoices as $invoice) {
$options = DB::table('user_payment_options')
->where('user_id', $invoice->user_id)
->whereNull('stripe_subscription_id')
->whereIn('status', ['active', 'grandfathered'])
->pluck('id');
if ($options->count() !== 1) {
continue;
}
DB::table('invoices')
->where('id', $invoice->id)
->update(['user_payment_option_id' => $options->first()]);
}
}
};

View file

@ -0,0 +1,348 @@
# Legacy-Rechnungen per Überweisung: Versand, Zahlungseingang, Bestandsschutz-Ende
Stand: 16.09.2026 (Analyse auf DEV-DB vom 15.09.2026) · Branch `technik/betriebsfixes-2026-09`
## Status 16.09.2026: umgesetzt (Phase B, 0, 1, 2, 3)
- Branch `technik/betriebsfixes-2026-09`, alle 1670 Tests grün.
- Entscheidungen wie vorgeschlagen; E7 = Archiv **behalten und ausblenden**.
- `legacy:settle-unpaid` auf der DEV-DB gelaufen: 9 Bündel gekündigt, MAN-00001/00002 storniert,
6 Vorgänge zurückgedreht, 7 Einzelkäufe abgeschrieben. **Nur 1 der 6** offenen Vorgänge konnte
sofort neu ausgestellt werden (MAN-00003 ersetzt 43541-7) — die anderen **5 haben keine
Rechnungsadresse** (in der Alt-Rechnung stand nur der Name) und stehen im Panel „Blockierte
Vereinbarungen": Adresse im User-Profil nachtragen → „Jetzt abrechnen" → „Rechnung senden".
- Auf Live nach dem Deploy: `php artisan migrate`, dann `php artisan legacy:settle-unpaid --dry-run`
prüfen und ohne `--dry-run` ausführen.
- Phase 4 (Automatisierung) bewusst offen.
## 0. Worum es geht
Bestandskunden aus dem Altsystem zahlen weiter per **Überweisung** auf das eigene
Konto. Neue Abschlüsse laufen ausschließlich über Stripe. Für die Bestandskunden
fehlt heute der gesamte Bearbeitungsweg *nach* der Rechnungserzeugung:
> Rechnung fällig → **Knopfdruck**: Mail mit Rechnung (PDF), Zahlungsbitte und Hinweis
> „ohne Zahlung endet der Bestandsschutz in 14 Tagen" → Zahlung eingegangen: **als bezahlt
> markieren** → keine Zahlung: **Bestandsschutz beenden** (Rechnung stornieren, Bündel kündigen).
---
## 1. Ist-Zustand (was schon da ist, was fehlt)
### 1.1 Drei Datenwelten — nur eine davon ist relevant
| Tabelle | Inhalt | Rolle in diesem Vorhaben |
|---|---|---|
| `legacy_invoices` (887 Zeilen, 91 ohne `paid_at`) | Read-only-Archiv der Alt-Rechnungen (D-12), eigener PDF-Renderer | Historie, bleibt read-only. Die „unbezahlten" teilen sich in 94 `hold` (nie bezahlt → erledigt, s. 1.4) und 13 `pending`/`reminder` (offene Vorgänge 2026, die sauber abzuarbeiten sind). |
| `user_payment_options` mit `status = grandfathered` (22 laufend) | Die Bestandsschutz-Vereinbarungen (je Portal ein Posten, nur als **Bündel** kündbar – `LegacySubscriptionService::cancelBundleFor()`) | Das ist der „Bestandsschutz", der bei Nichtzahlung beendet wird. |
| `invoices` ohne `stripe_invoice_id` = **MAN-Kreis** (2 Zeilen) | Neue Rechnungen, die der 04:30-Lauf `billing:generate-manual-invoices` beim Erreichen von `current_period_end` erzeugt (`ManualInvoiceService`) | **Hier setzt alles an.** Die Rechnung existiert bereits als Datensatz — aber sonst nichts. |
### 1.2 Was der MAN-Kreis heute kann
- Fälligkeit erkennen, Rechnungsadresse einfrieren, USt via `VatResolver` (DE 19 %, EU mit USt-ID
befreit, Drittland befreit), MAN-Nummer vergeben, `due_date = invoice_date + 14 Tage`
(`billing.manual_due_days`), Periode um ein Jahr weiterschalten.
- Anzeige in `/admin/payments` → Tabelle „Rechnungsausgang (STR/MAN)" (nur lesen).
- Anzeige im Kundenkonto `/admin/me/invoices` (Spalte „Beleg" zeigt für MAN nur „–").
### 1.3 Was fehlt (Lücken, die der Plan schließt)
| # | Lücke | Konsequenz heute |
|---|---|---|
| L1 | **Kein PDF** für MAN-Rechnungen (`LegacyInvoicePdfRenderer` kann nur `LegacyInvoice`) | Kunde bekommt keinen Beleg, Admin kann nichts verschicken |
| L2 | **Keine Mail** – weder bei Erzeugung noch per Hand | Rechnungen MAN-00001/MAN-00002 sind seit **07.08.2026** offen, seit 21.08. überfällig und wurden **nie zugestellt** |
| L3 | **Kein Link Rechnung → Vereinbarung** (`invoices.user_payment_id` ist NULL, zeigt ohnehin auf `user_payments`, nicht auf `user_payment_options`) | Beim „Bestandsschutz beenden" lässt sich nicht sicher sagen, zu welchem Posten die Rechnung gehört; man muss über den User gehen |
| L4 | **Keine Admin-Aktionen**: senden, erinnern, bezahlt markieren, stornieren | Zahlungseingänge lassen sich nicht verbuchen → Kunden bleiben ewig „offen" |
| L5 | **Keine Versand-/Mahnhistorie** (kein `sent_at`, `reminded_at`) | Man sieht nicht, was der Kunde wann bekommen hat |
| L6 | **Blockierte Vereinbarungen sind unsichtbar**: Posten ohne Rechnungsadresse werden täglich übersprungen (nur Log-Warning) | UPO #15 (User 59128) und #18 (User 62129, netto 1.000 €) hängen seit **02./14.08.2026** fest — und weil `current_period_end` in der Vergangenheit liegt, gelten sie per `scopeGrandfatheredNow()` als **nicht mehr aktiv** („Buchung erforderlich"), obwohl nie eine Rechnung gestellt wurde |
| L7 | **Rechnungsaussteller** ist nirgends konfiguriert; der Legacy-Renderer hat adametz.media (BP24) und Stern Consulting (Presseecho) inkl. IBAN **hartcodiert** | Für neue Rechnungen braucht es eine Quelle für Absender, Bankverbindung, Steuernummer |
### 1.4 Befund 16.09.: Der Bestandsschutz wurde aus **unbezahlten** Rechnungen abgeleitet
Einordnung (Kevin, 16.09.): Legacy-Status `hold` = nie bezahlt, überfällig, Kunde oft nicht mehr
erreichbar → **ersatzlos erledigt**. Nur `pending` und `reminder` sind noch offene, sauber
abzuarbeitende Vorgänge.
`legacy:grandfather-subscriptions` hat diese Unterscheidung **nicht** gemacht: Es filtert nur auf
`payment_option.type = recurring` und `user_payment_option.status = active`, nicht auf den
Zahlungsstatus der Quellrechnung. Ergebnis über die 22 laufenden Bestandsschutz-Posten:
| Quellrechnung | Posten | Bewertung |
|---|---|---|
| `paid` | 7 (#2, #3, #5, #6, #7, #11, #22) | **Echte Bestandskunden.** Regelbetrieb über den MAN-Kreis, nächste Fälligkeit Okt. 2026 – Jun. 2027. |
| `hold` | **9** (#4, #8, #10, #14, #15, #16, #17, #18, #19) | **Karteileichen.** Zahlungshistorie durchgehend `hold` seit 2018–2022 (z. B. #8: letzte Zahlung 12/2017, seither 8× unbezahlt). Darunter genau die beiden Kunden, an die **MAN-00001/00002** gestellt wurden (User 59455: 5× hold seit 2021, User 60911: nur hold, nie gezahlt) – und die beiden „blockierten" Posten #15/#18 aus L6. |
| `reminder` | 5 (#12, #13, #20, #21, #23) | **Offene Vorgänge 2026.** Rechnungen vom 30.06.2026, im Altsystem bereits gemahnt, fällig 14.07.2026, Leistungszeitraum bis Mitte 2027. Gemischte Historie (#13: 2024/25 bezahlt; #12: seit 2020 hold, 2026 trotzdem gemahnt). |
| `pending` (recurring) | 1 (#1, User 9338) | **Offener Vorgang.** 49 € Presseecho, Rechnung 07.07.2026, Kunde seit 2011, zuletzt wechselnd. |
Die sechs offenen Vorgänge haben eine Tücke: Ihre Legacy-Rechnung (Juni/Juli 2026) ist **unbezahlt**,
aber die Migration hat `current_period_end` bereits auf **2027** gesetzt (= `next_due_date` der
Rechnung). Der MAN-Kreis würde sie also erst 2027 wieder abrechnen – die offene 2026-Forderung
kennt das neue System nicht.
Daneben gibt es **7 `pending`-Einzelkäufe** (19,95 €, Juli 2026, BP24): Gmail-/SEO-Accounts, PM ist
jeweils `published`, nie bezahlt. Kein Bestandsschutz betroffen, aber Teil des „sauber abarbeiten".
Die 94 `hold`-Archivrechnungen (17.299,93 €, 2018–2026) haben alle eine User-Zuordnung.
Was bereits **mitgenutzt** werden kann (kein Neubau nötig):
- Globales Mail-Gate `GateOutgoingMail` (Bounce-Sperrliste) → jede neue Mailable ist automatisch geschützt.
- Mail-Layout `x-emails.layout` / `x-emails.button`, Muster `BookingPaymentConfirmed` (BCC an `legal.contact_email`).
- `ActivityLog` für den Verlauf, Permission `billing:manage`, Confirmation-Modals im Payments-Panel (Vorbild: „Bündel kündigen").
- PDF-Primitiven (`text()`, `line()`, `buildPdf()`) im `LegacyInvoicePdfRenderer` – ohne externe PDF-Library.
---
## 2. Zielbild: der Workflow in vier Schritten
```
04:30-Lauf Admin (Knopf) Admin (Knopf) Admin (Knopf)
─────────── ───────────── ───────────── ─────────────
Periode endet ──▶ MAN-Rechnung ──▶ „Rechnung senden" ──▶ „Als bezahlt markieren" oder
(status open) Mail + PDF-Anhang paid_at, status paid „Bestandsschutz beenden"
due_date = +14 Tage status void + Bündel gekündigt
sent_at gesetzt (cancelBundleFor)
│
└──▶ optional „Erinnerung senden"
(gleiche Mail, Mahn-Ton, reminded_at)
```
Rechnungen werden **nicht gelöscht**, sondern auf `void` („Storniert") gesetzt: die
MAN-Nummernfolge bleibt lückenlos, Buchhaltung/GoBD sind sauber, und der Vorgang bleibt
nachvollziehbar. Löschen im Wortsinn passiert beim **Bestandsschutz** (Posten → `cancelled`),
nicht bei der Rechnung.
---
## 3. Entwicklungsplan
Vier Phasen, jede für sich testbar und deploybar. Phase 0–3 ergeben zusammen den
gewünschten Knopfdruck-Workflow; Phase 4 ist optionale Automatisierung.
### Phase B — Bereinigung der Altlast (zuerst, unabhängig vom Rest) · Aufwand: klein
Ein einmaliger, wiederholbarer Befehl `legacy:settle-unpaid` (`--dry-run`, JSON-Report wie die
anderen Migrationsbefehle), der die Befunde aus 1.4 auflöst:
1. **`hold` → erledigt.**
- Die **9 aus `hold` abgeleiteten Bestandsschutz-Posten** werden gekündigt
(`cancelBundleFor()` je User, mit `ActivityLog`-Eintrag „Bestandsschutz beendet: Quellrechnung
nie bezahlt"). Damit fallen auch die „blockierten" Posten #15/#18 aus L6 weg – die brauchen
keine Rechnungsadresse mehr.
- **MAN-00001/00002** werden storniert (`void`, Grund „Kunde hat Vorjahresrechnung nie bezahlt") —
nicht versendet. Damit erledigt sich E6.
- Die 94 `hold`-Archivrechnungen: Vorschlag **im Archiv belassen** (read-only, D-12; Löschen von
Rechnungsdatensätzen ist auch bei nie bezahlten unschön), aber im Admin-Archiv standardmäßig
ausblenden (Filter „ohne hold"), und in der Kundenansicht `/admin/me/invoices` nicht mehr als
„offen" anzeigen. Wenn du sie wirklich weg haben willst: `--delete-hold` als bewusste Option,
löscht die 94 Zeilen (→ E7).
2. **`reminder`/`pending` (recurring, 6 Posten) → in den neuen Workflow überführen.**
Einfachster Weg: die Periode der 6 Posten **um ein Jahr zurückdrehen** (`current_period_start/end`
= Werte der offenen Legacy-Rechnung, also Ende = 30.06./07.07.2026). Dann stellt der nächste
04:30-Lauf ganz normal eine MAN-Rechnung für 2026/27 aus, und ab da gilt der Knopfdruck-Workflow
aus Phase 2/3 (senden → 14 Tage → bezahlt oder beenden). Die Legacy-Rechnung wird im Archiv als
`superseded` markiert (neue Status-Zeichenkette, verweist auf die MAN-Nummer), damit nicht zwei
Forderungen für denselben Zeitraum nebeneinander stehen. Kein zweiter Rechnungsweg, kein
Sonderfall im Code.
3. **7 `pending`-Einzelkäufe (19,95 €).** Fachliche Entscheidung (→ E8): entweder als `uncollectible`
abschreiben (Vorschlag – Gmail-/SEO-Accounts, Aufwand > Ertrag) oder die PM depublizieren.
Technisch ist beides ein Status-Update im Archiv bzw. ein Aufruf der bestehenden Depublikation.
4. **Ursache beheben:** `GrandfatherLegacySubscriptions` bekommt einen Filter auf den Zahlungsstatus
der Quellrechnung (`paid` → Bestandsschutz; `pending`/`reminder` → Bestandsschutz **mit** offener
Forderung, Periode = Rechnungsperiode; `hold` → überspringen). Der Befehl ist laut Doku
replay-fähig; so kann das nicht noch einmal passieren.
**Tests:** `LegacySettleUnpaidCommandTest` (hold-Posten gekündigt, MAN-Rechnungen void, reminder-Posten
zurückgedreht, paid-Posten unangetastet, dry-run schreibt nichts); `GrandfatherLegacySubscriptionsTest`
um die drei Status-Fälle erweitern.
**Ergebnis nach Phase B:** 7 gesunde Bestandskunden + 6 offene Vorgänge, die der MAN-Kreis binnen
eines Tages als Rechnungen ausstellt. Erst dann lohnen sich die Knöpfe.
### Phase 0 — Datenmodell & Konfiguration (Fundament) · Aufwand: klein
1. **Migration `invoices`** (Tabelle ist winzig, kein FULLTEXT → unkritisch):
- `user_payment_option_id` (nullable FK) — Link zur Vereinbarung (schließt L3)
- `sent_at`, `reminded_at`, `reminder_count` (tinyint) — Versandhistorie (L5)
- `voided_at`, `void_reason` (string, nullable) — Nachvollziehbarkeit beim Storno
2. **`ManualInvoiceService::invoiceFor()`** setzt künftig `user_payment_option_id`.
3. **Backfill** für MAN-00001/00002 in derselben Migration (User 59455 → UPO #14, User 60911 → UPO #16;
eindeutig, weil je genau ein Grandfathered-Posten) – auch wenn beide in Phase B storniert werden,
soll der Storno an der richtigen Vereinbarung hängen.
4. **`config/billing.php` → Block `issuer`** (Absender der MAN-Rechnungen, alles per `.env` überschreibbar):
Firmenname, Inhaber, Straße, PLZ/Ort, Telefon, E-Mail, Web, IBAN, BIC, Bank, Steuernummer,
USt-ID (letztere existiert schon als `own_vat_id`). Schließt L7 — und ist gleichzeitig die
Datenquelle für die Mail (Bankverbindung) und das PDF.
5. **`InvoicePolicy`** (analog `LegacyInvoicePolicy`): `view`/`downloadPdf` für Eigentümer und Admin,
`manage` = `billing:manage`.
**Tests:** Migration + Backfill (Pest, `RefreshDatabase`), `invoiceFor()` schreibt `user_payment_option_id`.
### Phase 1 — PDF für MAN-Rechnungen · Aufwand: mittel
1. PDF-Primitiven aus `LegacyInvoicePdfRenderer` in einen Trait `App\Services\Billing\Concerns\DrawsPdf`
(oder abstrakte Basisklasse) ziehen — reine Verschiebung, Legacy-Renderer bleibt funktional identisch.
2. **`ManualInvoicePdfRenderer`** (für `Invoice`): Absender aus `billing.issuer`, Empfänger aus
`invoice_billing_addresses`-Snapshot, Leistungsname aus `legacy_conditions.name` bzw. `payment_option`,
Leistungszeitraum aus der Vereinbarung (`current_period_start/end` **vor** dem Weiterschalten —
dafür genügt: Periodenstart = `invoice_date`, Ende = +1 Jahr/Monat), Netto/USt/Brutto,
`tax_note` (Reverse-Charge/Drittland), Zahlungsziel `due_date`, Bankverbindung.
Ergebnis wird **nicht** gespeichert (wie beim Archiv on-demand gerendert); `pdf_path` bleibt frei
für eine spätere Ablage.
3. **Routen**: `admin/invoices/{invoice}/pdf` (Admin) und `me/invoices/{invoice}/pdf` (Kunde),
Controller `InvoicePdfController`, Policy-Check; für STR-Rechnungen 404 (dort bleibt Stripe der Beleg).
4. **Kundenkonto**: in `/admin/me/invoices` Spalte „Beleg" für MAN → Button „PDF öffnen" statt „–".
**Tests:** Renderer liefert gültiges PDF (`%PDF-`-Header, Rechnungsnummer/Beträge im Stream), Route
403/404-Fälle, Legacy-Renderer-Snapshot unverändert (bestehende Tests grün).
### Phase 2 — Mail & Service (die eigentliche Logik) · Aufwand: mittel
1. **`ManualInvoiceDunningService`** (neu, `App\Services\Billing`), kapselt die vier Aktionen —
jede transaktional, jede mit `ActivityLog`-Eintrag (neuer `ActivityType::ManualInvoice`):
- `send(Invoice, User $actor)` — nur `status = open` und ohne `stripe_invoice_id`;
setzt `sent_at`, **`due_date = heute + manual_due_days`** (die 14 Tage zählen ab Zustellung,
nicht ab dem 04:30-Lauf — s. Entscheidung E1), verschickt Mailable.
- `remind(Invoice, User $actor)` — nur wenn `sent_at` gesetzt; `reminded_at`, `reminder_count++`,
gleiche Mailable im Mahn-Ton (Variante über Konstruktor-Flag).
- `markPaid(Invoice, Carbon $paidAt, User $actor)` — `status = paid`, `paid_at`.
- `terminateForNonPayment(Invoice, User $actor, ?string $note)` — `status = void`, `voided_at`,
`void_reason`; ruft `LegacySubscriptionService::cancelBundleFor($invoice->user)`; **weitere offene
MAN-Rechnungen desselben Users** werden ebenfalls storniert (ein Kunde, ein Bündel, ein Vorgang).
2. **Mailable `ManualInvoiceIssued`** (queued, `ShouldQueue`, BCC an `legal.contact_email` wie
`BookingPaymentConfirmed`), PDF als **Anhang** (`attachData` aus dem Renderer), View
`emails/billing/manual-invoice-issued.blade.php`:
- Anrede, Bezug auf Bestandsschutz-Leistung und Portal, Rechnungsnummer/-datum, Netto/USt/Brutto
- **Bankverbindung** (aus `billing.issuer`) und Verwendungszweck (= Rechnungsnummer)
- Zahlungsziel als Datum (`due_date`)
- Klar formulierter Hinweis: *„Geht die Zahlung nicht bis zum {due_date} ein, endet Ihr
Bestandsschutz (…) zu diesem Datum. Ihre bisherigen Pressemitteilungen bleiben online; neue
Veröffentlichungen sind dann nur über die aktuellen Tarife möglich."* (Wortlaut → E3)
- Button „Rechnung im Konto ansehen", Hilfe-Link
- Mahn-Variante: Betreff „Zahlungserinnerung …", Einleitung „wir haben noch keinen Zahlungseingang
festgestellt", Frist identisch (`due_date`), sonst gleicher Inhalt.
- Transaktionsmail → kein Opt-out; Bounce-Sperrliste greift automatisch über `GateOutgoingMail`.
3. **Kunden-Mail bei Beendigung?** Vorschlag: **ja, eine kurze Bestätigung** („Bestandsschutz beendet,
Rechnung storniert, so buchen Sie neu"). Ist eine zweite kleine Mailable `GrandfatheringTerminated`.
Kann in Phase 3 mit einer Checkbox im Modal optional gemacht werden (→ E4).
**Tests (`tests/Feature/Billing/ManualInvoiceDunningTest.php`):** `Mail::fake()`; send setzt `sent_at`
und neue `due_date`, Mail an User mit PDF-Anhang und BCC; send auf `paid`/STR-Rechnung wirft
Exception; remind ohne send verweigert; markPaid; terminate → Rechnung `void`, alle Grandfathered-Posten
`cancelled`, zweite offene MAN-Rechnung ebenfalls `void`; gesperrte Adresse → Mail wird vom Gate
gestoppt, `sent_at` bleibt trotzdem gesetzt? → **nein**: Service prüft vorher
`EmailSuppressionService::isSuppressed()` und verweigert mit Hinweis (Admin soll es sehen).
### Phase 3 — Admin-Oberfläche · Aufwand: mittel
Alles in der bestehenden Volt-Seite `livewire/admin/payments/index.blade.php`, kein neuer Screen:
1. **Tabelle „Rechnungsausgang (STR/MAN)"** — neue Spalte „Aktionen" (nur MAN-Zeilen):
- „PDF" (öffnet Phase-1-Route)
- Status `open`, noch nicht gesendet → **„Rechnung senden"** (Modal: Empfänger-Adresse, Betrag,
neues Zahlungsziel, Warnung falls Adresse gesperrt)
- Status `open`, gesendet → **„Erinnerung senden"**, **„Als bezahlt markieren"** (Modal mit
Datumsfeld, Default heute), **„Bestandsschutz beenden"** (Danger-Modal wie „Bündel kündigen",
zeigt die betroffenen Posten + weitere offene Rechnungen des Users, optional Checkbox „Kunde
informieren")
- Zusatzinfos in der Status-Zelle: „gesendet 17.09.", „erinnert 01.10. (1×)", Badge **„überfällig"**
wenn `due_date < heute` und `open`, „storniert" mit Grund-Tooltip.
2. **Filter/Tab „nur MAN offen"** oberhalb der Tabelle (Standardansicht dieses Vorgangs), KPI-Karte
**„Offene MAN-Rechnungen"** (Anzahl + Summe, davon überfällig).
3. **Panel „Blockierte Vereinbarungen"** (schließt L6; nach Phase B aktuell leer, bleibt aber als
Frühwarnung sinnvoll): Grandfathered-Posten mit `current_period_end <
heute`, für die keine offene MAN-Rechnung existiert — mit Grund („keine Rechnungsadresse") und
Link auf `admin.users.edit`. Sobald die Adresse gepflegt ist, stellt der nächste 04:30-Lauf die
Rechnung aus. Alternativ Button **„Jetzt abrechnen"** (ruft `invoiceFor()` sofort auf) — spart das
Warten bis zum nächsten Morgen.
4. **Abrechnungs-Sektion im Bestandsschutz-Panel**: pro Posten die letzte MAN-Rechnung (Nummer, Status)
inline anzeigen, damit man vom Kunden zur Rechnung kommt, nicht nur umgekehrt.
**Tests (`AdminPaymentsPageTest` erweitern):** Buttons nur für `billing:manage`; `send` aus der Seite
ruft Service (Mail::fake); `markPaid`, `terminate` mit Erfolgsmeldung; Blockiert-Panel listet UPO ohne
Rechnungsadresse.
### Phase 4 — Optional: Automatisierung · Aufwand: klein, aber Entscheidungssache
Erst nach ein paar Wochen Handbetrieb sinnvoll, wenn der Wortlaut und der Ablauf stehen:
- `billing:generate-manual-invoices` versendet direkt nach Erzeugung (`--send`), statt auf den Knopf
zu warten.
- Scheduler-Task `billing:remind-manual-invoices`: automatische Erinnerung z. B. 7 Tage nach
Zahlungsziel, **nie** automatisches Beenden — das bleibt bewusst ein Klick.
- Tägliche Admin-Notiz (bestehende Monitoring-Mail-Infrastruktur) „N MAN-Rechnungen > 14 Tage
überfällig".
---
## 4. Entscheidungen, die ich brauche
| # | Frage | Vorschlag |
|---|---|---|
| **E1** | Läuft die 14-Tage-Frist ab **Rechnungsdatum** (04:30-Lauf) oder ab **Versand**? Der Knopf kann Tage später gedrückt werden. | Ab Versand: beim „Senden" `due_date = heute + 14`. Rechnungsdatum bleibt das Erzeugungsdatum. |
| **E2** | Aussteller: **adametz.media für beide Portale**? (Presseecho-Altrechnungen liefen auf Stern Consulting GmbH.) | Ja, ein Aussteller, in `billing.issuer` konfiguriert. Bitte Bankverbindung/Steuernummer bestätigen — ich übernehme sonst die Werte aus dem Legacy-Renderer (Sparkasse Bielefeld, DE96 …, St.-Nr. 349/5001/4350, USt-ID DE298729654). |
| **E3** | Wortlaut des Bestandsschutz-Hinweises — insbesondere: bleiben veröffentlichte PMs online? Was passiert mit der API (Bestandskunden mit Token)? | Mein Entwurf in Phase 2; du korrigierst den Text im Blade. |
| **E4** | Soll der Kunde bei „Bestandsschutz beenden" eine Mail bekommen? | Ja, optional per Checkbox im Modal (Default: an). |
| **E5** | Bestätigung Phase B: alle **9** aus `hold` abgeleiteten Bestandsschutz-Posten kündigen – auch #13-artige Grenzfälle gibt es dort nicht, alle haben mehrjährige hold-Ketten. Betroffen sind u. a. #18 (1.000 € netto, seit 2022 nie bezahlt). | Ja, alle 9 kündigen. |
| **E6** | MAN-00001/00002 gehören zu zwei dieser hold-Kunden. | Stornieren, nicht senden (erledigt sich mit E5). |
| **E7** | Die 94 `hold`-Archivrechnungen: im Archiv behalten und ausblenden, oder physisch löschen? | Behalten + ausblenden. Löschen nur auf ausdrücklichen Wunsch (`--delete-hold`). |
| **E8** | 7 unbezahlte Einzelkäufe (19,95 €, Juli 2026, PMs online): abschreiben (`uncollectible`) oder PM depublizieren? | Abschreiben; optional zusätzlich depublizieren, wenn du das Signal setzen willst. |
| **E9** | Die 6 offenen Vorgänge (`reminder`/`pending`): Periode zurückdrehen und über den MAN-Kreis neu ausstellen (Rechnungsdatum = jetzt, Zahlungsziel +14 Tage ab Versand) — auch wenn der Kunde im Altsystem schon eine Rechnung vom 30.06. hat? | Ja: eine saubere neue Rechnung mit Hinweis „ersetzt Rechnung Nr. … vom 30.06.2026" im Text, statt zwei Rechnungswege zu pflegen. |
---
## 5. Reihenfolge & Aufwand (grob)
| Phase | Inhalt | Aufwand | Abhängig von |
|---|---|---|---|
| B | `legacy:settle-unpaid` (hold kündigen/stornieren, offene Vorgänge zurückdrehen), Grandfather-Filter | ~3 h | E5, E7, E8, E9 |
| 0 | Migration, Backfill, `billing.issuer`, Policy | ~2 h | E2 |
| 1 | PDF-Renderer, Routen, Kundenkonto-Button | ~4 h | 0 |
| 2 | Service + Mailable(s), Tests | ~4 h | 0, 1, E1, E3 |
| 3 | Admin-Aktionen, Blockiert-Panel, KPI | ~4–5 h | 2, E4 |
| 4 | Automatisierung | ~2 h | Erfahrung aus Handbetrieb |
**Einfachster Weg zum Ziel = Phase B → 0 → 1 → 2 → 3**, etwa 2 Arbeitstage inkl. Tests. Phase B
ist dabei die wichtigste: ohne sie würden die neuen Knöpfe Rechnungen an neun Kunden schicken, die
seit Jahren nicht zahlen, und die sechs echten offenen Forderungen aus 2026 blieben unsichtbar. Alles läuft
in bestehenden Dateien/Mustern (Volt-Seite, Mail-Layout, PDF-Primitiven, ActivityLog), keine neue
Abhängigkeit, keine neue Tabelle — nur fünf Spalten auf `invoices`.
Nach dem Deploy: Doku `docs/user-admin/Billing-und-Rechnungskreise.md` Abschnitt 3 und 7 (Punkt 4
„PDF-Erzeugung") aktualisieren.
---
## 6. Betroffene Dateien (Überblick)
```
neu
app/Console/Commands/SettleUnpaidLegacyInvoices.php (Phase B, `legacy:settle-unpaid`)
tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
app/Services/Billing/ManualInvoicePdfRenderer.php
app/Services/Billing/ManualInvoiceDunningService.php
app/Services/Billing/Concerns/DrawsPdf.php (aus LegacyInvoicePdfRenderer extrahiert)
app/Mail/ManualInvoiceIssued.php
app/Mail/GrandfatheringTerminated.php (E4)
app/Http/Controllers/InvoicePdfController.php
app/Policies/InvoicePolicy.php
database/migrations/2026_09_xx_add_dunning_columns_to_invoices_table.php
resources/views/emails/billing/manual-invoice-issued.blade.php
resources/views/emails/billing/grandfathering-terminated.blade.php
tests/Feature/Billing/ManualInvoiceDunningTest.php
tests/Feature/Billing/ManualInvoicePdfTest.php
geändert
app/Console/Commands/GrandfatherLegacySubscriptions.php (Filter auf Zahlungsstatus der Quellrechnung)
resources/views/livewire/admin/invoices/index.blade.php (hold standardmäßig ausblenden)
app/Models/Invoice.php (fillable, casts, Relation userPaymentOption, Scopes manual/openManual)
app/Services/Billing/ManualInvoiceService.php (user_payment_option_id setzen)
app/Services/Billing/LegacyInvoicePdfRenderer.php (Primitiven in Trait)
app/Enums/ActivityType.php (ManualInvoice)
config/billing.php (issuer-Block)
routes/admin.php, routes/customer.php (PDF-Routen)
resources/views/livewire/admin/payments/index.blade.php
resources/views/livewire/customer/invoices.blade.php
tests/Feature/Billing/AdminPaymentsPageTest.php
tests/Feature/Billing/ManualInvoiceGenerationTest.php
```

View file

@ -99,7 +99,51 @@ Täglicher Scheduler-Lauf (04:30): `billing:generate-manual-invoices`
Nicht abrechenbare Fälle (fehlende Rechnungsadresse, kein Intervall) werden
geloggt, **die Periode bleibt stehen** — der nächste Lauf versucht es erneut.
Optionen: `--dry-run`, `--limit=50`.
Solche Vereinbarungen erscheinen in `/admin/payments` im Panel **„Blockierte
Vereinbarungen"** (Grund + Link ins User-Profil, „Jetzt abrechnen" nach dem
Nachtragen der Adresse). Optionen: `--dry-run`, `--limit=50`.
Jede MAN-Rechnung trägt `user_payment_option_id` (Link zur Vereinbarung) und
ggf. `replaces_legacy_invoice_number`, wenn sie eine offene Alt-Rechnung aus
dem Archiv ersetzt (siehe 3a).
### 3a. Bearbeitung nach der Ausstellung (Überweisungskunden, 16.09.2026)
MAN-Kunden zahlen per Überweisung. Der Weg nach dem 04:30-Lauf ist ein
Knopfdruck-Workflow in `/admin/payments` → „Rechnungsausgang", Filter
„Offene MAN-Rechnungen" (Default). Logik: `ManualInvoiceDunningService`,
jede Aktion mit Eintrag im Aktivitäts-Verlauf (`ActivityType::ManualInvoice`).
| Aktion | Wirkung |
|---|---|
| **Rechnung senden** | Mail `ManualInvoiceIssued` mit PDF-Anhang (`ManualInvoicePdfRenderer`), Bankverbindung aus `billing.issuer`, Hinweis auf Bestandsschutz-Ende. Setzt `sent_at` und **`due_date = heute + manual_due_days`** — die Frist zählt ab Versand. Verweigert bei Adresse auf der Mail-Sperrliste. |
| **Erinnerung** | Gleiche Mail im Mahn-Ton (`isReminder`), `reminded_at`/`reminder_count`. Verstrichene Frist → Nachfrist `billing.reminder_due_days` (7) ab heute, sonst bleibt `due_date`. |
| **Bezahlt** | `status = paid`, `paid_at` = Datum laut Kontoauszug. |
| **Bestandsschutz beenden** | Rechnung `void` (`voided_at`, `void_reason`), weitere offene MAN-Rechnungen des Kunden ebenfalls `void`, `LegacySubscriptionService::cancelBundleFor()`; optional Kundenmail `GrandfatheringTerminated`. Rechnungen werden nie gelöscht (Nummernfolge, GoBD). |
| **Entfernen** (Panel „Blockierte Vereinbarungen") | Nicht abrechenbare Vereinbarung ohne Rechnung beenden (`abandonAgreement`): Bündel gekündigt, offene MAN-Rechnungen `void`, offene Alt-Rechnung im Archiv `uncollectible`. Keine Kundenmail. |
PDF-Routen: `admin.invoices.pdf` (Admin) und `me.invoices.manual-pdf`
(Kunde, `InvoicePolicy::downloadPdf`); STR-Rechnungen liefern 404 — dort
bleibt Stripe der Beleg.
### 3b. Altlast aus unbezahlten Legacy-Rechnungen (`legacy:settle-unpaid`)
Legacy-Status `hold` = nie bezahlt, ersatzlos erledigt (Einordnung
16.09.2026). `pending`/`reminder` = offene Vorgänge. Der Befehl
`legacy:settle-unpaid` (`--dry-run`, `--no-invoice`) räumt auf:
- Bestandsschutz-Posten mit `hold`-Quellrechnung → Bündel gekündigt, offene
MAN-Rechnungen storniert. Die Archivzeilen bleiben erhalten und werden im
Admin-Archiv sowie im Kundenkonto standardmäßig ausgeblendet
(`LegacyInvoice::scopeWithoutHold`).
- `pending`/`reminder` (recurring) → Periode auf den Leistungsbeginn der
offenen Rechnung zurückgedreht (`legacy_conditions.open_legacy_invoice`),
sofort MAN-Rechnung ausgestellt; die Alt-Rechnung erhält Status
`superseded` (+ `raw_snapshot.superseded_by_invoice_number`).
- `pending`/`reminder` (single) → `uncollectible`.
`legacy:grandfather-subscriptions` wertet den Zahlungsstatus seit 16.09.2026
selbst aus (hold → übersprungen, offen → Periode auf Leistungsbeginn).
**Befüllung**: `legacy:grandfather-subscriptions` (Migrations-Runbook, nach
`legacy:archive-invoices`) leitet die aktiven jährlichen Vereinbarungen aus
@ -140,6 +184,7 @@ Rechnungsadresse bestimmt:
| Befehl | Zweck | Scheduler |
|---|---|---|
| `billing:generate-manual-invoices` | MAN-Fälligkeitslauf (Abschnitt 3) | täglich 04:30 |
| `legacy:settle-unpaid` | Altlast unbezahlter Legacy-Rechnungen auflösen (Abschnitt 3b) | manuell, wiederholbar |
| `billing:sync-stripe-plans` | Tarife + Einzel-PM als Netto-Produkte/Preise nach Stripe synchronisieren (idempotent; `--dry-run`) | manuell |
| — Admin-UI: `/admin/payments/plans` | Tarif-Pflege (Preise, Kontingent, Tageslimit, aktiv/inaktiv) mit Sofort-Sync nach Stripe (`StripePlanSyncService`): Preisänderung legt ein neues Price-Objekt an und deaktiviert das alte; Bestandsabos behalten ihren Preis | — |
| `legacy:grandfather-subscriptions` | Aktive Legacy-Abos aus dem Archiv migrieren | manuell (Migrations-Runbook) |
@ -155,7 +200,9 @@ Rechnungsadresse bestimmt:
|---|---|---|---|
| `enforce_booking` | `BILLING_ENFORCE_BOOKING` | `false` | Submit-Gate scharf schalten (Launch-Schalter) |
| `invoice_number_padding` | — | `5` | Stellen der laufenden Nummer |
| `manual_due_days` | `BILLING_MANUAL_DUE_DAYS` | `14` | Zahlungsziel MAN-Rechnungen |
| `manual_due_days` | `BILLING_MANUAL_DUE_DAYS` | `14` | Zahlungsziel MAN-Rechnungen (ab Versand) |
| `reminder_due_days` | `BILLING_REMINDER_DUE_DAYS` | `7` | Nachfrist einer Zahlungserinnerung |
| `issuer.*` | `BILLING_ISSUER_*` | adametz.media | Aussteller, Bankverbindung, Steuernummer auf MAN-Rechnungen und in der Zahlungsmail |
| `vat_rate` | `BILLING_VAT_RATE` | `0.19` | USt-Satz für steuerpflichtige Fälle |
| `eu_country_codes` | — | EU-27 ohne DE | Basis der Drittland-/EU-Unterscheidung |
@ -205,8 +252,9 @@ CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`.
`billing:sync-stripe-plans` erneut ausführen (Live-Produkt-IDs).
3. **VIES-Validierung** der USt-ID (aktuell Formatprüfung; Stripe prüft
die im Checkout erfasste USt-ID asynchron selbst).
4. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`);
Archiv-PDFs existieren bereits on-demand.
4. **PDF-Erzeugung**: MAN-Rechnungen erledigt (16.09.2026,
`ManualInvoicePdfRenderer`, inkl. `tax_note`); STR-Belege kommen weiter
von Stripe.
5. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung
auf neue Tarife am `grandfathered_until` (D-13-Rest); erst dann wird
das unbegrenzte Bestandskontingent ggf. migriert.

View file

@ -0,0 +1,30 @@
<x-emails.layout title="Ihr Bestandsschutz ist beendet" preheader="Die Rechnung {{ $invoice->number }} wurde storniert; Ihr Bestandsschutz auf pressekonto ist beendet.">
<p>Guten Tag {{ $user->name }},</p>
<p>
für die Rechnung <strong>{{ $invoice->number }}</strong> vom
{{ $invoice->invoice_date?->format('d.m.Y') }} ist bis zum Zahlungsziel
({{ $invoice->due_date?->format('d.m.Y') }}) kein Zahlungseingang bei uns
verzeichnet worden. Wie in der Rechnung angekündigt, haben wir daher heute
</p>
<dl class="meta">
<dt>Rechnung</dt>
<dd>{{ $invoice->number }} — storniert, es besteht keine Forderung mehr</dd>
<dt>Bestandsschutz</dt>
<dd>beendet zum {{ now()->format('d.m.Y') }}</dd>
</dl>
<p>
Ihre bereits veröffentlichten Pressemitteilungen bleiben unverändert online.
Für neue Veröffentlichungen stehen Ihnen die aktuellen Tarife und der
Einzelversand auf pressekonto zur Verfügung — jederzeit direkt in Ihrem Konto buchbar.
</p>
<x-emails.button :url="$bookingUrl">Tarife ansehen</x-emails.button>
<div class="note">
<p>Sollte Ihre Zahlung sich mit dieser E-Mail überschnitten haben, melden Sie sich bitte kurz — wir klären das unkompliziert.</p>
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div>
</x-emails.layout>

View file

@ -0,0 +1,93 @@
@php
$formatCents = fn (int $cents): string => number_format($cents / 100, 2, ',', '.')
.' '.($invoice->currency === 'EUR' ? '€' : $invoice->currency);
$dueDate = $invoice->due_date?->format('d.m.Y');
$title = $isReminder ? 'Zahlungserinnerung zu Ihrer Rechnung' : 'Ihre Rechnung für den Bestandsschutz';
$preheader = $isReminder
? 'Für die Rechnung '.$invoice->number.' liegt uns noch kein Zahlungseingang vor.'
: 'Rechnung '.$invoice->number.' im Anhang — bitte bis zum '.$dueDate.' überweisen.';
@endphp
<x-emails.layout :title="$title" :preheader="$preheader">
<p>Guten Tag {{ $user->name }},</p>
@if ($isReminder)
<p>
zu der unten aufgeführten Rechnung konnten wir bisher keinen Zahlungseingang
feststellen. Vielleicht ist sie in der Ablage untergegangen — die Rechnung liegt
dieser E-Mail noch einmal als PDF bei.
</p>
@else
<p>
als Bestandskunde nutzen Sie Ihre bisherige Vereinbarung auf pressekonto weiter
zu den gewohnten Konditionen (Bestandsschutz). Für den neuen Leistungszeitraum
erhalten Sie anbei Ihre Rechnung als PDF.
</p>
@if ($invoice->replaces_legacy_invoice_number)
<p>
Diese Rechnung ersetzt die Rechnung Nr. {{ $invoice->replaces_legacy_invoice_number }}
aus unserem bisherigen System. Bitte begleichen Sie nur noch die hier
aufgeführte Rechnung.
</p>
@endif
@endif
<dl class="meta">
<dt>Rechnungsnummer</dt>
<dd>{{ $invoice->number }}</dd>
<dt>Rechnungsdatum</dt>
<dd>{{ $invoice->invoice_date?->format('d.m.Y') }}</dd>
<dt>Leistung</dt>
<dd>{{ $serviceName }}</dd>
@if ($servicePeriod)
<dt>Leistungszeitraum</dt>
<dd>{{ $servicePeriod[0] }} – {{ $servicePeriod[1] }}</dd>
@endif
<dt>Betrag (netto)</dt>
<dd>{{ $formatCents($invoice->amount_cents) }}</dd>
@if ($invoice->tax_cents > 0)
<dt>Umsatzsteuer</dt>
<dd>{{ $formatCents($invoice->tax_cents) }}</dd>
@endif
<dt>Rechnungsbetrag</dt>
<dd><strong>{{ $formatCents($invoice->total_cents) }}</strong></dd>
<dt>Zahlbar bis</dt>
<dd><strong>{{ $dueDate }}</strong></dd>
</dl>
<p>Bitte überweisen Sie den Rechnungsbetrag auf folgendes Konto:</p>
<dl class="meta">
<dt>Kontoinhaber</dt>
<dd>{{ $issuer['account_holder'] }}</dd>
<dt>IBAN</dt>
<dd>{{ $issuer['iban'] }}</dd>
<dt>BIC</dt>
<dd>{{ $issuer['bic'] }}</dd>
<dt>Bank</dt>
<dd>{{ $issuer['bank_name'] }}</dd>
<dt>Verwendungszweck</dt>
<dd>Rechnungsnummer {{ $invoice->number }}</dd>
</dl>
<div class="meta-danger">
<p style="margin: 0;">
<strong>Wichtiger Hinweis zum Bestandsschutz:</strong>
Geht die Zahlung nicht bis zum <strong>{{ $dueDate }}</strong> bei uns ein, endet
Ihr Bestandsschutz zu diesem Datum und die Rechnung wird storniert. Ihre bereits
veröffentlichten Pressemitteilungen bleiben online; neue Veröffentlichungen sind
danach nur noch über die aktuellen Tarife auf pressekonto möglich.
</p>
</div>
<x-emails.button :url="$invoicesUrl">Rechnung im Konto ansehen</x-emails.button>
<div class="note">
<p>
Sie möchten die Vereinbarung nicht fortführen? Dann müssen Sie nichts weiter tun —
der Bestandsschutz endet mit Ablauf des Zahlungsziels automatisch. Die aktuellen
Tarife finden Sie unter <a href="{{ $bookingUrl }}">Buchungen &amp; Add-ons</a>.
</p>
<p>Falls Sie den Betrag bereits überwiesen haben, betrachten Sie diese E-Mail bitte als gegenstandslos.</p>
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a> oder per E-Mail an {{ $issuer['email'] }}.</p>
</div>
</x-emails.layout>

View file

@ -23,6 +23,17 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
public string $pdfFilter = 'all';
/**
* `hold` = im Altsystem nie bezahlt, ersatzlos erledigt (16.09.2026).
* Bleibt im Archiv, wird aber standardmäßig ausgeblendet.
*/
public bool $showHold = false;
public function updatedShowHold(): void
{
$this->resetPage();
}
public function updatedSearch(): void
{
$this->resetPage();
@ -50,7 +61,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
public function resetFilters(): void
{
$this->reset(['search', 'portalFilter', 'statusFilter', 'mappingFilter', 'pdfFilter']);
$this->reset(['search', 'portalFilter', 'statusFilter', 'mappingFilter', 'pdfFilter', 'showHold']);
$this->resetPage();
}
@ -82,6 +93,8 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
'generated_pdf_count' => $supportsPdfGeneratedAt
? (clone $baseQuery)->whereNotNull('pdf_generated_at')->count()
: 0,
'hold_count' => (clone $baseQuery)->where('status', LegacyInvoice::STATUS_HOLD)->count(),
'open_legacy_count' => (clone $baseQuery)->openLegacy()->count(),
'filtered_count' => (clone $filteredQuery)->count(),
],
'supportsPdfGeneratedAt' => $supportsPdfGeneratedAt,
@ -91,6 +104,8 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
private function filteredQuery(): Builder
{
return LegacyInvoice::query()
// hold nur zeigen, wenn ausdrücklich gewünscht oder direkt gefiltert.
->when(! $this->showHold && $this->statusFilter !== LegacyInvoice::STATUS_HOLD, fn (Builder $query) => $query->withoutHold())
->when(filled($this->search), function (Builder $query): void {
$search = trim($this->search);
@ -126,7 +141,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Finanzen')"
:title="__('Legacy Rechnungen')"
:description="__('Legacy-Rechnungsarchiv mit read-only Übersicht, Filtern und PDF-Download. Der neue Stripe-Rechnungslauf folgt separat in Phase 8.')"
:description="__('Read-only-Archiv der Alt-Rechnungen mit Filtern und PDF-Download. Neue Rechnungen (Stripe und manueller Kreis) laufen unter Zahlungen. Nie bezahlte Alt-Rechnungen (hold) sind erledigt und standardmäßig ausgeblendet.')"
description-width="720px"
>
<x-slot:meta>
@ -215,9 +230,15 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
</flux:select>
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __(':count Treffer für die aktuelle Filterung. PDF-Dateien werden bei Bedarf aus den archivierten Legacy-Daten erzeugt.', ['count' => number_format($stats['filtered_count'], 0, ',', '.')]) }}
</p>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __(':count Treffer für die aktuelle Filterung. PDF-Dateien werden bei Bedarf aus den archivierten Legacy-Daten erzeugt.', ['count' => number_format($stats['filtered_count'], 0, ',', '.')]) }}
</p>
<flux:checkbox
wire:model.live="showHold"
:label="__(':count erledigte hold-Rechnungen (nie bezahlt) einblenden', ['count' => number_format($stats['hold_count'], 0, ',', '.')])"
/>
</div>
</div>
</article>
@ -277,6 +298,12 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
<flux:table.cell>
@if ($invoice->paid_at)
<span class="badge ok dot">{{ $invoice->status ?? __('Bezahlt') }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_HOLD)
<span class="badge" title="{{ __('Nie bezahlt — ersatzlos erledigt') }}">{{ $invoice->status }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_SUPERSEDED)
<span class="badge hub" title="{{ __('Ersetzt durch :number', ['number' => $invoice->raw_snapshot['superseded_by_invoice_number'] ?? '–']) }}">{{ __('ersetzt') }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_UNCOLLECTIBLE)
<span class="badge">{{ __('abgeschrieben') }}</span>
@else
<span class="badge warn dot">{{ $invoice->status ?? __('Offen') }}</span>
@endif

View file

@ -2,13 +2,18 @@
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Exceptions\ManualInvoiceActionException;
use App\Models\Invoice;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\LegacySubscriptionService;
use App\Services\Billing\ManualInvoiceDunningService;
use App\Services\Billing\ManualInvoiceService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Laravel\Cashier\Subscription;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -25,6 +30,23 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
public string $notificationType = 'success';
/** Rechnungsausgang: `all` oder `man_open` (offene MAN-Rechnungen zuerst bearbeiten). */
public string $invoiceFilter = 'man_open';
/** Eingaben der Aktions-Modals (eine Rechnung zur Zeit). */
public string $paidAtInput = '';
public string $terminateNote = '';
public bool $terminateNotify = true;
public string $abandonNote = '';
public function mount(): void
{
$this->paidAtInput = today()->toDateString();
}
public function updatedSearch(): void
{
$this->resetPage('subscriptionsPage');
@ -33,6 +55,118 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
$this->resetPage('bestandsschutzPage');
}
public function updatedInvoiceFilter(): void
{
$this->resetPage('invoicesPage');
}
/**
* MAN-Rechnung per Mail (mit PDF) an den Kunden senden; Zahlungsziel
* läuft ab heute.
*/
public function sendInvoice(int $invoiceId, ManualInvoiceDunningService $dunning): void
{
$this->runInvoiceAction($invoiceId, fn (Invoice $invoice): string => __(
'Rechnung :number an :email gesendet — zahlbar bis :date.',
$this->messageParts($dunning->send($invoice, auth()->user())),
), 'send-invoice-'.$invoiceId);
}
public function remindInvoice(int $invoiceId, ManualInvoiceDunningService $dunning): void
{
$this->runInvoiceAction($invoiceId, fn (Invoice $invoice): string => __(
'Zahlungserinnerung zu :number an :email gesendet — zahlbar bis :date.',
$this->messageParts($dunning->remind($invoice, auth()->user())),
), 'remind-invoice-'.$invoiceId);
}
public function markInvoicePaid(int $invoiceId, ManualInvoiceDunningService $dunning): void
{
$this->validate(['paidAtInput' => ['required', 'date']]);
$this->runInvoiceAction($invoiceId, function (Invoice $invoice) use ($dunning): string {
$paid = $dunning->markPaid($invoice, Carbon::parse($this->paidAtInput)->startOfDay(), auth()->user());
return __('Rechnung :number als bezahlt markiert (Zahlungseingang :date).', [
'number' => $paid->number,
'date' => $paid->paid_at->format('d.m.Y'),
]);
}, 'paid-invoice-'.$invoiceId);
}
public function terminateInvoice(int $invoiceId, ManualInvoiceDunningService $dunning): void
{
$this->runInvoiceAction($invoiceId, function (Invoice $invoice) use ($dunning): string {
$cancelled = $dunning->terminateForNonPayment(
$invoice,
auth()->user(),
$this->terminateNotify,
filled($this->terminateNote) ? trim($this->terminateNote) : null,
);
$this->reset(['terminateNote', 'terminateNotify']);
$this->resetPage('bestandsschutzPage');
return trans_choice(
'Rechnung :number storniert, :count Bestandsschutz-Posten gekündigt.|Rechnung :number storniert, :count Bestandsschutz-Posten gekündigt.',
$cancelled,
['number' => $invoice->number, 'count' => $cancelled],
);
}, 'terminate-invoice-'.$invoiceId);
}
/**
* Fällige, aber blockierte Vereinbarung sofort abrechnen (nachdem z. B.
* die Rechnungsadresse nachgetragen wurde) statt auf den 04:30-Lauf zu warten.
*/
public function invoiceNow(int $optionId, ManualInvoiceService $manualInvoices): void
{
$this->authorize('billing:manage');
$option = UserPaymentOption::query()->find($optionId);
$invoice = $option ? $manualInvoices->invoiceFor($option) : null;
if (! $invoice) {
$this->notify(__('Die Vereinbarung konnte nicht abgerechnet werden — fehlt die Rechnungsadresse des Kunden?'), 'error');
return;
}
$this->notify(__('Rechnung :number ausgestellt — jetzt an den Kunden senden.', ['number' => $invoice->number]));
}
/**
* Blockierte Vereinbarung ohne Abrechnung entfernen (Kunde nicht
* erreichbar / keine Adresse): Bündel gekündigt, Alt-Rechnung abgeschrieben.
*/
public function abandonAgreement(int $optionId, ManualInvoiceDunningService $dunning): void
{
$this->authorize('billing:manage');
$option = UserPaymentOption::query()->with('user')->find($optionId);
if (! $option) {
$this->notify(__('Die Vereinbarung wurde nicht gefunden.'), 'error');
return;
}
try {
$cancelled = $dunning->abandonAgreement($option, auth()->user(), filled($this->abandonNote) ? trim($this->abandonNote) : null);
$this->reset('abandonNote');
$this->resetPage('bestandsschutzPage');
$this->notify(trans_choice(
'Vereinbarung von :user entfernt — :count Bestandsschutz-Posten gekündigt, keine Rechnung gestellt.|Vereinbarung von :user entfernt — :count Bestandsschutz-Posten gekündigt, keine Rechnung gestellt.',
$cancelled,
['user' => $option->user?->name ?? '–', 'count' => $cancelled],
));
} catch (ManualInvoiceActionException $exception) {
$this->notify($exception->getMessage(), 'error');
}
Flux::modal('abandon-agreement-'.$optionId)->close();
}
/**
* Kündigt das gesamte Bestandsschutz-Bündel eines Kunden gemeinsam
* (M2: keine Einzelkündigung).
@ -61,6 +195,51 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
$this->resetPage('bestandsschutzPage');
}
/**
* Gemeinsamer Rahmen der Rechnungsaktionen: Berechtigung, Laden,
* fachliche Fehlermeldung statt Exception, Modal schließen.
*
* @param \Closure(Invoice): string $action
*/
private function runInvoiceAction(int $invoiceId, \Closure $action, string $modal): void
{
$this->authorize('billing:manage');
$invoice = Invoice::query()->with('user')->find($invoiceId);
if (! $invoice) {
$this->notify(__('Die Rechnung wurde nicht gefunden.'), 'error');
return;
}
try {
$this->notify($action($invoice));
} catch (ManualInvoiceActionException $exception) {
$this->notify($exception->getMessage(), 'error');
}
Flux::modal($modal)->close();
}
/**
* @return array{number: string, email: string, date: string}
*/
private function messageParts(Invoice $invoice): array
{
return [
'number' => (string) $invoice->number,
'email' => (string) $invoice->user?->email,
'date' => (string) $invoice->due_date?->format('d.m.Y'),
];
}
private function notify(string $message, string $type = 'success'): void
{
$this->notification = $message;
$this->notificationType = $type;
}
public function with(): array
{
$plans = Plan::query()->get();
@ -103,6 +282,23 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
->groupBy('user_id')
->pluck('aggregate_count', 'user_id');
// Offene MAN-Rechnungen (Überweisungskunden): der Bearbeitungsstapel.
$openManual = Invoice::query()->manual()->open();
// Fällige Vereinbarungen ohne Rechnung = vom 04:30-Lauf übersprungen
// (fehlende Rechnungsadresse o. ä.) — sonst wäre die Periode weitergeschaltet.
$blockedAgreements = app(ManualInvoiceService::class)->duePaymentOptions(limit: 100);
// Letzte MAN-Rechnung je Bestandsschutz-Posten (für die Inline-Anzeige).
$latestInvoiceByOption = Invoice::query()
->manual()
->whereNotNull('user_payment_option_id')
->orderByDesc('invoice_date')
->orderByDesc('id')
->get()
->unique('user_payment_option_id')
->keyBy('user_payment_option_id');
return [
'plansByPriceId' => $plansByPriceId,
'stats' => [
@ -115,7 +311,13 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
'open_purchases' => SinglePurchase::query()->grantingSubmission()->count(),
'grandfathered_posts' => $bundleCounts->sum(),
'grandfathered_customers' => $bundleCounts->count(),
'open_manual_count' => (clone $openManual)->count(),
'open_manual_cents' => (int) (clone $openManual)->sum('total_cents'),
'overdue_manual_count' => (clone $openManual)->whereDate('due_date', '<', today())->count(),
'unsent_manual_count' => (clone $openManual)->whereNull('sent_at')->count(),
],
'blockedAgreements' => $blockedAgreements,
'latestInvoiceByOption' => $latestInvoiceByOption,
'bestandsschutz' => $this->searchByUser($grandfatheredBase()->with('user:id,name,email'))
->orderBy('user_id')
->orderBy('id')
@ -128,7 +330,8 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
->latest('created_at')
->paginate(25, pageName: 'purchasesPage'),
'invoices' => $this->searchByUser(Invoice::query()->with('user'))
->latest('invoice_date')
->when($this->invoiceFilter === 'man_open', fn (Builder $query) => $query->manual()->open())
->when($this->invoiceFilter === 'man_open', fn (Builder $query) => $query->orderBy('due_date'), fn (Builder $query) => $query->latest('invoice_date'))
->latest('id')
->paginate(25, pageName: 'invoicesPage'),
];
@ -192,7 +395,7 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
@endif
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<section class="grid grid-cols-2 gap-4 lg:grid-cols-5">
<x-portal.stat-card variant="primary" :label="__('Aktive Abos')" :value="number_format($stats['active_subscriptions'], 0, ',', '.')">
<x-slot:meta>{{ __('Stripe-Subscriptions') }}</x-slot:meta>
</x-portal.stat-card>
@ -205,6 +408,15 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<x-portal.stat-card variant="muted" :label="__('Offene Einzel-PMs')" :value="number_format($stats['open_purchases'], 0, ',', '.')">
<x-slot:meta>{{ __('bezahlt, noch nicht eingelöst') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card :variant="$stats['overdue_manual_count'] > 0 ? 'warn' : 'muted'" :label="__('Offene MAN-Rechnungen')" :value="number_format($stats['open_manual_count'], 0, ',', '.')">
<x-slot:meta>
{{ __(':sum € · :overdue überfällig · :unsent ungesendet', [
'sum' => number_format($stats['open_manual_cents'] / 100, 2, ',', '.'),
'overdue' => $stats['overdue_manual_count'],
'unsent' => $stats['unsent_manual_count'],
]) }}
</x-slot:meta>
</x-portal.stat-card>
</section>
{{-- ============== SUCHE ============== --}}
@ -222,6 +434,88 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
</div>
</article>
{{-- ============== BLOCKIERTE VEREINBARUNGEN (fällig, aber ohne Rechnung) ============== --}}
@if ($blockedAgreements->isNotEmpty())
<article class="panel overflow-hidden border-[color:var(--color-warn)]/40">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Blockierte Vereinbarungen') }}</span>
<span class="badge warn dot">{{ trans_choice(':count fällig ohne Rechnung|:count fällig ohne Rechnung', $blockedAgreements->count(), ['count' => $blockedAgreements->count()]) }}</span>
</div>
<div class="px-5 pt-4 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Die Fälligkeit ist erreicht, aber der tägliche Rechnungslauf konnte keine Rechnung ausstellen — in der Regel fehlt die Rechnungsadresse. Entweder Adresse im User-Profil nachtragen und „Jetzt abrechnen", oder die Vereinbarung ohne Abrechnung „Entfernen" (Kunde nicht erreichbar).') }}
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('Kondition') }}</flux:table.column>
<flux:table.column>{{ __('Fällig seit') }}</flux:table.column>
<flux:table.column>{{ __('Grund') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
@foreach ($blockedAgreements as $blocked)
<flux:table.row wire:key="blocked-{{ $blocked->id }}">
<flux:table.cell>
@if ($blocked->user)
<div class="space-y-0.5">
<flux:button size="xs" variant="filled" :href="route('admin.users.edit', $blocked->user)" wire:navigate>
{{ $blocked->user->name }}
</flux:button>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $blocked->user->email }}</div>
</div>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="text-[12px] text-[color:var(--color-ink)]">{{ $blocked->legacy_conditions['name'] ?? ($blocked->paymentOption?->article_number ?? '–') }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ number_format((int) ($blocked->legacy_conditions['net_cents'] ?? $blocked->paymentOption?->price_cents ?? 0) / 100, 2, ',', '.') }} € netto</div>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $blocked->current_period_end?->format('d.m.Y') }}</span>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $blocked->current_period_end?->diffForHumans() }}</div>
</flux:table.cell>
<flux:table.cell>
@if (! $blocked->user?->billingAddress)
<span class="badge warn dot">{{ __('Keine Rechnungsadresse') }}</span>
@else
<span class="badge">{{ __('Siehe Log') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="flex flex-wrap items-center gap-1.5">
<flux:button size="sm" variant="primary" icon="document-plus" wire:click="invoiceNow({{ $blocked->id }})">
{{ __('Jetzt abrechnen') }}
</flux:button>
<flux:modal.trigger name="abandon-agreement-{{ $blocked->id }}">
<flux:button size="sm" variant="danger" icon="trash">{{ __('Entfernen') }}</flux:button>
</flux:modal.trigger>
</div>
<flux:modal name="abandon-agreement-{{ $blocked->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Vereinbarung von :user ohne Abrechnung entfernen?', ['user' => $blocked->user?->name ?? '–']) }}</flux:heading>
<flux:subheading>
{{ __('Es wird keine Rechnung gestellt. Alle Bestandsschutz-Posten des Kunden werden gekündigt, eine noch offene Alt-Rechnung im Archiv wird abgeschrieben. Der Kunde erhält keine E-Mail; für neue Veröffentlichungen gelten die aktuellen Tarife.') }}
</flux:subheading>
</div>
<flux:field>
<flux:label>{{ __('Interner Vermerk (optional)') }}</flux:label>
<flux:input wire:model="abandonNote" placeholder="{{ __('z. B. E-Mail nicht erreichbar, keine Adresse') }}" />
</flux:field>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close><flux:button variant="filled">{{ __('Abbrechen') }}</flux:button></flux:modal.close>
<flux:button variant="danger" icon="trash" wire:click="abandonAgreement({{ $blocked->id }})">{{ __('Entfernen') }}</flux:button>
</div>
</div>
</flux:modal>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table>
</article>
@endif
{{-- ============== BESTANDSSCHUTZ (Legacy-Posten, M3/M2) ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
@ -285,6 +579,18 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $post->current_period_end?->format('d.m.Y') ?? '–' }}
</span>
@if ($lastInvoice = $latestInvoiceByOption[$post->id] ?? null)
<div class="text-[11px] mt-0.5 flex items-center gap-1.5">
<span class="font-mono text-[color:var(--color-ink-2)]">{{ $lastInvoice->number }}</span>
@if ($lastInvoice->status === \App\Enums\InvoiceStatus::Paid)
<span class="badge ok">{{ __('bezahlt') }}</span>
@elseif ($lastInvoice->status === \App\Enums\InvoiceStatus::Open)
<span class="badge warn">{{ $lastInvoice->isOverdue() ? __('überfällig') : ($lastInvoice->sent_at ? __('offen') : __('ungesendet')) }}</span>
@else
<span class="badge">{{ $lastInvoice->status->label() }}</span>
@endif
</div>
@endif
</flux:table.cell>
<flux:table.cell>
@if ($bundleSize > 1)
@ -491,10 +797,21 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechnungsausgang (STR/MAN)') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => number_format($invoices->total(), 0, ',', '.')]) }}
</span>
<div class="flex items-center gap-3">
<flux:select wire:model.live="invoiceFilter" size="sm" class="min-w-[220px]">
<option value="man_open">{{ __('Offene MAN-Rechnungen (zu bearbeiten)') }}</option>
<option value="all">{{ __('Alle Rechnungen (STR + MAN)') }}</option>
</flux:select>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => number_format($invoices->total(), 0, ',', '.')]) }}
</span>
</div>
</div>
@if ($invoiceFilter === 'man_open')
<div class="px-5 pt-4 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Überweisungskunden (Bestandsschutz): Rechnung senden → Zahlung abwarten → als bezahlt markieren. Ohne Zahlung bis zum Zahlungsziel: Bestandsschutz beenden (Rechnung wird storniert, Bündel gekündigt).') }}
</div>
@endif
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Nummer') }}</flux:table.column>
@ -503,6 +820,7 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<flux:table.column>{{ __('Betrag (brutto)') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Rechnungsdatum') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse ($invoices as $invoice)
@ -533,13 +851,38 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<span class="text-[13px] font-semibold text-[color:var(--color-ink)] tabular-nums">{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} €</span>
</flux:table.cell>
<flux:table.cell>
@if ($invoice->status === \App\Enums\InvoiceStatus::Paid)
<span class="badge ok dot">{{ $invoice->status->label() }}</span>
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Open)
<span class="badge warn dot">{{ $invoice->status->label() }}</span>
@else
<span class="badge">{{ $invoice->status->label() }}</span>
@endif
@php($isManualOpen = $invoice->isManual() && $invoice->status === \App\Enums\InvoiceStatus::Open)
<div class="space-y-1">
@if ($invoice->status === \App\Enums\InvoiceStatus::Paid)
<span class="badge ok dot">{{ $invoice->status->label() }}</span>
@elseif ($isManualOpen && $invoice->isOverdue())
<span class="badge err dot">{{ __('Überfällig') }}</span>
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Open)
<span class="badge warn dot">{{ $invoice->status->label() }}</span>
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Void)
<span class="badge" title="{{ $invoice->void_reason }}">{{ $invoice->status->label() }}</span>
@else
<span class="badge">{{ $invoice->status->label() }}</span>
@endif
@if ($invoice->isManual())
<div class="text-[11px] text-[color:var(--color-ink-3)] leading-[1.5]">
@if ($invoice->sent_at)
{{ __('gesendet :date', ['date' => $invoice->sent_at->format('d.m.Y')]) }}
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Open)
{{ __('noch nicht gesendet') }}
@endif
@if ($invoice->reminded_at)
· {{ __('erinnert :date (:n×)', ['date' => $invoice->reminded_at->format('d.m.Y'), 'n' => $invoice->reminder_count]) }}
@endif
@if ($isManualOpen && $invoice->due_date)
· {{ __('zahlbar bis :date', ['date' => $invoice->due_date->format('d.m.Y')]) }}
@endif
@if ($invoice->replaces_legacy_invoice_number)
· {{ __('ersetzt :number', ['number' => $invoice->replaces_legacy_invoice_number]) }}
@endif
</div>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<div class="space-y-0.5">
@ -549,12 +892,125 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
@endif
</div>
</flux:table.cell>
<flux:table.cell>
@if ($invoice->isManual())
<div class="flex flex-wrap items-center gap-1.5">
<flux:button size="xs" variant="filled" icon="document-text" :href="route('admin.invoices.pdf', $invoice)" target="_blank">
{{ __('PDF') }}
</flux:button>
@if ($isManualOpen)
@if (! $invoice->sent_at)
<flux:modal.trigger name="send-invoice-{{ $invoice->id }}">
<flux:button size="xs" variant="primary" icon="paper-airplane">{{ __('Rechnung senden') }}</flux:button>
</flux:modal.trigger>
@else
<flux:modal.trigger name="remind-invoice-{{ $invoice->id }}">
<flux:button size="xs" variant="filled" icon="bell-alert">{{ __('Erinnerung') }}</flux:button>
</flux:modal.trigger>
@endif
<flux:modal.trigger name="paid-invoice-{{ $invoice->id }}">
<flux:button size="xs" variant="filled" icon="check-circle">{{ __('Bezahlt') }}</flux:button>
</flux:modal.trigger>
<flux:modal.trigger name="terminate-invoice-{{ $invoice->id }}">
<flux:button size="xs" variant="danger" icon="x-circle">{{ __('Bestandsschutz beenden') }}</flux:button>
</flux:modal.trigger>
@endif
</div>
@if ($isManualOpen)
{{-- Senden --}}
<flux:modal name="send-invoice-{{ $invoice->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Rechnung :number senden?', ['number' => $invoice->number]) }}</flux:heading>
<flux:subheading>
{{ __('Die Rechnung geht als PDF an :email — mit Bankverbindung, Zahlungsziel :date (:days Tage ab heute) und dem Hinweis, dass der Bestandsschutz ohne Zahlung zu diesem Datum endet.', [
'email' => $invoice->user?->email ?? '–',
'date' => today()->addDays((int) config('billing.manual_due_days', 14))->format('d.m.Y'),
'days' => (int) config('billing.manual_due_days', 14),
]) }}
</flux:subheading>
</div>
<dl class="grid grid-cols-2 gap-y-1 text-[12.5px]">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Betrag (brutto)') }}</dt>
<dd class="font-semibold tabular-nums">{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} €</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('Rechnungsdatum') }}</dt>
<dd>{{ $invoice->invoice_date?->format('d.m.Y') }}</dd>
</dl>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close><flux:button variant="filled">{{ __('Abbrechen') }}</flux:button></flux:modal.close>
<flux:button variant="primary" icon="paper-airplane" wire:click="sendInvoice({{ $invoice->id }})">{{ __('Jetzt senden') }}</flux:button>
</div>
</div>
</flux:modal>
{{-- Erinnern --}}
<flux:modal name="remind-invoice-{{ $invoice->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Zahlungserinnerung zu :number senden?', ['number' => $invoice->number]) }}</flux:heading>
<flux:subheading>
{{ __('Gleiche Rechnung als PDF, Betreff „Zahlungserinnerung". Ist das Zahlungsziel verstrichen, gilt eine Nachfrist von :days Tagen ab heute; sonst bleibt es bestehen.', ['days' => (int) config('billing.reminder_due_days', 7)]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close><flux:button variant="filled">{{ __('Abbrechen') }}</flux:button></flux:modal.close>
<flux:button variant="primary" icon="bell-alert" wire:click="remindInvoice({{ $invoice->id }})">{{ __('Erinnerung senden') }}</flux:button>
</div>
</div>
</flux:modal>
{{-- Bezahlt --}}
<flux:modal name="paid-invoice-{{ $invoice->id }}" class="max-w-md">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Zahlungseingang zu :number verbuchen', ['number' => $invoice->number]) }}</flux:heading>
<flux:subheading>{{ __('Betrag laut Kontoauszug: :amount €. Die Rechnung wird auf „Bezahlt" gesetzt.', ['amount' => number_format($invoice->total_cents / 100, 2, ',', '.')]) }}</flux:subheading>
</div>
<flux:field>
<flux:label>{{ __('Zahlungseingang am') }}</flux:label>
<flux:input type="date" wire:model="paidAtInput" max="{{ today()->toDateString() }}" />
<flux:error name="paidAtInput" />
</flux:field>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close><flux:button variant="filled">{{ __('Abbrechen') }}</flux:button></flux:modal.close>
<flux:button variant="primary" icon="check-circle" wire:click="markInvoicePaid({{ $invoice->id }})">{{ __('Als bezahlt markieren') }}</flux:button>
</div>
</div>
</flux:modal>
{{-- Bestandsschutz beenden --}}
<flux:modal name="terminate-invoice-{{ $invoice->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Bestandsschutz von :user beenden?', ['user' => $invoice->user?->name ?? '–']) }}</flux:heading>
<flux:subheading>
{{ __('Rechnung :number wird storniert (keine Forderung mehr), alle Bestandsschutz-Posten des Kunden werden gekündigt und weitere offene MAN-Rechnungen des Kunden ebenfalls storniert. Veröffentlichte PMs bleiben online; neue Veröffentlichungen nur noch über die aktuellen Tarife.', ['number' => $invoice->number]) }}
</flux:subheading>
</div>
<flux:field>
<flux:label>{{ __('Interner Vermerk (optional)') }}</flux:label>
<flux:input wire:model="terminateNote" placeholder="{{ __('z. B. telefonisch nicht erreichbar') }}" />
</flux:field>
<flux:checkbox wire:model="terminateNotify" :label="__('Kunden per E-Mail informieren (Rechnung storniert, Bestandsschutz beendet, Hinweis auf Tarife)')" />
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close><flux:button variant="filled">{{ __('Abbrechen') }}</flux:button></flux:modal.close>
<flux:button variant="danger" icon="x-circle" wire:click="terminateInvoice({{ $invoice->id }})">{{ __('Bestandsschutz beenden') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
@else
<span class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('Stripe') }}</span>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<flux:table.cell colspan="7">
<div class="px-4 py-8 text-center text-[13px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine Rechnungen im neuen Rechnungsausgang.') }}
{{ $invoiceFilter === 'man_open' ? __('Keine offenen MAN-Rechnungen — alles abgearbeitet.') : __('Noch keine Rechnungen im neuen Rechnungsausgang.') }}
</div>
</flux:table.cell>
</flux:table.row>

View file

@ -16,8 +16,11 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
public function with(): array
{
// Nie bezahlte Alt-Rechnungen (hold) sind erledigt und erscheinen
// dem Kunden nicht mehr als „offen".
$baseQuery = LegacyInvoice::query()
->where('user_id', auth()->id());
->where('user_id', auth()->id())
->withoutHold();
// Neue Rechnungen (STR = Stripe-Spiegelung, MAN = Bestandskreis).
$currentQuery = Invoice::query()->where('user_id', auth()->id());
@ -41,7 +44,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
'paid_count' => (clone $baseQuery)->whereNotNull('paid_at')->count()
+ (clone $currentQuery)->where('status', InvoiceStatus::Paid->value)->count(),
'downloadable_count' => (clone $baseQuery)->count()
+ (clone $currentQuery)->whereNotNull('stripe_invoice_id')->count(),
+ (clone $currentQuery)->count(),
],
];
}
@ -150,8 +153,13 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
<flux:table.cell>
@if ($invoice->status === \App\Enums\InvoiceStatus::Paid)
<span class="badge ok dot">{{ $invoice->status->label() }}</span>
@else
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Open)
<span class="badge warn dot">{{ $invoice->status->label() }}</span>
@if ($invoice->due_date)
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ __('zahlbar bis :date', ['date' => $invoice->due_date->format('d.m.Y')]) }}</div>
@endif
@else
<span class="badge">{{ $invoice->status->label() }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
@ -171,7 +179,15 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
{{ __('Bei Stripe öffnen') }}
</flux:button>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
<flux:button
size="sm"
variant="filled"
icon="document-text"
:href="route('me.invoices.manual-pdf', $invoice)"
target="_blank"
>
{{ __('PDF öffnen') }}
</flux:button>
@endif
</flux:table.cell>
</flux:table.row>
@ -225,9 +241,13 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
</flux:table.cell>
<flux:table.cell>
@if ($invoice->paid_at)
<span class="badge ok dot">{{ $invoice->status ?? __('Bezahlt') }}</span>
<span class="badge ok dot">{{ __('Bezahlt') }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_SUPERSEDED)
<span class="badge hub">{{ __('Ersetzt durch :number', ['number' => $invoice->raw_snapshot['superseded_by_invoice_number'] ?? '–']) }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_UNCOLLECTIBLE)
<span class="badge">{{ __('Erledigt') }}</span>
@else
<span class="badge warn dot">{{ $invoice->status ?? __('Offen') }}</span>
<span class="badge warn dot">{{ __('Offen') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>

View file

@ -2,6 +2,7 @@
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\LeaveImpersonationController;
use App\Http\Controllers\InvoicePdfController;
use App\Http\Controllers\LegacyInvoicePdfController;
use App\Http\Middleware\BlockAdminDuringImpersonation;
use App\Http\Middleware\EnsureUserIsAdmin;
@ -85,6 +86,7 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
Route::middleware('can:billing:manage')->group(function (): void {
Volt::route('admin/invoices', 'admin.invoices.index')->name('admin.invoices.index');
Route::get('admin/legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('admin.legacy-invoices.pdf');
Route::get('admin/invoices/{invoice}/pdf', InvoicePdfController::class)->name('admin.invoices.pdf');
Volt::route('admin/payments', 'admin.payments.index')->name('admin.payments.index');
Volt::route('admin/payments/plans', 'admin.payments.plans')->name('admin.payments.plans');
Volt::route('admin/coupons', 'admin.coupons.index')->name('admin.coupons.index');

View file

@ -2,6 +2,7 @@
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\EmailUnsubscribeController;
use App\Http\Controllers\InvoicePdfController;
use App\Http\Controllers\LegacyInvoicePdfController;
use App\Http\Controllers\ProofPdfController;
use App\Http\Controllers\StripeInvoiceRedirectController;
@ -67,6 +68,7 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
Volt::route('invoices', 'customer.invoices')->name('invoices.index');
Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf');
Route::get('invoices/{invoice}/stripe', StripeInvoiceRedirectController::class)->name('invoices.stripe');
Route::get('invoices/{invoice}/pdf', InvoicePdfController::class)->name('invoices.manual-pdf');
Volt::route('tokens', 'customer.tokens')->name('tokens.index');
Volt::route('profile', 'customer.profile')->name('profile');

View file

@ -68,6 +68,46 @@ test('admin sees legacy invoice archive overview with filters and mapping hints'
->assertSee('BP-2002');
});
test('never-paid hold invoices are hidden by default and can be shown on demand', function () {
$customer = User::factory()->create(['is_active' => true]);
LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 3001,
'user_id' => $customer->id,
'legacy_user_id' => 601,
'number' => 'BP-HOLD-3001',
'amount_cents' => 19900,
'total_cents' => 19900,
'status' => 'hold',
'invoice_date' => now()->subYear(),
'imported_at' => now(),
]);
LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 3002,
'user_id' => $customer->id,
'legacy_user_id' => 601,
'number' => 'BP-REMINDER-3002',
'amount_cents' => 19900,
'total_cents' => 19900,
'status' => 'reminder',
'invoice_date' => now()->subMonths(2),
'imported_at' => now(),
]);
LivewireVolt::test('admin.invoices.index')
->assertSee('BP-REMINDER-3002')
->assertDontSee('BP-HOLD-3001')
->assertSee('1 erledigte hold-Rechnungen')
->set('showHold', true)
->assertSee('BP-HOLD-3001')
->set('showHold', false)
->set('statusFilter', 'hold')
->assertSee('BP-HOLD-3001')
->assertDontSee('BP-REMINDER-3002');
});
test('admin opens generated legacy invoice pdf inline', function () {
/** @var TestCase $this */
$invoice = LegacyInvoice::query()->create([

View file

@ -1,11 +1,19 @@
<?php
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Mail\GrandfatheringTerminated;
use App\Mail\ManualInvoiceIssued;
use App\Models\BillingAddress;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\PaymentOption;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Models\UserPaymentOption;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
@ -94,11 +102,16 @@ test('local invoices appear with number and circle badge', function () {
$this->actingAs(paymentsPageAdmin());
// Default ist der Bearbeitungsstapel (offene MAN-Rechnungen) …
LivewireVolt::test('admin.payments.index')
->assertSee('MAN-2026-000007')
->assertSee('Manuell (MAN)')
->assertDontSee('STR-2026-000042')
// … der Filter „Alle" zeigt auch den STR-Spiegel.
->set('invoiceFilter', 'all')
->assertSee('STR-2026-000042')
->assertSee('Stripe (STR)')
->assertSee('MAN-2026-000007')
->assertSee('Manuell (MAN)');
->assertSee('MAN-2026-000007');
});
test('paid invoices of the last 30 days are summed as revenue', function () {
@ -148,3 +161,206 @@ test('the payments page is not accessible for customers', function () {
->get(route('admin.payments.index'))
->assertForbidden();
});
/**
* Offene MAN-Rechnung samt Bestandsschutz-Posten eines Überweisungskunden.
*
* @return array{0: Invoice, 1: UserPaymentOption, 2: User}
*/
function openManualInvoice(array $overrides = []): array
{
$customer = User::factory()->create(['name' => 'Ulla Überweisung']);
$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' => today(),
'current_period_end' => today()->addYear(),
'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-00021',
'status' => InvoiceStatus::Open->value,
'total_cents' => 19900,
'stripe_invoice_id' => null,
'sent_at' => null,
'due_date' => today()->addDays(14),
...$overrides,
]);
return [$invoice, $agreement, $customer];
}
test('an unsent MAN invoice offers send, paid and terminate; sending mails the customer and sets the due date', function () {
/** @var TestCase $this */
Mail::fake();
[$invoice, , $customer] = openManualInvoice();
$this->actingAs(paymentsPageAdmin());
LivewireVolt::test('admin.payments.index')
->assertSee('Offene MAN-Rechnungen')
->assertSee('MAN-00021')
->assertSee('noch nicht gesendet')
->assertSee('Rechnung senden')
->assertSee('Bestandsschutz beenden')
->assertSee(route('admin.invoices.pdf', $invoice))
->call('sendInvoice', $invoice->id)
->assertSet('notificationType', 'success')
->assertSee('gesendet '.today()->format('d.m.Y'))
->assertSee('zahlbar bis '.today()->addDays(14)->format('d.m.Y'))
->assertSee('Erinnerung');
Mail::assertQueued(ManualInvoiceIssued::class, fn (ManualInvoiceIssued $mail): bool => $mail->hasTo($customer->email));
expect($invoice->fresh()->sent_at)->not->toBeNull();
});
test('a sent MAN invoice can be reminded and marked as paid with a payment date', function () {
/** @var TestCase $this */
Mail::fake();
[$invoice] = openManualInvoice(['sent_at' => now()->subDays(20), 'due_date' => today()->subDays(6)]);
$this->actingAs(paymentsPageAdmin());
LivewireVolt::test('admin.payments.index')
->assertSee('Überfällig')
->call('remindInvoice', $invoice->id)
->assertSet('notificationType', 'success')
->assertSee('erinnert '.today()->format('d.m.Y').' (1×)')
->set('paidAtInput', today()->subDay()->toDateString())
->call('markInvoicePaid', $invoice->id)
->assertSet('notificationType', 'success')
// Bezahlt → der Bearbeitungsstapel ist leer …
->assertSee('Keine offenen MAN-Rechnungen')
->set('invoiceFilter', 'all')
->assertSee('MAN-00021')
->assertSee('bezahlt: '.today()->subDay()->format('d.m.Y'));
Mail::assertQueued(ManualInvoiceIssued::class, fn (ManualInvoiceIssued $mail): bool => $mail->isReminder);
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Paid);
});
test('a future payment date is rejected by validation', function () {
/** @var TestCase $this */
[$invoice] = openManualInvoice(['sent_at' => now()]);
$this->actingAs(paymentsPageAdmin());
LivewireVolt::test('admin.payments.index')
->set('paidAtInput', 'kein-datum')
->call('markInvoicePaid', $invoice->id)
->assertHasErrors(['paidAtInput']);
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Open);
});
test('terminating from the page voids the invoice, cancels the bundle and honours the notify checkbox', function () {
/** @var TestCase $this */
Mail::fake();
[$invoice, $agreement, $customer] = openManualInvoice(['sent_at' => now()->subDays(20), 'due_date' => today()->subDays(6)]);
$this->actingAs(paymentsPageAdmin());
LivewireVolt::test('admin.payments.index')
->assertSee('Ulla Überweisung')
->set('terminateNotify', false)
->set('terminateNote', 'Nicht erreichbar.')
->call('terminateInvoice', $invoice->id)
->assertSet('notificationType', 'success')
->assertSee('1 Bestandsschutz-Posten gekündigt')
->assertSee('Keine offenen MAN-Rechnungen')
->assertSee('Keine laufenden Bestandsschutz-Posten');
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Void);
expect($invoice->fresh()->void_reason)->toContain('Nicht erreichbar.');
expect($agreement->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
Mail::assertNotQueued(GrandfatheringTerminated::class);
});
test('service refusals surface as an error notification instead of an exception', function () {
/** @var TestCase $this */
Mail::fake();
[$invoice] = openManualInvoice();
$this->actingAs(paymentsPageAdmin());
LivewireVolt::test('admin.payments.index')
->call('remindInvoice', $invoice->id)
->assertSet('notificationType', 'error')
->assertSee('noch nicht versendet');
Mail::assertNothingQueued();
});
test('due agreements the daily run could not invoice are listed as blocked and can be invoiced once the address exists', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['name' => 'Bianca Blockiert']);
$agreement = UserPaymentOption::factory()->create([
'user_id' => $customer->id,
'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly', 'price_cents' => 4118])->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'stripe_subscription_id' => null,
'cancelled_at' => null,
'current_period_start' => today()->subYear()->subMonth(),
'current_period_end' => today()->subMonth(),
'legacy_conditions' => ['name' => 'Kleine Mappe', 'legacy_portal' => 'presseecho', 'interval' => 'yearly', 'net_cents' => 4118],
]);
$this->actingAs(paymentsPageAdmin());
$page = LivewireVolt::test('admin.payments.index')
->assertSee('Blockierte Vereinbarungen')
->assertSee('Bianca Blockiert')
->assertSee('Keine Rechnungsadresse')
->call('invoiceNow', $agreement->id)
->assertSet('notificationType', 'error');
BillingAddress::factory()->create(['user_id' => $customer->id, 'country_code' => 'DE']);
$page->call('invoiceNow', $agreement->id)
->assertSet('notificationType', 'success')
->assertDontSee('Blockierte Vereinbarungen')
->assertSee('MAN-00001')
->assertSee('noch nicht gesendet');
expect(Invoice::query()->manual()->open()->where('user_payment_option_id', $agreement->id)->exists())->toBeTrue();
});
test('a blocked agreement can be removed without invoicing', function () {
/** @var TestCase $this */
Mail::fake();
$customer = User::factory()->create(['name' => 'Rita Rückläufer']);
$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' => today()->subYear()->subMonth(),
'current_period_end' => today()->subMonth(),
'legacy_conditions' => ['name' => 'Unbegrenzte PMs', 'legacy_portal' => 'businessportal24', 'interval' => 'yearly', 'net_cents' => 16723],
]);
$this->actingAs(paymentsPageAdmin());
LivewireVolt::test('admin.payments.index')
->assertSee('Blockierte Vereinbarungen')
->assertSee('Rita Rückläufer')
->assertSee('Entfernen')
->set('abandonNote', 'Nicht erreichbar.')
->call('abandonAgreement', $agreement->id)
->assertSet('notificationType', 'success')
->assertSee('keine Rechnung gestellt')
->assertDontSee('Blockierte Vereinbarungen')
->assertSee('Keine laufenden Bestandsschutz-Posten');
expect($agreement->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect(Invoice::count())->toBe(0);
Mail::assertNothingQueued();
});

View file

@ -206,3 +206,53 @@ test('after migration the MAN circle invoices a due legacy agreement with proper
expect($due->first()->fresh()->current_period_end->toDateString())->toBe('2027-05-14');
expect(Invoice::count())->toBe(1);
});
test('a never-paid (hold) latest invoice yields no grandfathering', function () {
$user = User::factory()->create();
legacyArchiveInvoice($user, ['status' => 'hold']);
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])
->expectsOutputToContain('Übersprungen (hold, nie bezahlt)')
->assertSuccessful();
expect(UserPaymentOption::count())->toBe(0);
});
test('an open (reminder) latest invoice is grandfathered with the unpaid period due and the MAN circle re-issues it', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id, 'country_code' => 'DE']);
// Legacy stellte am 30.06.2026 die Periode 14.06.2026–13.06.2027 in
// Rechnung (199,00 € brutto), im Altsystem bereits gemahnt, nie bezahlt.
$legacy = legacyArchiveInvoice($user, [
'number' => 'BP-42489-10',
'legacy_portal' => 'businessportal24',
'status' => 'reminder',
'invoice_date' => '2026-06-30',
'total_cents' => 19900,
'raw_snapshot' => ['service_period_begin_date' => '2026-06-14', 'service_period_end_date' => '2027-06-13'],
'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2027-06-14']],
]);
Carbon::setTestNow('2026-09-16 09:00:00');
$this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful();
$agreement = UserPaymentOption::sole();
expect($agreement->status)->toBe(UserPaymentOptionStatus::Grandfathered);
// Fällig ist der Leistungsbeginn der unbezahlten Rechnung, nicht 2027.
expect($agreement->current_period_end->toDateString())->toBe('2026-06-14');
expect($agreement->current_period_start->toDateString())->toBe('2025-06-14');
expect($agreement->legacy_conditions['open_legacy_invoice']['number'])->toBe('BP-42489-10');
$service = app(ManualInvoiceService::class);
$invoice = $service->invoiceFor($agreement);
expect($invoice)->not->toBeNull();
expect($invoice->replaces_legacy_invoice_number)->toBe('BP-42489-10');
expect($invoice->user_payment_option_id)->toBe($agreement->id);
expect($invoice->total_cents)->toBe(19900);
expect($agreement->fresh()->current_period_end->toDateString())->toBe('2027-06-14');
expect($agreement->fresh()->legacy_conditions)->not->toHaveKey('open_legacy_invoice');
expect($legacy->fresh()->status)->toBe(LegacyInvoice::STATUS_SUPERSEDED);
expect($legacy->fresh()->raw_snapshot['superseded_by_invoice_number'])->toBe($invoice->number);
});

View file

@ -0,0 +1,223 @@
<?php
use App\Console\Commands\SettleUnpaidLegacyInvoices;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
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 Illuminate\Support\Carbon;
/**
* Bestandsschutz-Posten samt Quellrechnung im Archiv, so wie die
* Grandfather-Migration ihn vor der Korrektur angelegt hat.
*
* @return array{0: UserPaymentOption, 1: LegacyInvoice}
*/
function settleFixture(User $user, string $status, array $invoiceOverrides = [], array $postOverrides = []): array
{
static $legacyId = 5000;
$legacyId++;
$legacy = LegacyInvoice::query()->create(array_replace_recursive([
'legacy_portal' => 'businessportal24',
'legacy_id' => $legacyId,
'user_id' => $user->id,
'legacy_user_id' => 70000 + $legacyId,
'number' => 'BP-'.$legacyId,
'amount_cents' => 19900,
'tax_cents' => 0,
'total_cents' => 19900,
'status' => $status,
'invoice_date' => '2026-06-30',
'raw_snapshot' => [
'is_netto' => false,
'service_period_begin_date' => '2026-06-14',
'service_period_end_date' => '2027-06-13',
],
'pdf_payload' => [
'user_payment_option' => ['id' => $legacyId, 'status' => 'active', 'next_due_date' => '2027-06-14'],
'payment_option' => ['id' => 7, 'type' => 'recurring', 'article_number' => 'BP-01'],
'payment_option_translation' => ['name' => 'Unbegrenzte PMs'],
],
'imported_at' => now(),
], $invoiceOverrides));
$post = UserPaymentOption::factory()->create(array_replace([
'user_id' => $user->id,
'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly'])->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => null,
'stripe_subscription_id' => null,
'cancelled_at' => null,
'current_period_start' => '2026-06-14',
'current_period_end' => '2027-06-14',
'legacy_conditions' => [
'legacy_portal' => 'businessportal24',
'interval' => 'yearly',
'net_cents' => 16723,
'name' => 'Unbegrenzte PMs',
'source_invoice_number' => $legacy->number,
],
], $postOverrides));
return [$post, $legacy];
}
function manInvoiceFor(UserPaymentOption $post, string $number): Invoice
{
return Invoice::factory()->create([
'user_id' => $post->user_id,
'user_payment_option_id' => $post->id,
'user_payment_id' => null,
'invoice_billing_address_id' => InvoiceBillingAddress::factory(),
'number' => $number,
'status' => InvoiceStatus::Open->value,
'stripe_invoice_id' => null,
'total_cents' => 19900,
]);
}
beforeEach(function (): void {
Carbon::setTestNow('2026-09-16 09:00:00');
});
afterEach(function (): void {
Carbon::setTestNow();
});
test('hold-derived bundles are cancelled and their open MAN invoices voided', function () {
$user = User::factory()->create();
[$post] = settleFixture($user, 'hold');
$invoice = manInvoiceFor($post, 'MAN-00001');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect($post->fresh()->cancelled_at)->not->toBeNull();
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Void);
expect($invoice->fresh()->voided_at)->not->toBeNull();
expect($invoice->fresh()->void_reason)->toContain('nie bezahlt');
expect(ActivityEvent::query()->where('type', 'manual_invoice')->count())->toBe(1);
expect(ActivityEvent::query()->where('type', 'subscription_change')->count())->toBe(1);
});
test('the hold archive rows stay untouched', function () {
$user = User::factory()->create();
[, $legacy] = settleFixture($user, 'hold');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($legacy->fresh()->status)->toBe('hold');
expect(LegacyInvoice::count())->toBe(1);
});
test('paid-derived posts are left alone', function () {
$user = User::factory()->create();
[$post] = settleFixture($user, 'paid');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($post->fresh()->current_period_end->toDateString())->toBe('2027-06-14');
expect(Invoice::count())->toBe(0);
});
test('open reminder posts are rewound and re-issued as MAN invoices replacing the legacy one', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id, 'country_code' => 'DE']);
[$post, $legacy] = settleFixture($user, 'reminder');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
$invoice = Invoice::query()->manual()->sole();
expect($invoice->status)->toBe(InvoiceStatus::Open);
expect($invoice->user_payment_option_id)->toBe($post->id);
expect($invoice->replaces_legacy_invoice_number)->toBe($legacy->number);
expect($invoice->invoice_date->toDateString())->toBe('2026-09-16');
expect($invoice->amount_cents)->toBe(16723);
expect($invoice->total_cents)->toBe(19900);
// Rhythmus bleibt: die neu ausgestellte Periode endet wie im Altsystem 2027.
expect($post->fresh()->current_period_start->toDateString())->toBe('2026-06-14');
expect($post->fresh()->current_period_end->toDateString())->toBe('2027-06-14');
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($legacy->fresh()->status)->toBe(LegacyInvoice::STATUS_SUPERSEDED);
});
test('an open post without billing address is rewound and reported as conflict for the daily run', function () {
$user = User::factory()->create();
[$post, $legacy] = settleFixture($user, 'pending');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])
->expectsOutputToContain('Konflikt')
->assertSuccessful();
expect(Invoice::count())->toBe(0);
expect($post->fresh()->current_period_end->toDateString())->toBe('2026-06-14');
expect($post->fresh()->legacy_conditions['open_legacy_invoice']['number'])->toBe($legacy->number);
expect($legacy->fresh()->status)->toBe('pending');
});
test('a mixed bundle (hold + paid) is not cancelled but reported', function () {
$user = User::factory()->create();
[$holdPost] = settleFixture($user, 'hold');
[$paidPost] = settleFixture($user, 'paid', ['legacy_portal' => 'presseecho'], []);
$paidPost->update(['legacy_conditions' => array_merge($paidPost->legacy_conditions, ['legacy_portal' => 'presseecho'])]);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])
->expectsOutputToContain('gemischten Bündel')
->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($paidPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
});
test('open single purchases are written off', function () {
$user = User::factory()->create();
$single = LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 9001,
'user_id' => $user->id,
'legacy_user_id' => 79001,
'number' => 'BP-9001',
'amount_cents' => 1995,
'tax_cents' => 0,
'total_cents' => 1995,
'status' => 'pending',
'invoice_date' => '2026-07-08',
'raw_snapshot' => [],
'pdf_payload' => ['payment_option' => ['type' => 'single'], 'user_payment_option' => ['id' => 1, 'status' => 'active']],
'imported_at' => now(),
]);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($single->fresh()->status)->toBe(LegacyInvoice::STATUS_UNCOLLECTIBLE);
});
test('dry-run writes nothing and a second real run is a no-op', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id]);
[$holdPost] = settleFixture($user, 'hold');
$other = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $other->id]);
settleFixture($other, 'reminder');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--dry-run' => true, '--no-report' => true])->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect(Invoice::count())->toBe(0);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect(Invoice::count())->toBe(1);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect(Invoice::count())->toBe(1);
expect(UserPaymentOption::query()->where('status', UserPaymentOptionStatus::Cancelled->value)->count())->toBe(1);
});

View file

@ -0,0 +1,265 @@
<?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\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('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');
});

View file

@ -0,0 +1,142 @@
<?php
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\ManualInvoicePdfRenderer;
use Database\Seeders\RolesAndPermissionsSeeder;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function manualPdfInvoice(array $overrides = []): Invoice
{
$user = User::factory()->create();
$agreement = UserPaymentOption::factory()->create([
'user_id' => $user->id,
'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly'])->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'stripe_subscription_id' => null,
'current_period_start' => '2026-08-07',
'current_period_end' => '2027-08-07',
'legacy_conditions' => ['name' => 'Unbegrenzte PMs', 'legacy_portal' => 'businessportal24', 'interval' => 'yearly', 'net_cents' => 16723],
]);
return Invoice::factory()->create([
'user_id' => $user->id,
'user_payment_id' => null,
'user_payment_option_id' => $agreement->id,
'invoice_billing_address_id' => InvoiceBillingAddress::factory()->create([
'company' => 'Muster GmbH',
'name' => 'Erika Muster',
'salutation_key' => 'mrs',
'address1' => 'Beispielweg 1',
'postal_code' => '33739',
'city' => 'Bielefeld',
'country_code' => 'DE',
'vat_id' => 'DE123456789',
])->id,
'number' => 'MAN-00042',
'status' => InvoiceStatus::Open->value,
'amount_cents' => 16723,
'tax_cents' => 3177,
'total_cents' => 19900,
'is_netto' => false,
'tax_note' => null,
'invoice_date' => '2026-09-16',
'due_date' => '2026-09-30',
'stripe_invoice_id' => null,
'replaces_legacy_invoice_number' => '42489-10',
...$overrides,
]);
}
/**
* Textläufe aus dem PDF-Stream (nur zum Prüfen des Inhalts).
*/
function pdfTextRuns(string $pdf): string
{
preg_match_all('/\((.*?)\) Tj/', $pdf, $matches);
return implode("\n", array_map(fn (string $run): string => mb_convert_encoding($run, 'UTF-8', 'Windows-1252'), $matches[1]));
}
test('the MAN invoice pdf carries issuer, recipient, service period, amounts, due date and bank details', function () {
$invoice = manualPdfInvoice();
$pdf = app(ManualInvoicePdfRenderer::class)->render($invoice);
$text = pdfTextRuns($pdf);
expect($pdf)->toStartWith('%PDF-1.4');
expect($text)
->toContain('adametz.media')
->toContain('Muster GmbH')
->toContain('Frau Erika Muster')
->toContain('USt-ID: DE123456789')
->toContain('Unbegrenzte PMs auf Businessportal24')
->toContain('Leistungszeitraum: 07.08.2026 - 06.08.2027')
->toContain('Rechnungsnummer: MAN-00042')
->toContain('Ersetzt die Rechnung Nr. 42489-10')
->toContain('167,23')
->toContain('MwSt. 19%:')
->toContain('199,00')
->toContain('bis zum 30.09.2026')
->toContain('IBAN: DE96 4805 0161 0065 0356 02')
->toContain('Rechnungsnummer MAN-00042');
});
test('a tax exempt invoice omits the VAT row and prints the tax note', function () {
$invoice = manualPdfInvoice([
'tax_cents' => 0,
'total_cents' => 16723,
'is_netto' => true,
'tax_note' => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge, Art. 196 MwStSystRL).',
]);
$text = pdfTextRuns(app(ManualInvoicePdfRenderer::class)->render($invoice));
expect($text)->not->toContain('MwSt.')
->toContain('Reverse Charge');
});
test('the customer downloads the own MAN pdf, not foreign or stripe invoices', function () {
/** @var TestCase $this */
$invoice = manualPdfInvoice();
$invoice->user->assignRole('customer');
$stranger = User::factory()->create(['is_active' => true]);
$stranger->assignRole('customer');
$this->actingAs($invoice->user)
->get(route('me.invoices.manual-pdf', $invoice))
->assertOk()
->assertHeader('Content-Type', 'application/pdf');
$this->actingAs($stranger)
->get(route('me.invoices.manual-pdf', $invoice))
->assertForbidden();
$stripeInvoice = manualPdfInvoice(['stripe_invoice_id' => 'in_test_123', 'number' => 'STR-00001']);
$stripeInvoice->user->assignRole('customer');
$this->actingAs($stripeInvoice->user)
->get(route('me.invoices.manual-pdf', $stripeInvoice))
->assertNotFound();
});
test('billing admins download any MAN pdf via the admin route', function () {
/** @var TestCase $this */
$invoice = manualPdfInvoice();
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.invoices.pdf', $invoice))
->assertOk()
->assertHeader('Content-Type', 'application/pdf');
});

View file

@ -135,11 +135,26 @@ test('customer sees only own legacy invoices', function () {
'imported_at' => now(),
]);
// Nie bezahlte Alt-Rechnung (hold): erledigt, erscheint dem Kunden nicht.
LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 1003,
'user_id' => $customer->id,
'legacy_user_id' => 501,
'number' => 'RE-CUSTOMER-HOLD',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'hold',
'invoice_date' => now()->subYears(2),
'imported_at' => now(),
]);
$this->actingAs($customer);
LivewireVolt::test('customer.invoices')
->assertSee('RE-CUSTOMER-001')
->assertDontSee('RE-OTHER-001')
->assertDontSee('RE-CUSTOMER-HOLD')
->assertSee('99,00 €');
});