diff --git a/app/Console/Commands/GrandfatherLegacySubscriptions.php b/app/Console/Commands/GrandfatherLegacySubscriptions.php index fbd7c8a5..0f6a8dc5 100644 --- a/app/Console/Commands/GrandfatherLegacySubscriptions.php +++ b/app/Console/Commands/GrandfatherLegacySubscriptions.php @@ -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] : []), ], ]; } diff --git a/app/Console/Commands/SettleUnpaidLegacyInvoices.php b/app/Console/Commands/SettleUnpaidLegacyInvoices.php new file mode 100644 index 00000000..8cf53db6 --- /dev/null +++ b/app/Console/Commands/SettleUnpaidLegacyInvoices.php @@ -0,0 +1,348 @@ +> */ + 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 $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 $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, ',', '.'); + } +} diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index cbecd94c..afd19172 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -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', }; } } diff --git a/app/Exceptions/ManualInvoiceActionException.php b/app/Exceptions/ManualInvoiceActionException.php new file mode 100644 index 00000000..ee2b8d7f --- /dev/null +++ b/app/Exceptions/ManualInvoiceActionException.php @@ -0,0 +1,12 @@ +isManual(), 404); + + Gate::authorize('downloadPdf', $invoice); + + return $renderer->inlineResponse($invoice); + } +} diff --git a/app/Mail/GrandfatheringTerminated.php b/app/Mail/GrandfatheringTerminated.php new file mode 100644 index 00000000..3b914db0 --- /dev/null +++ b/app/Mail/GrandfatheringTerminated.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/app/Mail/ManualInvoiceIssued.php b/app/Mail/ManualInvoiceIssued.php new file mode 100644 index 00000000..8a421275 --- /dev/null +++ b/app/Mail/ManualInvoiceIssued.php @@ -0,0 +1,86 @@ + */ + 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 + */ + public function attachments(): array + { + $renderer = app(ManualInvoicePdfRenderer::class); + + return [ + Attachment::fromData(fn (): string => $renderer->render($this->invoice), $renderer->filename($this->invoice)) + ->withMime('application/pdf'), + ]; + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index b8d65652..07ca432f 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -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 + */ + public function userPaymentOption(): BelongsTo + { + return $this->belongsTo(UserPaymentOption::class); + } + /** * @return BelongsTo */ @@ -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()); + } } diff --git a/app/Models/LegacyInvoice.php b/app/Models/LegacyInvoice.php index eb1e9814..e9945207 100644 --- a/app/Models/LegacyInvoice.php +++ b/app/Models/LegacyInvoice.php @@ -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; + } } diff --git a/app/Policies/InvoicePolicy.php b/app/Policies/InvoicePolicy.php new file mode 100644 index 00000000..b2ce9da9 --- /dev/null +++ b/app/Policies/InvoicePolicy.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/app/Services/Billing/Concerns/DrawsPdf.php b/app/Services/Billing/Concerns/DrawsPdf.php new file mode 100644 index 00000000..48e501a2 --- /dev/null +++ b/app/Services/Billing/Concerns/DrawsPdf.php @@ -0,0 +1,177 @@ + $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 + */ + 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 + */ + 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; + } +} diff --git a/app/Services/Billing/LegacyInvoicePdfRenderer.php b/app/Services/Billing/LegacyInvoicePdfRenderer.php index 9d8118ca..24a5e9cc 100644 --- a/app/Services/Billing/LegacyInvoicePdfRenderer.php +++ b/app/Services/Billing/LegacyInvoicePdfRenderer.php @@ -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 $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 $data * @return list @@ -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 - */ - 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 - */ - 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; - } } diff --git a/app/Services/Billing/ManualInvoiceDunningService.php b/app/Services/Billing/ManualInvoiceDunningService.php new file mode 100644 index 00000000..30b23971 --- /dev/null +++ b/app/Services/Billing/ManualInvoiceDunningService.php @@ -0,0 +1,306 @@ +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, + )); + } + } +} diff --git a/app/Services/Billing/ManualInvoicePdfRenderer.php b/app/Services/Billing/ManualInvoicePdfRenderer.php new file mode 100644 index 00000000..fdf53350 --- /dev/null +++ b/app/Services/Billing/ManualInvoicePdfRenderer.php @@ -0,0 +1,267 @@ +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 + */ + 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 + */ + 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 + */ + private function issuer(): array + { + return array_map(fn (mixed $value): string => (string) $value, (array) config('billing.issuer', [])); + } +} diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php index a5cd29f8..55ea23e8 100644 --- a/app/Services/Billing/ManualInvoiceService.php +++ b/app/Services/Billing/ManualInvoiceService.php @@ -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'] diff --git a/config/billing.php b/config/billing.php index 08015491..d5005f1e 100644 --- a/config/billing.php +++ b/config/billing.php @@ -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) diff --git a/database/migrations/2026_09_16_120000_add_dunning_columns_to_invoices_table.php b/database/migrations/2026_09_16_120000_add_dunning_columns_to_invoices_table.php new file mode 100644 index 00000000..20947a53 --- /dev/null +++ b/database/migrations/2026_09_16_120000_add_dunning_columns_to_invoices_table.php @@ -0,0 +1,79 @@ +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()]); + } + } +}; diff --git a/dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md b/dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md new file mode 100644 index 00000000..2e3a4510 --- /dev/null +++ b/dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md @@ -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 +``` diff --git a/docs/user-admin/Billing-und-Rechnungskreise.md b/docs/user-admin/Billing-und-Rechnungskreise.md index ae8bbfdd..8461397a 100644 --- a/docs/user-admin/Billing-und-Rechnungskreise.md +++ b/docs/user-admin/Billing-und-Rechnungskreise.md @@ -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. diff --git a/resources/views/emails/billing/grandfathering-terminated.blade.php b/resources/views/emails/billing/grandfathering-terminated.blade.php new file mode 100644 index 00000000..888865dd --- /dev/null +++ b/resources/views/emails/billing/grandfathering-terminated.blade.php @@ -0,0 +1,30 @@ + +

Guten Tag {{ $user->name }},

+ +

+ für die Rechnung {{ $invoice->number }} 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 +

+ +
+
Rechnung
+
{{ $invoice->number }} — storniert, es besteht keine Forderung mehr
+
Bestandsschutz
+
beendet zum {{ now()->format('d.m.Y') }}
+
+ +

+ 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. +

+ + Tarife ansehen + +
+

Sollte Ihre Zahlung sich mit dieser E-Mail überschnitten haben, melden Sie sich bitte kurz — wir klären das unkompliziert.

+

Bei Fragen erreichen Sie uns über den Hilfe-Bereich auf pressekonto.

+
+
diff --git a/resources/views/emails/billing/manual-invoice-issued.blade.php b/resources/views/emails/billing/manual-invoice-issued.blade.php new file mode 100644 index 00000000..64459e5c --- /dev/null +++ b/resources/views/emails/billing/manual-invoice-issued.blade.php @@ -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 + +

Guten Tag {{ $user->name }},

+ + @if ($isReminder) +

+ 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. +

+ @else +

+ 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. +

+ @if ($invoice->replaces_legacy_invoice_number) +

+ 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. +

+ @endif + @endif + +
+
Rechnungsnummer
+
{{ $invoice->number }}
+
Rechnungsdatum
+
{{ $invoice->invoice_date?->format('d.m.Y') }}
+
Leistung
+
{{ $serviceName }}
+ @if ($servicePeriod) +
Leistungszeitraum
+
{{ $servicePeriod[0] }} – {{ $servicePeriod[1] }}
+ @endif +
Betrag (netto)
+
{{ $formatCents($invoice->amount_cents) }}
+ @if ($invoice->tax_cents > 0) +
Umsatzsteuer
+
{{ $formatCents($invoice->tax_cents) }}
+ @endif +
Rechnungsbetrag
+
{{ $formatCents($invoice->total_cents) }}
+
Zahlbar bis
+
{{ $dueDate }}
+
+ +

Bitte überweisen Sie den Rechnungsbetrag auf folgendes Konto:

+ +
+
Kontoinhaber
+
{{ $issuer['account_holder'] }}
+
IBAN
+
{{ $issuer['iban'] }}
+
BIC
+
{{ $issuer['bic'] }}
+
Bank
+
{{ $issuer['bank_name'] }}
+
Verwendungszweck
+
Rechnungsnummer {{ $invoice->number }}
+
+ +
+

+ Wichtiger Hinweis zum Bestandsschutz: + Geht die Zahlung nicht bis zum {{ $dueDate }} 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. +

+
+ + Rechnung im Konto ansehen + +
+

+ 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 Buchungen & Add-ons. +

+

Falls Sie den Betrag bereits überwiesen haben, betrachten Sie diese E-Mail bitte als gegenstandslos.

+

Bei Fragen erreichen Sie uns über den Hilfe-Bereich auf pressekonto oder per E-Mail an {{ $issuer['email'] }}.

+
+
diff --git a/resources/views/livewire/admin/invoices/index.blade.php b/resources/views/livewire/admin/invoices/index.blade.php index a136d24a..0df522f2 100644 --- a/resources/views/livewire/admin/invoices/index.blade.php +++ b/resources/views/livewire/admin/invoices/index.blade.php @@ -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" > @@ -215,9 +230,15 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend -

- {{ __(':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, ',', '.')]) }} -

+
+

+ {{ __(':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, ',', '.')]) }} +

+ +
@@ -277,6 +298,12 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend @if ($invoice->paid_at) {{ $invoice->status ?? __('Bezahlt') }} + @elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_HOLD) + {{ $invoice->status }} + @elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_SUPERSEDED) + {{ __('ersetzt') }} + @elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_UNCOLLECTIBLE) + {{ __('abgeschrieben') }} @else {{ $invoice->status ?? __('Offen') }} @endif diff --git a/resources/views/livewire/admin/payments/index.blade.php b/resources/views/livewire/admin/payments/index.blade.php index 867bee7e..2f4b02d6 100644 --- a/resources/views/livewire/admin/payments/index.blade.php +++ b/resources/views/livewire/admin/payments/index.blade.php @@ -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 ============== --}} -
+
{{ __('Stripe-Subscriptions') }} @@ -205,6 +408,15 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon {{ __('bezahlt, noch nicht eingelöst') }} + + + {{ __(':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'], + ]) }} + +
{{-- ============== SUCHE ============== --}} @@ -222,6 +434,88 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon + {{-- ============== BLOCKIERTE VEREINBARUNGEN (fällig, aber ohne Rechnung) ============== --}} + @if ($blockedAgreements->isNotEmpty()) +
+
+ {{ __('Blockierte Vereinbarungen') }} + {{ trans_choice(':count fällig ohne Rechnung|:count fällig ohne Rechnung', $blockedAgreements->count(), ['count' => $blockedAgreements->count()]) }} +
+
+ {{ __('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).') }} +
+ + + {{ __('User') }} + {{ __('Kondition') }} + {{ __('Fällig seit') }} + {{ __('Grund') }} + {{ __('Aktion') }} + + @foreach ($blockedAgreements as $blocked) + + + @if ($blocked->user) +
+ + {{ $blocked->user->name }} + +
{{ $blocked->user->email }}
+
+ @else + – + @endif +
+ +
{{ $blocked->legacy_conditions['name'] ?? ($blocked->paymentOption?->article_number ?? '–') }}
+
{{ number_format((int) ($blocked->legacy_conditions['net_cents'] ?? $blocked->paymentOption?->price_cents ?? 0) / 100, 2, ',', '.') }} € netto
+
+ + {{ $blocked->current_period_end?->format('d.m.Y') }} +
{{ $blocked->current_period_end?->diffForHumans() }}
+
+ + @if (! $blocked->user?->billingAddress) + {{ __('Keine Rechnungsadresse') }} + @else + {{ __('Siehe Log') }} + @endif + + +
+ + {{ __('Jetzt abrechnen') }} + + + {{ __('Entfernen') }} + +
+ + +
+
+ {{ __('Vereinbarung von :user ohne Abrechnung entfernen?', ['user' => $blocked->user?->name ?? '–']) }} + + {{ __('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.') }} + +
+ + {{ __('Interner Vermerk (optional)') }} + + +
+ {{ __('Abbrechen') }} + {{ __('Entfernen') }} +
+
+
+
+
+ @endforeach +
+
+ @endif + {{-- ============== BESTANDSSCHUTZ (Legacy-Posten, M3/M2) ============== --}}
@@ -285,6 +579,18 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon {{ $post->current_period_end?->format('d.m.Y') ?? '–' }} + @if ($lastInvoice = $latestInvoiceByOption[$post->id] ?? null) +
+ {{ $lastInvoice->number }} + @if ($lastInvoice->status === \App\Enums\InvoiceStatus::Paid) + {{ __('bezahlt') }} + @elseif ($lastInvoice->status === \App\Enums\InvoiceStatus::Open) + {{ $lastInvoice->isOverdue() ? __('überfällig') : ($lastInvoice->sent_at ? __('offen') : __('ungesendet')) }} + @else + {{ $lastInvoice->status->label() }} + @endif +
+ @endif @if ($bundleSize > 1) @@ -491,10 +797,21 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
{{ __('Rechnungsausgang (STR/MAN)') }} - - {{ __(':count Einträge', ['count' => number_format($invoices->total(), 0, ',', '.')]) }} - +
+ + + + + + {{ __(':count Einträge', ['count' => number_format($invoices->total(), 0, ',', '.')]) }} + +
+ @if ($invoiceFilter === 'man_open') +
+ {{ __('Überweisungskunden (Bestandsschutz): Rechnung senden → Zahlung abwarten → als bezahlt markieren. Ohne Zahlung bis zum Zahlungsziel: Bestandsschutz beenden (Rechnung wird storniert, Bündel gekündigt).') }} +
+ @endif {{ __('Nummer') }} @@ -503,6 +820,7 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon {{ __('Betrag (brutto)') }} {{ __('Status') }} {{ __('Rechnungsdatum') }} + {{ __('Aktionen') }} @forelse ($invoices as $invoice) @@ -533,13 +851,38 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon {{ number_format($invoice->total_cents / 100, 2, ',', '.') }} € - @if ($invoice->status === \App\Enums\InvoiceStatus::Paid) - {{ $invoice->status->label() }} - @elseif ($invoice->status === \App\Enums\InvoiceStatus::Open) - {{ $invoice->status->label() }} - @else - {{ $invoice->status->label() }} - @endif + @php($isManualOpen = $invoice->isManual() && $invoice->status === \App\Enums\InvoiceStatus::Open) +
+ @if ($invoice->status === \App\Enums\InvoiceStatus::Paid) + {{ $invoice->status->label() }} + @elseif ($isManualOpen && $invoice->isOverdue()) + {{ __('Überfällig') }} + @elseif ($invoice->status === \App\Enums\InvoiceStatus::Open) + {{ $invoice->status->label() }} + @elseif ($invoice->status === \App\Enums\InvoiceStatus::Void) + {{ $invoice->status->label() }} + @else + {{ $invoice->status->label() }} + @endif + @if ($invoice->isManual()) +
+ @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 +
+ @endif +
@@ -549,12 +892,125 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon @endif
+ + @if ($invoice->isManual()) +
+ + {{ __('PDF') }} + + + @if ($isManualOpen) + @if (! $invoice->sent_at) + + {{ __('Rechnung senden') }} + + @else + + {{ __('Erinnerung') }} + + @endif + + {{ __('Bezahlt') }} + + + {{ __('Bestandsschutz beenden') }} + + @endif +
+ + @if ($isManualOpen) + {{-- Senden --}} + +
+
+ {{ __('Rechnung :number senden?', ['number' => $invoice->number]) }} + + {{ __('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), + ]) }} + +
+
+
{{ __('Betrag (brutto)') }}
+
{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} €
+
{{ __('Rechnungsdatum') }}
+
{{ $invoice->invoice_date?->format('d.m.Y') }}
+
+
+ {{ __('Abbrechen') }} + {{ __('Jetzt senden') }} +
+
+
+ + {{-- Erinnern --}} + +
+
+ {{ __('Zahlungserinnerung zu :number senden?', ['number' => $invoice->number]) }} + + {{ __('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)]) }} + +
+
+ {{ __('Abbrechen') }} + {{ __('Erinnerung senden') }} +
+
+
+ + {{-- Bezahlt --}} + +
+
+ {{ __('Zahlungseingang zu :number verbuchen', ['number' => $invoice->number]) }} + {{ __('Betrag laut Kontoauszug: :amount €. Die Rechnung wird auf „Bezahlt" gesetzt.', ['amount' => number_format($invoice->total_cents / 100, 2, ',', '.')]) }} +
+ + {{ __('Zahlungseingang am') }} + + + +
+ {{ __('Abbrechen') }} + {{ __('Als bezahlt markieren') }} +
+
+
+ + {{-- Bestandsschutz beenden --}} + +
+
+ {{ __('Bestandsschutz von :user beenden?', ['user' => $invoice->user?->name ?? '–']) }} + + {{ __('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]) }} + +
+ + {{ __('Interner Vermerk (optional)') }} + + + +
+ {{ __('Abbrechen') }} + {{ __('Bestandsschutz beenden') }} +
+
+
+ @endif + @else + {{ __('Stripe') }} + @endif +
@empty - +
- {{ __('Noch keine Rechnungen im neuen Rechnungsausgang.') }} + {{ $invoiceFilter === 'man_open' ? __('Keine offenen MAN-Rechnungen — alles abgearbeitet.') : __('Noch keine Rechnungen im neuen Rechnungsausgang.') }}
diff --git a/resources/views/livewire/customer/invoices.blade.php b/resources/views/livewire/customer/invoices.blade.php index 54955976..2caf7881 100644 --- a/resources/views/livewire/customer/invoices.blade.php +++ b/resources/views/livewire/customer/invoices.blade.php @@ -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 @if ($invoice->status === \App\Enums\InvoiceStatus::Paid) {{ $invoice->status->label() }} - @else + @elseif ($invoice->status === \App\Enums\InvoiceStatus::Open) {{ $invoice->status->label() }} + @if ($invoice->due_date) +
{{ __('zahlbar bis :date', ['date' => $invoice->due_date->format('d.m.Y')]) }}
+ @endif + @else + {{ $invoice->status->label() }} @endif
@@ -171,7 +179,15 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo {{ __('Bei Stripe öffnen') }} @else - – + + {{ __('PDF öffnen') }} + @endif @@ -225,9 +241,13 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo @if ($invoice->paid_at) - {{ $invoice->status ?? __('Bezahlt') }} + {{ __('Bezahlt') }} + @elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_SUPERSEDED) + {{ __('Ersetzt durch :number', ['number' => $invoice->raw_snapshot['superseded_by_invoice_number'] ?? '–']) }} + @elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_UNCOLLECTIBLE) + {{ __('Erledigt') }} @else - {{ $invoice->status ?? __('Offen') }} + {{ __('Offen') }} @endif diff --git a/routes/admin.php b/routes/admin.php index f72750ff..e2817b67 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/routes/customer.php b/routes/customer.php index 4095a083..37b352e3 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -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'); diff --git a/tests/Feature/Admin/AdminLegacyInvoiceArchiveTest.php b/tests/Feature/Admin/AdminLegacyInvoiceArchiveTest.php index 32ad4a36..816085df 100644 --- a/tests/Feature/Admin/AdminLegacyInvoiceArchiveTest.php +++ b/tests/Feature/Admin/AdminLegacyInvoiceArchiveTest.php @@ -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([ diff --git a/tests/Feature/Billing/AdminPaymentsPageTest.php b/tests/Feature/Billing/AdminPaymentsPageTest.php index 6ca4afd2..5665af30 100644 --- a/tests/Feature/Billing/AdminPaymentsPageTest.php +++ b/tests/Feature/Billing/AdminPaymentsPageTest.php @@ -1,11 +1,19 @@ 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(); +}); diff --git a/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php b/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php index e6b04022..7a391ac8 100644 --- a/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php +++ b/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php @@ -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); +}); diff --git a/tests/Feature/Billing/LegacySettleUnpaidCommandTest.php b/tests/Feature/Billing/LegacySettleUnpaidCommandTest.php new file mode 100644 index 00000000..69adec1e --- /dev/null +++ b/tests/Feature/Billing/LegacySettleUnpaidCommandTest.php @@ -0,0 +1,223 @@ +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); +}); diff --git a/tests/Feature/Billing/ManualInvoiceDunningTest.php b/tests/Feature/Billing/ManualInvoiceDunningTest.php new file mode 100644 index 00000000..4b80a58c --- /dev/null +++ b/tests/Feature/Billing/ManualInvoiceDunningTest.php @@ -0,0 +1,265 @@ +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'); +}); diff --git a/tests/Feature/Billing/ManualInvoicePdfTest.php b/tests/Feature/Billing/ManualInvoicePdfTest.php new file mode 100644 index 00000000..30e36e4c --- /dev/null +++ b/tests/Feature/Billing/ManualInvoicePdfTest.php @@ -0,0 +1,142 @@ +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'); +}); diff --git a/tests/Feature/CustomerPortalTest.php b/tests/Feature/CustomerPortalTest.php index de1e7dd6..b6e9cea6 100644 --- a/tests/Feature/CustomerPortalTest.php +++ b/tests/Feature/CustomerPortalTest.php @@ -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 €'); });