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
17
.github/workflows/lint.yml
vendored
17
.github/workflows/lint.yml
vendored
|
|
@ -11,7 +11,7 @@ on:
|
|||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
|
|
@ -33,14 +33,7 @@ jobs:
|
|||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
npm install
|
||||
|
||||
- name: Run Pint
|
||||
run: vendor/bin/pint
|
||||
|
||||
# - name: Commit Changes
|
||||
# uses: stefanzweifel/git-auto-commit-action@v5
|
||||
# with:
|
||||
# commit_message: fix code style
|
||||
# commit_options: '--no-verify'
|
||||
# file_pattern: |
|
||||
# **/*
|
||||
# !.github/workflows/*
|
||||
# Prüfen, nicht formatieren (Audit F24): vorher lief Pint im Schreibmodus
|
||||
# und der Job war grün, obwohl der Commit selbst unformatiert blieb.
|
||||
- name: Check code style
|
||||
run: vendor/bin/pint --test
|
||||
|
|
|
|||
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));
|
||||
}
|
||||
|
|
|
|||
48
app/Mail/GrandfatheringExpired.php
Normal file
48
app/Mail/GrandfatheringExpired.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@
|
|||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,classification\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,classification\" \"php artisan pail --timeout=0\" \"npm run dev:all\" --names=server,queue,logs,vite"
|
||||
],
|
||||
"analyse": [
|
||||
"vendor/bin/phpstan analyse --memory-limit=1G"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Sperrvermerk des MAN-Rechnungslaufs (Audit F16, Plan 5.3).
|
||||
*
|
||||
* Eine fällige Vereinbarung, die nicht abrechenbar ist (keine Rechnungs-
|
||||
* adresse, kein Intervall, kein Benutzer), behielt ihre Fälligkeit und
|
||||
* stand jeden Tag wieder vorn im Fenster. `billing_hold_reason` und
|
||||
* `billing_hold_at` machen den Grund in der Admin-Zahlungsseite sichtbar
|
||||
* und sortieren blockierte Fälle ans Ende; beim nächsten erfolgreichen
|
||||
* Rechnungslauf wird der Vermerk gelöscht. Kleine Tabelle.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_payment_options', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('user_payment_options', 'billing_hold_reason')) {
|
||||
$table->string('billing_hold_reason', 120)->nullable()->after('current_period_end');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('user_payment_options', 'billing_hold_at')) {
|
||||
$table->timestamp('billing_hold_at')->nullable()->after('billing_hold_reason');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_payment_options', function (Blueprint $table) {
|
||||
$table->dropColumn(array_filter([
|
||||
Schema::hasColumn('user_payment_options', 'billing_hold_reason') ? 'billing_hold_reason' : null,
|
||||
Schema::hasColumn('user_payment_options', 'billing_hold_at') ? 'billing_hold_at' : null,
|
||||
]));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
# `docs/` — Konzept- und Status-Dokumente
|
||||
|
||||
Stand: 12.06.2026 — Phase 8, die KI-Prüf-Pipeline (Phasen 0–5) sowie aus
|
||||
Phase 9 die Stripe-Anbindung (9E), die Tarif-Seite (9F), das
|
||||
**Historischer Stand (12.06.2026, vor dem Go-Live am 20.07.2026).** Die
|
||||
Statusangaben in diesem Ordner beschreiben den Planungsstand der Konzeptphase;
|
||||
was seitdem umgesetzt wurde, steht nicht hier. Aktueller Einstieg für
|
||||
Betrieb, Backlog und Deploy: [`dev/README.md`](../dev/README.md) mit
|
||||
[`dev/roadmap.md`](../dev/roadmap.md) und `dev/2026-09-01/`.
|
||||
|
||||
Ursprünglicher Stand 12.06.2026 — Phase 8, die KI-Prüf-Pipeline (Phasen 0–5)
|
||||
sowie aus Phase 9 die Stripe-Anbindung (9E), die Tarif-Seite (9F), das
|
||||
Admin-Zahlungsmodul, die User-Panel-Restarbeiten und das
|
||||
Verlinkungs-Decision-Update sind abgeschlossen. Nächste Blöcke: die zwei
|
||||
neuen Decision-Updates in `weiteres/` (Phase-2-Funktionen & Magic-Link,
|
||||
Verlinkungs-Decision-Update sind abgeschlossen. Damals nächste Blöcke: die
|
||||
zwei Decision-Updates in `weiteres/` (Phase-2-Funktionen & Magic-Link,
|
||||
Duplicate-Content), dazu 9G Tageslimit und der Login-/Registrierungs-Flow.
|
||||
|
||||
Diese README ist der schnellste Einstieg in den `docs/`-Ordner.
|
||||
|
|
@ -19,7 +25,7 @@ Sie verlinkt die zentralen Dokumente und sortiert sie nach „Was ist der aktuel
|
|||
| Wie wird das umgesetzt (aktueller Plan)? | [`PHASE-9-FLOW-UND-TARIFE-PLAN.md`](./PHASE-9-FLOW-UND-TARIFE-PLAN.md) |
|
||||
| Wie funktioniert die KI-Prüfung (Klassifikation, Score, Audit)? | [`user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`](./user-admin/Entwicklungsplan%20KI-Pruefung%20und%20Veroeffentlichung.md) |
|
||||
| Was ist pro Phase erledigt, was offen? | [`user-admin/checkliste-user-backend.md`](./user-admin/checkliste-user-backend.md) |
|
||||
| Welche Hub-Flux-Phasen sind durch? | [`../dev/frontend/hub-flux/PROGRESS.md`](../dev/frontend/hub-flux/PROGRESS.md) |
|
||||
| Welche Hub-Flux-Phasen sind durch? | [`../dev/archive/frontend/hub-flux/PROGRESS.md`](../dev/archive/frontend/hub-flux/PROGRESS.md) (Archiv) |
|
||||
|
||||
## Aufbau
|
||||
|
||||
|
|
@ -74,7 +80,7 @@ In den überarbeiteten Dokumenten finden sich folgende Markierungen:
|
|||
| **IST-Stand JJJJ-MM-TT** | Kompakte Notiz oben am Abschnitt, was im Code tatsächlich umgesetzt ist. |
|
||||
| Phase 1 / Phase 7 / Phase 8 | Abgeschlossene Roadmap-Blöcke: Grund-User-Backend, PM-Form-Refactor, User-Panel-Konsolidierung. |
|
||||
| KI-Pipeline (Phasen 0–5) | Klassifikation, Routing, Content-Score — abgeschlossen 11.06.2026, siehe Entwicklungsplan. |
|
||||
| Hub-Flux | Visuelle Migrationsphase des User Backends, gepflegt in `dev/frontend/hub-flux/`. |
|
||||
| Hub-Flux | Visuelle Migrationsphase des User Backends, gepflegt in `dev/archive/frontend/hub-flux/` (Archiv). |
|
||||
| Phase 2 / Phase 3 | Später — Magic-Link-Flow, Re-Check/Redigieren, Trust-Score, Notice-and-Action, Statistik. |
|
||||
| Launch-Block (offen) | Zahlung/Tarife, Submit-Gate, Slot-Verbrauch bei Veröffentlichung — siehe Decision-Update. |
|
||||
|
||||
|
|
@ -85,6 +91,6 @@ In den überarbeiteten Dokumenten finden sich folgende Markierungen:
|
|||
- Jeder Phasen-Abschluss aktualisiert
|
||||
- `user-admin/checkliste-user-backend.md` (Erledigt-Block),
|
||||
- `STATUS-ABGLEICH-USER-PANEL.md` (Abgleich),
|
||||
- `dev/frontend/hub-flux/PROGRESS.md` (Tagebuch),
|
||||
- und ggf. die Detail-Doku in `dev/frontend/hub-flux/`.
|
||||
- `dev/archive/frontend/hub-flux/PROGRESS.md` (Tagebuch, Archiv),
|
||||
- und ggf. die Detail-Doku in `dev/archive/frontend/hub-flux/`.
|
||||
- Neue große Themen bekommen ein eigenes Plan-Dokument auf `docs/`-Top-Level (z. B. `PHASE-8-USER-PANEL-PLAN.md`).
|
||||
|
|
|
|||
|
|
@ -100,7 +100,35 @@
|
|||
|
||||
{{-- Header (Logo + Suche mittig + Buttons rechtsbündig + Burger) --}}
|
||||
<div id="header" class="header-normal bg-bg border-b border-bg-rule"
|
||||
x-data="{ searchOpen: false, loginOpen: false }"
|
||||
x-data="{
|
||||
searchOpen: false,
|
||||
loginOpen: false,
|
||||
loginTrigger: null,
|
||||
// Fokusführung des eigenen Dialogs (Audit F26): Fokus beim Öffnen
|
||||
// ins Modal, Tab bleibt darin, beim Schließen zurück zum Auslöser.
|
||||
loginFocusables() {
|
||||
return Array.from(this.$refs.loginDialog?.querySelectorAll('a[href], button:not([disabled]), input, [tabindex]:not([tabindex=\'-1\'])') ?? [])
|
||||
.filter(el => el.offsetParent !== null);
|
||||
},
|
||||
trapLoginTab(event) {
|
||||
const items = this.loginFocusables();
|
||||
if (items.length === 0) { return; }
|
||||
const first = items[0], last = items[items.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (! event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
},
|
||||
init() {
|
||||
this.$watch('loginOpen', open => {
|
||||
if (open) {
|
||||
this.loginTrigger = document.activeElement;
|
||||
this.$nextTick(() => (this.$refs.loginPrimary ?? this.loginFocusables()[0])?.focus());
|
||||
} else if (this.loginTrigger instanceof HTMLElement) {
|
||||
this.loginTrigger.focus();
|
||||
this.loginTrigger = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}"
|
||||
@keydown.escape.window="searchOpen = false; loginOpen = false"
|
||||
@keydown.window.prevent.cmd.k="$refs.desktopSearch ? $refs.desktopSearch.focus() : searchOpen = true"
|
||||
@keydown.window.prevent.ctrl.k="$refs.desktopSearch ? $refs.desktopSearch.focus() : searchOpen = true">
|
||||
|
|
@ -191,11 +219,13 @@
|
|||
x-transition.opacity.duration.150ms
|
||||
class="fixed inset-0 z-50 bg-ink/70 backdrop-blur-sm flex items-start sm:items-center justify-center p-4 sm:p-6 overflow-y-auto"
|
||||
@click.self="loginOpen = false"
|
||||
@keydown.tab="trapLoginTab($event)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="login-modal-title"
|
||||
style="display: none;">
|
||||
<div x-show="loginOpen"
|
||||
x-ref="loginDialog"
|
||||
x-transition.duration.200ms
|
||||
class="relative w-full max-w-[520px] bg-bg border border-bg-rule-strong shadow-2xl mt-10 sm:mt-0">
|
||||
<button type="button"
|
||||
|
|
@ -218,6 +248,7 @@
|
|||
|
||||
<div class="flex flex-col sm:flex-row gap-2.5">
|
||||
<a href="{{ $portalUrl }}/login"
|
||||
x-ref="loginPrimary"
|
||||
class="inline-flex items-center justify-center gap-2 px-[18px] py-2.5 text-[13px] font-semibold text-white bg-brand cursor-pointer hover:bg-brand-deep rounded-[2px] transition-colors">
|
||||
{{ __('web.header.login_modal_login') }}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<x-emails.layout title="Ihr Bestandsschutz ist ausgelaufen" preheader="Ihr Bestandsschutz auf pressekonto ist zum vereinbarten Vertragsende ausgelaufen.">
|
||||
<p>Guten Tag {{ $user->name }},</p>
|
||||
|
||||
<p>
|
||||
Ihr Bestandsschutz aus dem früheren Vertrag ist zum vereinbarten Ende
|
||||
@if ($endedOn)
|
||||
am <strong>{{ $endedOn->format('d.m.Y') }}</strong>
|
||||
@endif
|
||||
ausgelaufen. Es wird keine weitere Rechnung mehr gestellt.
|
||||
</p>
|
||||
|
||||
<dl class="meta">
|
||||
<dt>Bestandsschutz</dt>
|
||||
<dd>beendet{{ $endedOn ? ' zum '.$endedOn->format('d.m.Y') : '' }}</dd>
|
||||
</dl>
|
||||
|
||||
<p>
|
||||
Ihre bereits veröffentlichten Pressemitteilungen bleiben unverändert online.
|
||||
Für neue Veröffentlichungen stehen Ihnen die aktuellen Tarife und der
|
||||
Einzelversand auf pressekonto zur Verfügung — jederzeit direkt in Ihrem Konto buchbar.
|
||||
</p>
|
||||
|
||||
<x-emails.button :url="$bookingUrl">Tarife ansehen</x-emails.button>
|
||||
|
||||
<div class="note">
|
||||
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
@ -475,10 +475,13 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
|
|||
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $blocked->current_period_end?->diffForHumans() }}</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if (! $blocked->user?->billingAddress)
|
||||
@if ($blocked->billing_hold_reason)
|
||||
<span class="badge warn dot">{{ $blocked->billing_hold_reason }}</span>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('seit :date', ['date' => $blocked->billing_hold_at?->format('d.m.Y')]) }}</div>
|
||||
@elseif (! $blocked->user?->billingAddress)
|
||||
<span class="badge warn dot">{{ __('Keine Rechnungsadresse') }}</span>
|
||||
@else
|
||||
<span class="badge">{{ __('Siehe Log') }}</span>
|
||||
<span class="badge">{{ __('Noch nicht geprüft') }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Console\Commands\AggregatePressReleaseViews;
|
||||
use App\Console\Commands\CheckIndexStatus;
|
||||
use App\Console\Commands\CheckMailDomains;
|
||||
use App\Console\Commands\ExpireGrandfathering;
|
||||
use App\Console\Commands\FetchMailBounces;
|
||||
use App\Console\Commands\GenerateManualInvoices;
|
||||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
|
|
@ -134,6 +135,15 @@ Schedule::command(PurgeConversionEvents::class)
|
|||
// Manueller Rechnungskreis (MAN-) — Legacy-Zahlungen
|
||||
// ========================================
|
||||
|
||||
// Bestandsschutz mit überschrittenem Legacy-Vertragsende beenden (Audit
|
||||
// F06) — vor dem Rechnungslauf, damit kein beendeter Vertrag mehr
|
||||
// abgerechnet wird.
|
||||
Schedule::command(ExpireGrandfathering::class)
|
||||
->dailyAt('04:15')
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->onFailure($alertOnFailure('billing:expire-grandfathering'));
|
||||
|
||||
// Fällige Rechnungen für laufende Legacy-Zahlungsvereinbarungen ausstellen
|
||||
// (Periodenende erreicht), wie im Altsystem. Neue Abschlüsse laufen über
|
||||
// Stripe (STR-Kreis) und werden hier nicht angefasst.
|
||||
|
|
|
|||
61
tests/Feature/Billing/ExpireGrandfatheringTest.php
Normal file
61
tests/Feature/Billing/ExpireGrandfatheringTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\ExpireGrandfathering;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Mail\GrandfatheringExpired;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\PaymentOption;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Explizites Legacy-Vertragsende (Audit F06, Plan 5.2): Bestandsschutz mit
|
||||
* überschrittenem `grandfathered_until` wird beendet und der Kunde
|
||||
* informiert – bisher passierte das nur manuell über die Mahnung.
|
||||
*/
|
||||
function grandfatheredAgreement(?string $until = null, array $overrides = []): UserPaymentOption
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
BillingAddress::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
return UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'payment_option_id' => PaymentOption::factory()->create()->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'stripe_subscription_id' => null,
|
||||
'grandfathered_until' => $until,
|
||||
'cancelled_at' => null,
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
test('grandfathering past its contract end is ended and the customer is told once', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
Carbon::setTestNow('2026-09-18 04:15:00');
|
||||
|
||||
$expired = grandfatheredAgreement('2026-09-17');
|
||||
$endsToday = grandfatheredAgreement('2026-09-18');
|
||||
$open = grandfatheredAgreement(null);
|
||||
$alreadyCancelled = grandfatheredAgreement('2026-09-01', ['status' => UserPaymentOptionStatus::Cancelled->value, 'cancelled_at' => now()->subDay()]);
|
||||
|
||||
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
|
||||
|
||||
expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled)
|
||||
->and($expired->fresh()->cancelled_at)->not->toBeNull()
|
||||
->and($endsToday->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered)
|
||||
->and($open->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
|
||||
|
||||
Mail::assertQueued(GrandfatheringExpired::class, fn (GrandfatheringExpired $mail) => $mail->user->is($expired->user));
|
||||
Mail::assertQueuedCount(1);
|
||||
|
||||
// Wiederholung ist wirkungslos.
|
||||
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
|
||||
Mail::assertQueuedCount(1);
|
||||
expect($alreadyCancelled->fresh()->cancelled_at->toDateString())->toBe('2026-09-17');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
|
@ -189,3 +189,80 @@ test('the command dry-run does not create invoices', function () {
|
|||
|
||||
expect(Invoice::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('no MAN invoice is issued for a period that starts after the legacy contract end (Audit F06, E6)', function () {
|
||||
Carbon::setTestNow('2026-09-18 08:00:00');
|
||||
$service = app(ManualInvoiceService::class);
|
||||
|
||||
// Vorgestern beendet: die nächste Periode (ab 17.09.) läge nach dem Vertragsende.
|
||||
$ended = manualAgreement([
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'current_period_end' => '2026-09-17',
|
||||
'grandfathered_until' => '2026-09-16',
|
||||
]);
|
||||
// Heute endend, Periode beginnt heute: noch abrechenbar.
|
||||
$endsToday = manualAgreement([
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'current_period_end' => '2026-09-18',
|
||||
'grandfathered_until' => '2026-09-18',
|
||||
]);
|
||||
// Vertragsende vor Periodenbeginn: nicht mehr abrechenbar.
|
||||
$expired = manualAgreement([
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'current_period_end' => '2026-09-10',
|
||||
'grandfathered_until' => '2026-09-01',
|
||||
]);
|
||||
// Unbefristet: wie bisher.
|
||||
$open = manualAgreement([
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'current_period_end' => '2026-09-10',
|
||||
'grandfathered_until' => null,
|
||||
]);
|
||||
|
||||
expect($service->duePaymentOptions()->pluck('id')->all())->toBe([$open->id, $endsToday->id]);
|
||||
|
||||
// Der direkte Aufruf beachtet dieselbe Grenze unter der Sperre.
|
||||
expect($service->invoiceFor($expired))->toBeNull()
|
||||
->and($service->invoiceFor($ended))->toBeNull()
|
||||
->and($service->invoiceFor($endsToday))->not->toBeNull()
|
||||
->and($service->invoiceFor($open))->not->toBeNull()
|
||||
->and($expired->fresh()->current_period_end->toDateString())->toBe('2026-09-10');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('a skipped agreement is marked with a hold reason and cleared once invoiced (Audit F16)', function () {
|
||||
$agreement = manualAgreement();
|
||||
$agreement->user->billingAddress->delete();
|
||||
|
||||
expect(app(ManualInvoiceService::class)->invoiceFor($agreement->fresh()))->toBeNull();
|
||||
|
||||
$fresh = $agreement->fresh();
|
||||
expect($fresh->billing_hold_reason)->toBe('Keine Rechnungsadresse')
|
||||
->and($fresh->billing_hold_at)->not->toBeNull();
|
||||
|
||||
BillingAddress::factory()->create(['user_id' => $agreement->user_id]);
|
||||
|
||||
expect(app(ManualInvoiceService::class)->invoiceFor($agreement->fresh()))->not->toBeNull();
|
||||
expect($agreement->fresh()->billing_hold_reason)->toBeNull();
|
||||
});
|
||||
|
||||
test('the daily run works through every due agreement instead of the fifty oldest (Audit F16)', function () {
|
||||
/** @var TestCase $this */
|
||||
// 50 blockierte Vereinbarungen sind älter als die eine abrechenbare.
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$blocked = manualAgreement(['current_period_end' => today()->subDays(30 + $i)]);
|
||||
$blocked->user->billingAddress->delete();
|
||||
}
|
||||
$billable = manualAgreement(['current_period_end' => today()->subDay()]);
|
||||
|
||||
$this->artisan(GenerateManualInvoices::class, ['--limit' => 50])->assertSuccessful();
|
||||
|
||||
expect(Invoice::count())->toBe(1)
|
||||
->and(Invoice::sole()->user_payment_option_id)->toBe($billable->id)
|
||||
->and(UserPaymentOption::whereNotNull('billing_hold_reason')->count())->toBe(50);
|
||||
|
||||
// Zweiter Lauf: die Blockierten stehen jetzt hinten an, nichts Neues.
|
||||
$this->artisan(GenerateManualInvoices::class, ['--limit' => 50])->assertSuccessful();
|
||||
expect(Invoice::count())->toBe(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue