$excludeIds bereits in diesem Lauf behandelte IDs * @return Collection */ 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 $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); } }