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
99
app/Console/Commands/ExpireGrandfathering.php
Normal file
99
app/Console/Commands/ExpireGrandfathering.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Mail\GrandfatheringExpired;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Beendet Bestandsschutz-Posten, deren explizites Legacy-Vertragsende
|
||||
* (`grandfathered_until`, aus valid_until_date importiert) überschritten ist
|
||||
* (Audit F06, Plan 5.2).
|
||||
*
|
||||
* Bisher endete ein solcher Vertrag nur im Zugriff (scopeGrandfatheredNow),
|
||||
* nicht im Datensatz: Status blieb `grandfathered`, der MAN-Rechnungslauf
|
||||
* hätte weiter abgerechnet, und niemand sagte dem Kunden Bescheid. Jetzt
|
||||
* läuft der Befehl täglich vor dem Rechnungslauf, setzt die Posten auf
|
||||
* `cancelled` und schickt je Kunde genau eine Mail. Idempotent: bereits
|
||||
* gekündigte Posten werden nicht mehr angefasst.
|
||||
*/
|
||||
class ExpireGrandfathering extends Command
|
||||
{
|
||||
protected $signature = 'billing:expire-grandfathering
|
||||
{--dry-run : Nur anzeigen, welche Posten beendet würden}';
|
||||
|
||||
protected $description = 'Beendet Bestandsschutz-Posten mit überschrittenem Legacy-Vertragsende und informiert den Kunden.';
|
||||
|
||||
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
|
||||
{
|
||||
$expired = UserPaymentOption::query()
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereNull('cancelled_at')
|
||||
->whereNotNull('grandfathered_until')
|
||||
->whereDate('grandfathered_until', '<', today())
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
if ($expired->isEmpty()) {
|
||||
$this->info('Kein Bestandsschutz mit überschrittenem Vertragsende.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
foreach ($expired as $option) {
|
||||
$this->line(sprintf('[dry-run] Posten #%d (User #%s) endete am %s', $option->id, $option->user_id, $option->grandfathered_until?->toDateString()));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$ended = 0;
|
||||
|
||||
foreach ($expired->groupBy('user_id') as $userId => $options) {
|
||||
/** @var User|null $user */
|
||||
$user = $options->first()?->user;
|
||||
|
||||
DB::transaction(function () use ($options, &$ended): void {
|
||||
$ended += UserPaymentOption::query()
|
||||
->whereIn('id', $options->pluck('id')->all())
|
||||
->whereNull('cancelled_at')
|
||||
->update([
|
||||
'status' => UserPaymentOptionStatus::Cancelled->value,
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
if ($user === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$latestEnd = $options->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::SubscriptionChange,
|
||||
sprintf('Bestandsschutz zum Vertragsende %s beendet (%d Posten).', $latestEnd?->format('d.m.Y'), $options->count()),
|
||||
$user,
|
||||
null,
|
||||
['action' => 'expire_grandfathering', 'posts' => $options->count(), 'until' => $latestEnd?->toDateString()],
|
||||
'system',
|
||||
);
|
||||
|
||||
if (! $suppressions->isSuppressed($user->email)) {
|
||||
Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd));
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf('%d Bestandsschutz-Posten beendet.', $ended));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,14 +11,22 @@ use Illuminate\Console\Command;
|
|||
* Läuft täglich über den Scheduler und stellt für aktive Legacy-
|
||||
* Zahlungsvereinbarungen (ohne Stripe-Subscription) die fälligen
|
||||
* Rechnungen aus — wie im Legacy-System. Nicht abrechenbare
|
||||
* Vereinbarungen (fehlende Rechnungsadresse o. ä.) werden geloggt und
|
||||
* beim nächsten Lauf erneut geprüft.
|
||||
* Vereinbarungen (fehlende Rechnungsadresse o. ä.) werden geloggt, mit
|
||||
* Sperrvermerk versehen und beim nächsten Lauf erneut geprüft.
|
||||
*
|
||||
* Der Lauf arbeitet den gesamten fälligen Bestand in Seiten ab (Audit
|
||||
* F16): Vorher nahm er die 50 ältesten Fälligkeiten; standen dort 50
|
||||
* nicht abrechenbare Fälle, kam die 51. nie dran, und ohne Blocker war
|
||||
* der Durchsatz auf 50 Rechnungen am Tag begrenzt. `--limit` deckelt jetzt
|
||||
* die Zahl der erstellten Rechnungen, nicht der betrachteten Fälle.
|
||||
*/
|
||||
class GenerateManualInvoices extends Command
|
||||
{
|
||||
private const PAGE_SIZE = 100;
|
||||
|
||||
protected $signature = 'billing:generate-manual-invoices
|
||||
{--dry-run : Nur anzeigen, was fällig ist}
|
||||
{--limit=50 : Maximale Anzahl Vereinbarungen pro Lauf}';
|
||||
{--limit=500 : Maximale Anzahl erstellter Rechnungen pro Lauf}';
|
||||
|
||||
protected $description = 'Stellt fällige Rechnungen des manuellen Legacy-Rechnungskreises (MAN-) aus';
|
||||
|
||||
|
|
@ -27,40 +35,54 @@ class GenerateManualInvoices extends Command
|
|||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
$due = $service->duePaymentOptions(limit: $limit);
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$seen = [];
|
||||
|
||||
if ($due->isEmpty()) {
|
||||
while ($created < $limit) {
|
||||
$due = $service->duePaymentOptions(limit: self::PAGE_SIZE, excludeIds: $seen);
|
||||
|
||||
if ($due->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($due as $option) {
|
||||
$seen[] = $option->id;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
'[dry-run] Fällig: Vereinbarung #%d (User #%s, Periode bis %s)%s',
|
||||
$option->id,
|
||||
$option->user_id,
|
||||
$option->current_period_end->toDateString(),
|
||||
$option->billing_hold_reason ? ' – gesperrt: '.$option->billing_hold_reason : '',
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($created >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$invoice = $service->invoiceFor($option);
|
||||
|
||||
if ($invoice) {
|
||||
$created++;
|
||||
$this->line(sprintf('Rechnung %s für Vereinbarung #%d erstellt.', $invoice->number, $option->id));
|
||||
} else {
|
||||
$skipped++;
|
||||
$this->warn(sprintf('Vereinbarung #%d übersprungen: %s.', $option->id, $option->refresh()->billing_hold_reason ?? 'nicht abrechenbar'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($seen === []) {
|
||||
$this->info('Keine fälligen Zahlungsvereinbarungen gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($due as $option) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
'[dry-run] Fällig: Vereinbarung #%d (User #%s, Periode bis %s)',
|
||||
$option->id,
|
||||
$option->user_id,
|
||||
$option->current_period_end->toDateString(),
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoice = $service->invoiceFor($option);
|
||||
|
||||
if ($invoice) {
|
||||
$created++;
|
||||
$this->line(sprintf('Rechnung %s für Vereinbarung #%d erstellt.', $invoice->number, $option->id));
|
||||
} else {
|
||||
$skipped++;
|
||||
$this->warn(sprintf('Vereinbarung #%d übersprungen (siehe Log).', $option->id));
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info(sprintf('%d Rechnung(en) erstellt, %d übersprungen.', $created, $skipped));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue