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