presseportale/app/Services/Billing/ManualInvoiceService.php
Kevin Adametz d548f4b235 Phase 9D: Tarif-Datenmodell, Cashier und hybride Rechnungskreise STR-/MAN-
Tarif-Datenmodell (Decision-Update):
- plans: Starter/Business/Pro/Agency mit Monats-/Jahrespreis (Jahres =
  10 x Monat), PM-Kontingent, Tageslimit, Stripe-IDs; idempotenter Seeder
- single_purchases: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit
  Status-Lifecycle und Stripe-Checkout-Referenzen
- laravel/cashier ^16.5 installiert (freigegeben); User ist Billable,
  Cashier-Migrationen published + ausgefuehrt; lokale invoices()-Relation
  ueberschreibt bewusst die Cashier-Methode

Hybride Rechnungskreise (Entscheidung 12.06.2026):
- invoice_number_sequences + InvoiceNumberGenerator: atomare fortlaufende
  Nummern pro Kreis (STR- fuer den neuen Stripe-Shop, MAN- fuer den
  manuellen Legacy-Kreis); Alt-Archiv legacy_invoices bleibt unveraendert
- ManualInvoiceService + billing:generate-manual-invoices (Scheduler
  taeglich 04:30): prueft aktive/grandfathered user_payment_options ohne
  Stripe-Subscription auf erreichtes Periodenende, friert die
  Rechnungsadresse als Snapshot ein, stellt die MAN-Rechnung aus
  (Zahlungsziel billing.manual_due_days) und schaltet die Periode weiter;
  Konditions-Overrides via legacy_conditions, sonst Netto-Preis +
  billing.vat_rate; nicht abrechenbare Faelle werden geloggt und
  beim naechsten Lauf erneut geprueft

Submit-Gate:
- User::hasActiveBooking() prueft jetzt echt (hinter
  billing.enforce_booking): Cashier-Abo, bezahlter Einzel-/Extra-PM-Kauf
  oder laufende Legacy-Vereinbarung (MAN-Kreis)

Suite: 468 passed, 4 skipped (17 neue Billing-Tests). Pint clean.
Offen fuer 9E: Stripe-Checkout/Webhooks, STR-Spiegelung, Slot-Logik auf
Plan-Kontingent, Migration der aktiven Legacy-Zahlungen in
user_payment_options.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:15:46 +00:00

156 lines
5.8 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\UserPaymentOption;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
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.
*
* Konditions-Overrides pro Vereinbarung über `legacy_conditions` (JSON):
* `amount_cents`, `tax_cents`, `total_cents`, `interval` (monthly|yearly).
* Ohne Override gilt der Netto-Preis der `payment_option` plus
* `billing.vat_rate`.
*/
class ManualInvoiceService
{
public function __construct(private readonly InvoiceNumberGenerator $numbers) {}
/**
* @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();
$user = $option->user;
$interval = $this->billingInterval($option);
if (! $user) {
Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $option->id]);
return null;
}
if (! $interval) {
Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $option->id]);
return null;
}
$billingAddress = $user->billingAddress;
if (! $billingAddress) {
Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [
'user_payment_option_id' => $option->id,
'user_id' => $user->id,
]);
return null;
}
[$amountCents, $taxCents, $totalCents] = $this->resolveAmounts($option);
return DB::transaction(function () use ($option, $user, $billingAddress, $amountCents, $taxCents, $totalCents, $interval, $asOf): Invoice {
// Adresse pro Rechnung einfrieren (Snapshot-Tabelle).
$addressSnapshot = InvoiceBillingAddress::query()->create([
'salutation_key' => $billingAddress->salutation_key,
'title' => $billingAddress->title,
'name' => $billingAddress->name,
'address1' => $billingAddress->address1,
'address2' => $billingAddress->address2,
'postal_code' => $billingAddress->postal_code,
'city' => $billingAddress->city,
'country_code' => $billingAddress->country_code,
]);
$invoice = Invoice::query()->create([
'user_id' => $user->id,
'invoice_billing_address_id' => $addressSnapshot->id,
'number' => $this->numbers->nextManualNumber(),
'status' => InvoiceStatus::Open->value,
'amount_cents' => $amountCents,
'tax_cents' => $taxCents,
'total_cents' => $totalCents,
'currency' => $option->paymentOption?->currency ?? 'EUR',
'invoice_date' => $asOf,
'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)),
]);
$option->update([
'current_period_start' => $option->current_period_end,
'current_period_end' => $interval === 'yearly'
? $option->current_period_end->copy()->addYear()
: $option->current_period_end->copy()->addMonth(),
]);
return $invoice;
});
}
private function billingInterval(UserPaymentOption $option): ?string
{
$interval = $option->legacy_conditions['interval']
?? $option->paymentOption?->interval;
return in_array($interval, ['monthly', 'yearly'], true) ? $interval : null;
}
/**
* @return array{0: int, 1: int, 2: int} [amount_cents, tax_cents, total_cents]
*/
private function resolveAmounts(UserPaymentOption $option): array
{
$conditions = $option->legacy_conditions ?? [];
if (isset($conditions['amount_cents'], $conditions['total_cents'])) {
$amount = (int) $conditions['amount_cents'];
$total = (int) $conditions['total_cents'];
return [$amount, (int) ($conditions['tax_cents'] ?? $total - $amount), $total];
}
$amount = (int) ($conditions['amount_cents'] ?? $option->paymentOption?->price_cents ?? 0);
$tax = (int) round($amount * (float) config('billing.vat_rate', 0.19));
return [$amount, $tax, $amount + $tax];
}
}