billing/dev: Vertragsende in der MAN-Abrechnung, Rechnungslauf ohne Tageskappe, Qualitätsgates (Audit Block 5+6, Deploy E)
Block 5 — MAN-Abrechnung F06 (E6): Ein explizites Legacy-Vertragsende (grandfathered_until) deckelt jetzt Auswahl und Sperr-Nachprüfung: Beginnt die nächste Periode nach dem Vertragsende, gibt es keine Rechnung mehr. Neuer täglicher Befehl billing:expire-grandfathering (04:15, vor dem Rechnungslauf) setzt abgelaufene Posten auf gekündigt und schickt je Kunde eine Mail (GrandfatheringExpired). Auf der Dev-Kopie trägt kein Posten ein Vertragsende; der Fehler war latent. F16: Nicht abrechenbare Vereinbarungen bekommen einen Sperrvermerk (billing_hold_reason/-at), sichtbar auf der Admin-Zahlungsseite, und stehen im Rechnungslauf hinten an. Der Lauf arbeitet den fälligen Bestand in Seiten ab; --limit deckelt die erstellten Rechnungen (500), nicht die betrachteten Fälle. Vorher blockierten 50 unvollständige Fälle die 51. dauerhaft, und der Durchsatz war auf 50 am Tag begrenzt. Block 6 — Entwicklung F24: Lint-Workflow prüft mit pint --test statt zu formatieren; keine Schreibrechte mehr. F27: composer run dev startet beide Vite-Server (npm run dev:all). F28: docs/README.md als historischer Stand markiert, toter Link auf das Hub-Flux-Archiv korrigiert, aktueller Einstieg dev/README.md. F26: Login-Modal übernimmt den Fokus, hält Tab im Dialog und gibt den Fokus beim Schließen an den Auslöser zurück – ohne neue Abhängigkeit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
f235156de9
commit
375f4dffce
15 changed files with 518 additions and 65 deletions
|
|
@ -9,6 +9,7 @@ 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;
|
||||
|
|
@ -40,9 +41,16 @@ class ManualInvoiceService
|
|||
) {}
|
||||
|
||||
/**
|
||||
* 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): Collection
|
||||
public function duePaymentOptions(?Carbon $asOf = null, int $limit = 50, array $excludeIds = []): Collection
|
||||
{
|
||||
$asOf = $asOf ?? today();
|
||||
|
||||
|
|
@ -53,6 +61,11 @@ class ManualInvoiceService
|
|||
])
|
||||
->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'])
|
||||
|
|
@ -77,6 +90,9 @@ class ManualInvoiceService
|
|||
], 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;
|
||||
}
|
||||
|
|
@ -86,26 +102,20 @@ class ManualInvoiceService
|
|||
$interval = $this->billingInterval($lockedOption);
|
||||
|
||||
if (! $user) {
|
||||
Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $lockedOption->id]);
|
||||
|
||||
return null;
|
||||
return $this->hold($lockedOption, 'Kein Benutzer', ['user_payment_option_id' => $lockedOption->id]);
|
||||
}
|
||||
|
||||
if (! $interval) {
|
||||
Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $lockedOption->id]);
|
||||
|
||||
return null;
|
||||
return $this->hold($lockedOption, 'Kein abrechenbares Intervall', ['user_payment_option_id' => $lockedOption->id]);
|
||||
}
|
||||
|
||||
$billingAddress = $user->billingAddress;
|
||||
|
||||
if (! $billingAddress) {
|
||||
Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [
|
||||
return $this->hold($lockedOption, 'Keine Rechnungsadresse', [
|
||||
'user_payment_option_id' => $lockedOption->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$netCents = $this->resolveNetCents($lockedOption);
|
||||
|
|
@ -155,6 +165,8 @@ class ManualInvoiceService
|
|||
|
||||
$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()
|
||||
|
|
@ -165,6 +177,25 @@ class ManualInvoiceService
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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::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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue