Bestandskunden aus dem Altsystem zahlen per Ueberweisung; nach dem 04:30-Lauf fehlte bisher der gesamte Bearbeitungsweg (kein PDF, keine Mail, kein Zahlungseingang, kein Bestandsschutz-Ende). Plan und Befund: dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md. Bereinigung (legacy:settle-unpaid, wiederholbar): - Legacy-Status hold = nie bezahlt, ersatzlos erledigt. 9 daraus abgeleitete Bestandsschutz-Buendel werden gekuendigt, MAN-00001/00002 storniert. Archivzeilen bleiben erhalten, sind aber in Admin-Archiv und Kundenkonto standardmaessig ausgeblendet. - pending/reminder (recurring) = offene Vorgaenge: Periode auf den Leistungsbeginn der unbezahlten Rechnung zurueckgedreht und als MAN-Rechnung neu ausgestellt (Archiv-Status superseded, replaces_legacy_invoice_number). - pending/reminder (single) = abgeschrieben (uncollectible). - legacy:grandfather-subscriptions wertet den Zahlungsstatus jetzt selbst aus. Workflow in /admin/payments (ManualInvoiceDunningService, je Aktion mit Eintrag im Aktivitaets-Verlauf): - Rechnung senden: Mail mit PDF-Anhang, Bankverbindung aus billing.issuer, Hinweis auf Bestandsschutz-Ende; Zahlungsziel zaehlt ab Versand. - Erinnerung: gleiche Mail im Mahn-Ton, Nachfrist bei verstrichener Frist. - Bezahlt: Zahlungseingang laut Kontoauszug verbuchen. - Bestandsschutz beenden: Rechnung storniert (nie geloescht), Buendel gekuendigt, weitere offene MAN-Rechnungen des Kunden storniert, optionale Kundenmail. - Panel "Blockierte Vereinbarungen": faellige Posten ohne Rechnung (meist fehlende Rechnungsadresse) mit "Jetzt abrechnen" oder "Entfernen". Schema: invoices bekommt user_payment_option_id, sent_at, reminded_at, reminder_count, voided_at, void_reason, replaces_legacy_invoice_number (inkl. Backfill der bestehenden MAN-Rechnungen). PDF-Primitiven des Archiv-Renderers in den Trait DrawsPdf ausgelagert; MAN-PDF on-demand ueber admin.invoices.pdf / me.invoices.manual-pdf (InvoicePolicy). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209 lines
8.3 KiB
PHP
209 lines
8.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
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;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Manueller Rechnungskreis (MAN-) für laufende Legacy-Zahlungen.
|
|
*
|
|
* Aktive Alt-Vereinbarungen leben in `user_payment_options` ohne
|
|
* `stripe_subscription_id`. Wie im Legacy-System wird im Hintergrund
|
|
* geprüft, wann eine Rechnung fällig ist (`current_period_end` erreicht),
|
|
* dann eine Rechnung im MAN-Kreis ausgestellt und die Periode
|
|
* weitergeschaltet. Neue Abschlüsse laufen ausschließlich über Stripe
|
|
* (STR-Kreis) und werden hier bewusst ausgeklammert.
|
|
*
|
|
* Preisbasis ist immer NETTO (Entscheidung 12.06.2026): `legacy_conditions.
|
|
* net_cents` (von der Grandfather-Migration aus den Brutto-/Netto-Beträgen
|
|
* der letzten Legacy-Rechnung abgeleitet), sonst der Netto-Preis der
|
|
* `payment_option`. Die Steuer wird zur Rechnungsstellung über den
|
|
* `VatResolver` bestimmt und sauber ausgewiesen (DE immer mit Steuer,
|
|
* EU nur mit gültiger USt-ID befreit, Drittland befreit).
|
|
*/
|
|
class ManualInvoiceService
|
|
{
|
|
use LocksModelForUpdate;
|
|
|
|
public function __construct(
|
|
private readonly InvoiceNumberGenerator $numbers,
|
|
private readonly VatResolver $vat,
|
|
) {}
|
|
|
|
/**
|
|
* @return Collection<int, UserPaymentOption>
|
|
*/
|
|
public function duePaymentOptions(?Carbon $asOf = null, int $limit = 50): Collection
|
|
{
|
|
$asOf = $asOf ?? today();
|
|
|
|
return UserPaymentOption::query()
|
|
->whereIn('status', [
|
|
UserPaymentOptionStatus::Active->value,
|
|
UserPaymentOptionStatus::Grandfathered->value,
|
|
])
|
|
->whereNull('stripe_subscription_id')
|
|
->whereDate('current_period_end', '<=', $asOf)
|
|
->orderBy('current_period_end')
|
|
->limit($limit)
|
|
->with(['user.billingAddress', 'paymentOption'])
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Stellt die fällige Rechnung für eine Vereinbarung aus und schaltet die
|
|
* Periode weiter. Gibt die Rechnung zurück oder null, wenn die
|
|
* Vereinbarung (noch) nicht abrechenbar ist — dann bleibt die Periode
|
|
* unverändert und der nächste Lauf versucht es erneut.
|
|
*/
|
|
public function invoiceFor(UserPaymentOption $option, ?Carbon $asOf = null): ?Invoice
|
|
{
|
|
$asOf = $asOf ?? today();
|
|
|
|
return $this->withLockedByKey($option, function (UserPaymentOption $lockedOption) use ($asOf): ?Invoice {
|
|
if (
|
|
! in_array($lockedOption->status, [
|
|
UserPaymentOptionStatus::Active,
|
|
UserPaymentOptionStatus::Grandfathered,
|
|
], true)
|
|
|| $lockedOption->stripe_subscription_id !== null
|
|
|| $lockedOption->current_period_end->isAfter($asOf)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$lockedOption->loadMissing(['user.billingAddress', 'paymentOption']);
|
|
$user = $lockedOption->user;
|
|
$interval = $this->billingInterval($lockedOption);
|
|
|
|
if (! $user) {
|
|
Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $lockedOption->id]);
|
|
|
|
return null;
|
|
}
|
|
|
|
if (! $interval) {
|
|
Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $lockedOption->id]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$billingAddress = $user->billingAddress;
|
|
|
|
if (! $billingAddress) {
|
|
Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [
|
|
'user_payment_option_id' => $lockedOption->id,
|
|
'user_id' => $user->id,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$netCents = $this->resolveNetCents($lockedOption);
|
|
$treatment = $this->vat->resolve($billingAddress->country_code, $billingAddress->vat_id);
|
|
$taxCents = $this->vat->taxCentsFor($netCents, $treatment);
|
|
|
|
// Adresse pro Rechnung einfrieren (Snapshot-Tabelle).
|
|
$addressSnapshot = InvoiceBillingAddress::query()->create([
|
|
'salutation_key' => $billingAddress->salutation_key,
|
|
'title' => $billingAddress->title,
|
|
'company' => $billingAddress->company,
|
|
'name' => $billingAddress->name,
|
|
'address1' => $billingAddress->address1,
|
|
'address2' => $billingAddress->address2,
|
|
'postal_code' => $billingAddress->postal_code,
|
|
'city' => $billingAddress->city,
|
|
'country_code' => $billingAddress->country_code,
|
|
'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,
|
|
'amount_cents' => $netCents,
|
|
'tax_cents' => $taxCents,
|
|
'total_cents' => $netCents + $taxCents,
|
|
'currency' => $lockedOption->paymentOption?->currency ?? config('billing.default_currency'),
|
|
'is_netto' => $treatment->isTaxExempt(),
|
|
'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()
|
|
: $lockedOption->current_period_end->copy()->addMonth(),
|
|
]);
|
|
|
|
return $invoice;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @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']
|
|
?? $option->paymentOption?->interval;
|
|
|
|
return in_array($interval, ['monthly', 'yearly'], true) ? $interval : null;
|
|
}
|
|
|
|
/**
|
|
* Netto-Vertragsbasis der Vereinbarung. Alle neuen Preise sind netto;
|
|
* für Grandfathered-Vereinbarungen liefert die Migration `net_cents`
|
|
* (aus den Brutto-/Netto-Beträgen der letzten Legacy-Rechnung).
|
|
*/
|
|
private function resolveNetCents(UserPaymentOption $option): int
|
|
{
|
|
$conditions = $option->legacy_conditions ?? [];
|
|
|
|
return (int) ($conditions['net_cents'] ?? $option->paymentOption?->price_cents ?? 0);
|
|
}
|
|
}
|