billing: MAN-Rechnungen fuer Ueberweisungskunden bearbeiten, hold-Altlast bereinigen
Bestandskunden aus dem Altsystem zahlen per Ueberweisung; nach dem 04:30-Lauf fehlte bisher der gesamte Bearbeitungsweg (kein PDF, keine Mail, kein Zahlungseingang, kein Bestandsschutz-Ende). Plan und Befund: dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md. Bereinigung (legacy:settle-unpaid, wiederholbar): - Legacy-Status hold = nie bezahlt, ersatzlos erledigt. 9 daraus abgeleitete Bestandsschutz-Buendel werden gekuendigt, MAN-00001/00002 storniert. Archivzeilen bleiben erhalten, sind aber in Admin-Archiv und Kundenkonto standardmaessig ausgeblendet. - pending/reminder (recurring) = offene Vorgaenge: Periode auf den Leistungsbeginn der unbezahlten Rechnung zurueckgedreht und als MAN-Rechnung neu ausgestellt (Archiv-Status superseded, replaces_legacy_invoice_number). - pending/reminder (single) = abgeschrieben (uncollectible). - legacy:grandfather-subscriptions wertet den Zahlungsstatus jetzt selbst aus. Workflow in /admin/payments (ManualInvoiceDunningService, je Aktion mit Eintrag im Aktivitaets-Verlauf): - Rechnung senden: Mail mit PDF-Anhang, Bankverbindung aus billing.issuer, Hinweis auf Bestandsschutz-Ende; Zahlungsziel zaehlt ab Versand. - Erinnerung: gleiche Mail im Mahn-Ton, Nachfrist bei verstrichener Frist. - Bezahlt: Zahlungseingang laut Kontoauszug verbuchen. - Bestandsschutz beenden: Rechnung storniert (nie geloescht), Buendel gekuendigt, weitere offene MAN-Rechnungen des Kunden storniert, optionale Kundenmail. - Panel "Blockierte Vereinbarungen": faellige Posten ohne Rechnung (meist fehlende Rechnungsadresse) mit "Jetzt abrechnen" oder "Entfernen". Schema: invoices bekommt user_payment_option_id, sent_at, reminded_at, reminder_count, voided_at, void_reason, replaces_legacy_invoice_number (inkl. Backfill der bestehenden MAN-Rechnungen). PDF-Primitiven des Archiv-Renderers in den Trait DrawsPdf ausgelagert; MAN-PDF on-demand ueber admin.invoices.pdf / me.invoices.manual-pdf (InvoicePolicy). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ada41cb067
commit
36691a5b13
33 changed files with 3606 additions and 195 deletions
|
|
@ -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] : []),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
348
app/Console/Commands/SettleUnpaidLegacyInvoices.php
Normal file
348
app/Console/Commands/SettleUnpaidLegacyInvoices.php
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Billing\LegacySubscriptionService;
|
||||
use App\Services\Billing\ManualInvoiceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Bereinigt die Altlast aus unbezahlten Legacy-Rechnungen (Einordnung
|
||||
* 16.09.2026, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
|
||||
*
|
||||
* `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus
|
||||
* Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das
|
||||
* anhand des Zahlungsstatus der jeweiligen Quellrechnung auf:
|
||||
*
|
||||
* - `hold` → Kunde zahlt seit Jahren nicht: Bestandsschutz-Bündel kündigen,
|
||||
* offene MAN-Rechnungen des Kunden stornieren. Die Archiv-
|
||||
* Rechnungen bleiben unverändert erhalten (werden nur ausgeblendet).
|
||||
* - `pending` / `reminder` (wiederkehrend) → echte offene Forderung 2026:
|
||||
* Periode auf die unbezahlte Rechnung zurückdrehen und sofort eine
|
||||
* MAN-Rechnung ausstellen, die die Alt-Rechnung ersetzt
|
||||
* (Archiv-Status `superseded`). Ab da gilt der Knopfdruck-Workflow.
|
||||
* - `pending` / `reminder` (Einzelkauf) → abschreiben (`uncollectible`).
|
||||
* - `paid` → unangetastet.
|
||||
*
|
||||
* Wiederholbar: Bereits gekündigte, stornierte oder ersetzte Vorgänge werden
|
||||
* beim nächsten Lauf übersprungen.
|
||||
*
|
||||
* Verwendung:
|
||||
* php artisan legacy:settle-unpaid --dry-run
|
||||
* php artisan legacy:settle-unpaid
|
||||
*/
|
||||
class SettleUnpaidLegacyInvoices extends Command
|
||||
{
|
||||
protected $signature = 'legacy:settle-unpaid
|
||||
{--dry-run : Nur anzeigen, nichts schreiben}
|
||||
{--no-invoice : Offene Vorgänge nur zurückdrehen, MAN-Rechnung dem 04:30-Lauf überlassen}
|
||||
{--no-report : Keinen JSON-Report schreiben}';
|
||||
|
||||
protected $description = 'Löst Bestandsschutz-Posten aus unbezahlten Legacy-Rechnungen auf (hold kündigen, offene Vorgänge neu ausstellen, Einzelkäufe abschreiben).';
|
||||
|
||||
private const VOID_REASON_HOLD = 'Vorjahresrechnung im Altsystem nie bezahlt (hold) — Bestandsschutz beendet.';
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $report = [];
|
||||
|
||||
private bool $dryRun = false;
|
||||
|
||||
public function handle(
|
||||
LegacySubscriptionService $legacy,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): int {
|
||||
$this->dryRun = (bool) $this->option('dry-run');
|
||||
$this->report = [
|
||||
'bundles_cancelled' => [],
|
||||
'man_invoices_voided' => [],
|
||||
'reopened' => [],
|
||||
'man_invoices_issued' => [],
|
||||
'singles_written_off' => [],
|
||||
'conflicts' => [],
|
||||
'untouched_paid' => [],
|
||||
];
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||
}
|
||||
|
||||
$postsByUser = UserPaymentOption::query()
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereNull('cancelled_at')
|
||||
->with('user')
|
||||
->get()
|
||||
->groupBy('user_id');
|
||||
|
||||
foreach ($postsByUser as $userId => $posts) {
|
||||
$this->settleBundle((int) $userId, $posts, $legacy, $manualInvoices, $activityLog);
|
||||
}
|
||||
|
||||
$this->writeOffSingles();
|
||||
|
||||
$this->table(
|
||||
['Ergebnis', 'Anzahl'],
|
||||
collect($this->report)->map(fn (array $lines, string $key): array => [$key, count($lines)])->values()->all(),
|
||||
);
|
||||
|
||||
foreach ($this->report['conflicts'] as $line) {
|
||||
$this->warn('Konflikt: '.$line);
|
||||
}
|
||||
|
||||
if (! $this->option('no-report')) {
|
||||
$path = sprintf('migration/settle-unpaid-%s.json', now()->format('Ymd-His'));
|
||||
Storage::put($path, json_encode([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'dry_run' => $this->dryRun,
|
||||
...$this->report,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->info("Report: storage/app/{$path}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kunde = ein Bündel. Die Kündigung ist nur gemeinsam möglich, daher
|
||||
* wird nur gekündigt, wenn ALLE Posten des Kunden aus `hold`-Rechnungen
|
||||
* stammen; gemischte Bündel landen als Konflikt im Report.
|
||||
*
|
||||
* @param Collection<int, UserPaymentOption> $posts
|
||||
*/
|
||||
private function settleBundle(
|
||||
int $userId,
|
||||
Collection $posts,
|
||||
LegacySubscriptionService $legacy,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
$classified = $posts->map(fn (UserPaymentOption $post): array => [
|
||||
'post' => $post,
|
||||
'source' => $this->sourceInvoice($post),
|
||||
]);
|
||||
|
||||
$statuses = $classified
|
||||
->map(fn (array $entry): string => $entry['source']?->status ?? 'unknown')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$user = $posts->first()?->user;
|
||||
|
||||
if ($statuses->count() === 1 && $statuses->first() === LegacyInvoice::STATUS_HOLD) {
|
||||
$this->cancelHoldBundle($userId, $user, $classified, $legacy, $activityLog);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($classified as ['post' => $post, 'source' => $source]) {
|
||||
$status = $source?->status;
|
||||
|
||||
if ($status === 'paid') {
|
||||
$this->report['untouched_paid'][] = $this->describe($post, $source);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
|
||||
$this->reopen($post, $source, $manualInvoices, $activityLog);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// hold im gemischten Bündel oder Quelle nicht auffindbar → manuell klären.
|
||||
$this->report['conflicts'][] = $this->describe($post, $source).' · Status '.($status ?? 'unbekannt').' im gemischten Bündel';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array{post: UserPaymentOption, source: ?LegacyInvoice}> $classified
|
||||
*/
|
||||
private function cancelHoldBundle(
|
||||
int $userId,
|
||||
?User $user,
|
||||
Collection $classified,
|
||||
LegacySubscriptionService $legacy,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
foreach ($classified as ['post' => $post, 'source' => $source]) {
|
||||
$this->report['bundles_cancelled'][] = $this->describe($post, $source);
|
||||
}
|
||||
|
||||
$openInvoices = Invoice::query()
|
||||
->manual()
|
||||
->open()
|
||||
->where('user_id', $userId)
|
||||
->get();
|
||||
|
||||
foreach ($openInvoices as $invoice) {
|
||||
$this->report['man_invoices_voided'][] = sprintf('%s · User #%d · %s €', $invoice->number, $userId, $this->euro($invoice->total_cents));
|
||||
}
|
||||
|
||||
if ($this->dryRun || ! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $openInvoices, $legacy, $activityLog): void {
|
||||
$count = $legacy->cancelBundleFor($user);
|
||||
|
||||
foreach ($openInvoices as $invoice) {
|
||||
$invoice->update([
|
||||
'status' => InvoiceStatus::Void->value,
|
||||
'voided_at' => now(),
|
||||
'void_reason' => self::VOID_REASON_HOLD,
|
||||
]);
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::ManualInvoice,
|
||||
sprintf('Rechnung %s storniert: %s', $invoice->number, self::VOID_REASON_HOLD),
|
||||
$user,
|
||||
$invoice,
|
||||
['command' => $this->getName()],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
}
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::SubscriptionChange,
|
||||
sprintf('Bestandsschutz beendet (%d Posten): Quellrechnung im Altsystem nie bezahlt (hold).', $count),
|
||||
$user,
|
||||
null,
|
||||
['command' => $this->getName(), 'posts' => $count],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Periode auf die unbezahlte Alt-Rechnung zurückdrehen und die offene
|
||||
* Forderung als MAN-Rechnung neu ausstellen.
|
||||
*/
|
||||
private function reopen(
|
||||
UserPaymentOption $post,
|
||||
LegacyInvoice $source,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
// Die unbezahlte Periode beginnt am Leistungsbeginn der Alt-Rechnung;
|
||||
// dorthin wird `current_period_end` zurückgedreht, damit der MAN-Kreis
|
||||
// genau diese Periode abrechnet und den gewohnten Rhythmus fortführt.
|
||||
$periodEnd = $this->legacyPeriodBegin($source);
|
||||
$periodStart = $periodEnd->copy()->subYear();
|
||||
|
||||
$this->report['reopened'][] = $this->describe($post, $source).sprintf(' · Periode → %s..%s', $periodStart->toDateString(), $periodEnd->toDateString());
|
||||
|
||||
if ($this->dryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conditions = $post->legacy_conditions ?? [];
|
||||
$conditions['open_legacy_invoice'] = [
|
||||
'id' => $source->id,
|
||||
'number' => $source->number,
|
||||
'invoice_date' => $source->invoice_date->toDateString(),
|
||||
'total_cents' => $source->total_cents,
|
||||
];
|
||||
|
||||
$post->update([
|
||||
'legacy_conditions' => $conditions,
|
||||
'current_period_start' => $periodStart->toDateString(),
|
||||
'current_period_end' => $periodEnd->toDateString(),
|
||||
]);
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::ManualInvoice,
|
||||
sprintf('Offene Alt-Rechnung %s (%s €) wird im MAN-Kreis neu ausgestellt.', $source->number, $this->euro($source->total_cents)),
|
||||
$post->user,
|
||||
$post,
|
||||
['command' => $this->getName(), 'legacy_invoice_id' => $source->id],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
|
||||
if ($this->option('no-invoice')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = $manualInvoices->invoiceFor($post->fresh());
|
||||
|
||||
if ($invoice) {
|
||||
$this->report['man_invoices_issued'][] = sprintf('%s ersetzt %s · User #%d · %s €', $invoice->number, $source->number, $post->user_id, $this->euro($invoice->total_cents));
|
||||
} else {
|
||||
$this->report['conflicts'][] = $this->describe($post, $source).' · MAN-Rechnung nicht ausstellbar (Rechnungsadresse fehlt?) — 04:30-Lauf versucht es erneut';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbezahlte Einzelkäufe (19,95-€-PMs) sind keine Bestandsschutz-Fälle
|
||||
* und werden abgeschrieben (Entscheidung E8).
|
||||
*/
|
||||
private function writeOffSingles(): void
|
||||
{
|
||||
$singles = LegacyInvoice::query()
|
||||
->openLegacy()
|
||||
->get()
|
||||
->filter(fn (LegacyInvoice $invoice): bool => ($invoice->pdf_payload['payment_option']['type'] ?? null) === 'single');
|
||||
|
||||
foreach ($singles as $invoice) {
|
||||
$this->report['singles_written_off'][] = sprintf('%s · User #%s · %s €', $invoice->number, $invoice->user_id ?? '-', $this->euro($invoice->total_cents));
|
||||
|
||||
if (! $this->dryRun) {
|
||||
$invoice->forceFill(['status' => LegacyInvoice::STATUS_UNCOLLECTIBLE])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sourceInvoice(UserPaymentOption $post): ?LegacyInvoice
|
||||
{
|
||||
$conditions = $post->legacy_conditions ?? [];
|
||||
$number = $conditions['source_invoice_number'] ?? null;
|
||||
|
||||
if (! $number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LegacyInvoice::query()
|
||||
->where('number', $number)
|
||||
->when($conditions['legacy_portal'] ?? null, fn ($query, string $portal) => $query->where('legacy_portal', $portal))
|
||||
->first();
|
||||
}
|
||||
|
||||
private function legacyPeriodBegin(LegacyInvoice $source): Carbon
|
||||
{
|
||||
$begin = $source->raw_snapshot['service_period_begin_date'] ?? null;
|
||||
|
||||
if ($begin && ! str_starts_with((string) $begin, '0000-00-00')) {
|
||||
return Carbon::parse(substr((string) $begin, 0, 10))->startOfDay();
|
||||
}
|
||||
|
||||
return Carbon::instance($source->invoice_date)->startOfDay();
|
||||
}
|
||||
|
||||
private function describe(UserPaymentOption $post, ?LegacyInvoice $source): string
|
||||
{
|
||||
return sprintf(
|
||||
'UPO #%d · User #%d · Quelle %s (%s, %s)',
|
||||
$post->id,
|
||||
$post->user_id,
|
||||
$source?->number ?? '?',
|
||||
$source?->status ?? 'unbekannt',
|
||||
$source?->invoice_date?->toDateString() ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
private function euro(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, ',', '.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
app/Exceptions/ManualInvoiceActionException.php
Normal file
12
app/Exceptions/ManualInvoiceActionException.php
Normal 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 {}
|
||||
24
app/Http/Controllers/InvoicePdfController.php
Normal file
24
app/Http/Controllers/InvoicePdfController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
48
app/Mail/GrandfatheringTerminated.php
Normal file
48
app/Mail/GrandfatheringTerminated.php
Normal 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');
|
||||
}
|
||||
}
|
||||
86
app/Mail/ManualInvoiceIssued.php
Normal file
86
app/Mail/ManualInvoiceIssued.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
app/Policies/InvoicePolicy.php
Normal file
39
app/Policies/InvoicePolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
177
app/Services/Billing/Concerns/DrawsPdf.php
Normal file
177
app/Services/Billing/Concerns/DrawsPdf.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
306
app/Services/Billing/ManualInvoiceDunningService.php
Normal file
306
app/Services/Billing/ManualInvoiceDunningService.php
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
267
app/Services/Billing/ManualInvoicePdfRenderer.php
Normal file
267
app/Services/Billing/ManualInvoicePdfRenderer.php
Normal 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', []));
|
||||
}
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
};
|
||||
348
dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md
Normal file
348
dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md
Normal 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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 & 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
223
tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
Normal file
223
tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
Normal 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);
|
||||
});
|
||||
265
tests/Feature/Billing/ManualInvoiceDunningTest.php
Normal file
265
tests/Feature/Billing/ManualInvoiceDunningTest.php
Normal 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');
|
||||
});
|
||||
142
tests/Feature/Billing/ManualInvoicePdfTest.php
Normal file
142
tests/Feature/Billing/ManualInvoicePdfTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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 €');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue