*/ 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; } $netCents = $this->resolveNetCents($option); // USt zur Rechnungsstellung bestimmen: DE immer mit Steuer, EU nur // mit gültiger USt-ID befreit (Reverse Charge), Drittland befreit. $treatment = $this->vat->resolve($billingAddress->country_code, $billingAddress->vat_id); $taxCents = $this->vat->taxCentsFor($netCents, $treatment); return DB::transaction(function () use ($option, $user, $billingAddress, $netCents, $taxCents, $treatment, $interval, $asOf): Invoice { // 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, ]); $invoice = Invoice::query()->create([ 'user_id' => $user->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' => $option->paymentOption?->currency ?? 'EUR', 'is_netto' => $treatment->isTaxExempt(), 'tax_note' => $treatment->taxNote(), '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; } /** * 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); } }