presseportale/app/Services/Billing/ManualInvoiceService.php
Kevin Adametz 85d7c2b0d5 betrieb: Logs je Thema in Tagesdateien (billing, ki, monitoring, market); Doku: Duplicate-Content-Decision durch Anhang 2 abgelöst
- config/logging.php: Themen-Channels billing, ki, monitoring, market als
  daily-Dateien mit gemeinsamer Aufbewahrung (LOG_DAILY_DAYS), Stack-Default
  auf daily; Channel `testing` für die Suite.
- Fachliche Log-Aufrufe in Billing, KI-Jobs, OperationalAlert und
  Kursdaten auf Log::channel() umgestellt.
- phpunit.xml + TestCase: Suite schreibt ausschließlich nach
  testing-YYYY-MM-DD.log; LogChannelsTest sichert die Konfiguration.
- docs/weiteres: Decision-Update Duplicate-Content entfernt (überholt durch
  Roadmap-Anhang 2), Decision-Update Verlinkung & Backlinks als Referenz
  wieder abgelegt, README-Index nachgezogen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:35:42 +00:00

240 lines
9.9 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\Database\Eloquent\Builder;
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,
) {}
/**
* Fällige, abrechenbare Vereinbarungen. Ein explizites Legacy-Vertrags-
* ende (`grandfathered_until`) deckelt (Audit F06, E6): Liegt der Beginn
* der nächsten Periode dahinter, gibt es keine Rechnung mehr. Blockierte
* Fälle (Sperrvermerk) stehen hinten, damit sie das Fenster nicht
* verstopfen (Audit F16).
*
* @param list<int> $excludeIds bereits in diesem Lauf behandelte IDs
* @return Collection<int, UserPaymentOption>
*/
public function duePaymentOptions(?Carbon $asOf = null, int $limit = 50, array $excludeIds = []): Collection
{
$asOf = $asOf ?? today();
return UserPaymentOption::query()
->whereIn('status', [
UserPaymentOptionStatus::Active->value,
UserPaymentOptionStatus::Grandfathered->value,
])
->whereNull('stripe_subscription_id')
->whereDate('current_period_end', '<=', $asOf)
->where(fn (Builder $query) => $query
->whereNull('grandfathered_until')
->orWhereColumn('grandfathered_until', '>=', 'current_period_end'))
->when($excludeIds !== [], fn (Builder $query) => $query->whereNotIn('id', $excludeIds))
->orderByRaw('(billing_hold_at is not null)')
->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)
// Vertragsende vor Beginn der nächsten Periode (Audit F06, E6).
|| ($lockedOption->grandfathered_until !== null
&& $lockedOption->grandfathered_until->lt($lockedOption->current_period_end))
) {
return null;
}
$lockedOption->loadMissing(['user.billingAddress', 'paymentOption']);
$user = $lockedOption->user;
$interval = $this->billingInterval($lockedOption);
if (! $user) {
return $this->hold($lockedOption, 'Kein Benutzer', ['user_payment_option_id' => $lockedOption->id]);
}
if (! $interval) {
return $this->hold($lockedOption, 'Kein abrechenbares Intervall', ['user_payment_option_id' => $lockedOption->id]);
}
$billingAddress = $user->billingAddress;
if (! $billingAddress) {
return $this->hold($lockedOption, 'Keine Rechnungsadresse', [
'user_payment_option_id' => $lockedOption->id,
'user_id' => $user->id,
]);
}
$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,
'billing_hold_reason' => null,
'billing_hold_at' => 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;
});
}
/**
* Nicht abrechenbar: Sperrvermerk setzen (Audit F16), Periode bleibt.
* Der Vermerk erscheint in der Admin-Zahlungsseite und sortiert den Fall
* im Rechnungslauf nach hinten; ein erfolgreicher Lauf löscht ihn.
*
* @param array<string, mixed> $context
*/
private function hold(UserPaymentOption $option, string $reason, array $context): null
{
Log::channel('billing')->warning('MAN-Rechnung übersprungen: '.$reason.'.', $context);
$option->forceFill([
'billing_hold_reason' => $reason,
'billing_hold_at' => now(),
])->save();
return null;
}
/**
* @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);
}
}