*/ 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]; } }