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:
Kevin Adametz 2026-09-18 10:41:04 +00:00
parent 72432fdb82
commit 9a88e82baa
15 changed files with 518 additions and 65 deletions

View 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;
}
}

View file

@ -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));
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
/**
* Information an den Kunden, dass sein Bestandsschutz zum vertraglich
* vereinbarten Ende ausgelaufen ist (Audit F06) — keine Mahnung, keine
* Stornierung, nur der Hinweis, wie es weitergeht.
*
* Transaktionsmail: bewusst ohne Opt-out.
*/
class GrandfatheringExpired extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public string $bookingUrl;
public function __construct(
public readonly User $user,
public readonly ?Carbon $endedOn,
) {
$this->bookingUrl = route('me.bookings.index');
}
public function envelope(): Envelope
{
$bcc = (string) config('legal.contact_email');
return new Envelope(
subject: 'Ihr Bestandsschutz auf pressekonto ist zum Vertragsende ausgelaufen',
bcc: $bcc !== '' ? [$bcc] : [],
);
}
public function content(): Content
{
return new Content(view: 'emails.billing.grandfathering-expired');
}
}

View file

@ -22,6 +22,8 @@ class UserPaymentOption extends Model
'legacy_conditions',
'current_period_start',
'current_period_end',
'billing_hold_reason',
'billing_hold_at',
'stripe_subscription_id',
'cancelled_at',
];
@ -34,6 +36,7 @@ class UserPaymentOption extends Model
'legacy_conditions' => 'array',
'current_period_start' => 'date',
'current_period_end' => 'date',
'billing_hold_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}

View file

@ -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
*/