Compare commits

...

26 commits

Author SHA1 Message Date
ad741331ee Doku-Abschluss 12.06.: README-Stand, weiteres/-Index, Next Steps (Phase-2/Magic-Link, Duplicate-Content)
Some checks failed
tests / ci (push) Has been cancelled
linter / quality (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:24:28 +00:00
970b4909fa PM-Vorschau: Firmen-Kachel im Stil der Firmenübersicht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:20:23 +00:00
284d029b29 PM-Vorschau: Firma & Pressekontakt zusammengeführt, Sprache als Badge am Portal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:11:57 +00:00
cc7b3c3379 KI-generierte Bilder: eigener Lizenztyp, Anbieter-Bestätigung, Kennzeichnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:04:12 +00:00
6e0b2b1814 Titelbild: Bildnachweis als Pflichtfeld, Lizenzdetail-Reset bei Typwechsel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:44:52 +00:00
a7c30d4ecc Titelbild-Upload: Bildrechte in 5 Schritten + große Bildvorschau
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:34:40 +00:00
25ea91d85b Verlinkung & Backlinks: systemseitige rel-Auszeichnung (Decision-Update 11.06.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:24:20 +00:00
5a8da0c1f4 PM-Vorschau: Workflow oben + farbig, Metadaten, saubere Inhalts-Typografie
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:16:52 +00:00
b7145512a7 Profil-Feinschliff: Pflicht-Badges, Fokus-Fix, Submit-Modal auf Switches
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:02:11 +00:00
afcca34f91 User-Panel-Restarbeiten: PM-Guard, Profil-Rework, USt-ID-Prüfung, Buchungspflicht-Adresse
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:36:18 +00:00
036a53499f Responsive-Härtung: Seiten-Header, Kontextleiste, Stat-Cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:08:08 +00:00
bda755fcf8 Admin-Zahlungsmodul: Zahlungs-Übersicht + Tarif-Verwaltung mit Stripe-Sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:54:53 +00:00
8f3261d0b4 Checkout: Stripe-Tax-Adressanforderung erfüllen
Stripe Tax verlangt eine gültige Kundenadresse. Beide Checkout-Sessions
erfassen jetzt die Rechnungsadresse verpflichtend und speichern sie am
Stripe-Customer (customer_update address/name = auto; Name ist Pflicht
bei aktivierter USt-ID-Abfrage). Zusätzlich liefert User::stripeAddress()
die lokale Rechnungsadresse bei der Customer-Anlage mit (Cashier-Hook).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:33:58 +00:00
6a82e2a2a8 Buchungs-Seite: Feinschliff nach Review
- Aktueller-Tarif-Card erscheint erst mit vorhandener Buchung; die
  Kontingent-Kachel zeigt nur noch echte Zahlen (kein irreführendes
  "Unbegrenzt" vor dem Launch-Schalter)
- Tarif-Cards plakativer: Icon je Tarif, größerer Preis, Trennlinie vor
  den Leistungen, mehr Abstand zum größeren Buchen-Button
- "Prüfung und Veröffentlichung inklusive" statt "KI-Prüfung"
- "Aktive Buchungen"-Panel entfernt (redundant zum Tarif-Panel);
  Verlauf als eigene, durch Trennlinie abgegrenzte Sektion
- Tests angepasst; Suite 519 passed / 4 skipped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:30:06 +00:00
23ac8bc7f1 Phase 9F: Tarif-Seite mit Stripe-Checkout und Billing Portal
- Buchungs-Seite zeigt das echte 4-Tier-Raster aus plans (Monat/Jahr-
  Toggle, Jahrespreis als "2 Monate gratis") mit Checkout-Buttons,
  Einzel-PM als separaten No-Abo-Block und Enterprise-Hinweis;
  Credit-Konzept-Mock entfernt (Credits folgen mit 9I bzw. Phase 2)
- Aktueller-Tarif-Panel real: Abo (Preis, Kontingent, Kündigungsstatus),
  Bestandstarif (unbegrenzt, nächste MAN-Rechnung), offene Einzelkäufe;
  Kontingent-Kachel zeigt "Unbegrenzt" bei Bestandsschutz
- "Abo verwalten" über das Stripe Billing Portal
  (me.checkout.billing-portal; Zahlungsmethode, Rechnungen, Kündigung)
- Aktive Buchungen + Verlauf aus echten Daten (Abo, Legacy-Vereinbarung,
  offene/eingelöste Einzelkäufe mit PM-Verknüpfung)
- Tests: BookingsPageTest (9 Tests), PanelConsolidationTest angepasst;
  Suite 519 passed / 4 skipped
- Doku: PHASE-9-Plan 9F , Billing-Doku (Routen, Stripe Tax aktiviert),
  STATUS-ABGLEICH, Checkliste, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:39:39 +00:00
c8dc99c3c8 Phase 9E (Abschluss): Checkout-Flows und Plan-Kontingent statt Quota-Stub
- Checkout-Backend: me.checkout.subscription (Tarif-Abo monatlich/jährlich)
  und me.checkout.single-pm (Einzel-PM 19 € netto, pending-Kauf mit
  Webhook-Erfüllung); StripeCheckoutService als mockbarer Stripe-Wrapper;
  Stripe Tax via Cashier::calculateTaxes() (Netto-Preise, USt-ID-Abfrage)
- Slot-Logik: Kontingent aus dem Tarif (plans.press_release_quota) plus
  bezahlte Einmalkäufe; Verbrauch bei Veröffentlichung zuerst aus dem
  Plan-Zähler, danach Einlösung des ältesten Einmalkaufs (consumed +
  PM-Verknüpfung); Grandfathered = unbegrenzt (Entscheidung 12.06.2026,
  Bestandsschutz); Stub-Spalte users.press_release_quota entfernt
- billing:sync-stripe-plans legt zusätzlich das Einzel-PM-Produkt an
  (STRIPE_PRICE_SINGLE_PM); Test-Mode-Sync gelaufen
- Buchungs-Seite: Rückmeldung nach Checkout (erfolg/abbruch/Guard-Hinweis)
- Tests: PressReleaseQuotaTest auf Plan-Semantik neu geschrieben,
  CheckoutFlowTest (8 Tests), Modal-/API-Tests angepasst; Suite 510 passed
- Doku: Billing-und-Rechnungskreise (Kontingent-Tabelle, Checkout-Routen,
  Webhook-Events, Stripe-CLI-Hinweis), PHASE-9-Plan 9E , Checkliste,
  STATUS-ABGLEICH, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:10:32 +00:00
38fab64e10 Phase 9E (Backbone): Stripe-Produkt-Sync und Webhook-Verarbeitung mit STR-Spiegelung
- billing:sync-stripe-plans: legt die Tarife als Netto-Produkte/Preise
  (tax_behavior exclusive, EUR) in Stripe an und pflegt die IDs zurueck
  nach plans; idempotent, --dry-run. Gegen Stripe Test-Mode ausgefuehrt —
  alle 4 Tiers verknuepft.
- ProcessStripeWebhook (Listener auf Cashier WebhookReceived):
  - invoice.payment_succeeded -> Spiegelung in den lokalen STR-Kreis
    (fortlaufende Nummer via InvoiceNumberGenerator, Adress-Snapshot
    bevorzugt aus dem Stripe-Payload inkl. lokaler USt-ID, Status paid,
    idempotent gegen doppelte Zustellung)
  - checkout.session.completed -> markiert den referenzierten
    single_purchases-Datensatz als bezahlt (Metadata single_purchase_id)
- CASHIER_CURRENCY=eur (+ Locale de_DE); Cashier-Webhook-Route aktiv
- Doku: Billing-Referenz §7 + Phase-9-Plan (9E-Backbone) aktualisiert

Offen fuer 9E-Rest: Checkout-Flows (Abo + Einmalkauf), Webhook-Endpoint
im Stripe-Dashboard + STRIPE_WEBHOOK_SECRET, Slot-Logik auf
Plan-Kontingent (fachliche Frage: Grandfathered = unbegrenzt?).

Tests: StripeWebhookProcessingTest (7, inkl. Event-Wiring).
Suite: 497 passed, 4 skipped. Pint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:07:09 +00:00
62e6b7e70f Doku: zentrale Billing-Referenz und Status-Sync Phase 9D
- Neues Dokument docs/user-admin/Billing-und-Rechnungskreise.md:
  hybride Rechnungskreise (STR-/MAN-/Archiv), Tarif-Datenmodell,
  MAN-Faelligkeitslauf, USt-Regeln, Befehle/Scheduler, Konfiguration
  (billing.php + Cashier-ENV) und offene Punkte
- README-Index + STATUS-ABGLEICH (Finanzen-Sektion) aktualisiert
- PROGRESS-Eintrag Phase 9D (Datenmodell, Rechnungskreise,
  Grandfather-Migration, USt)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:02:45 +00:00
894a9436b0 USt-Behandlung: Netto-Preise, VatResolver und Steuer-Ausweis im MAN-Kreis
Einwand/Entscheidung 12.06.2026: Legacy fakturierte brutto (Steuer
inkludiert, z. B. 199 Euro; steuerbefreite Kunden mit Netto-Ausweis
167,23). Alle neuen Preise sind netto; die Steuer wird zur
Rechnungsstellung sauber validiert und ausgewiesen.

- VatResolver + VatTreatment: DE grundsaetzlich immer mit Steuer, EU nur
  mit (formal plausibler) USt-ID befreit (Reverse Charge inkl.
  Pflichthinweis), Drittlaender grundsaetzlich befreit;
  EU-Laenderliste + vat_rate in config/billing.php
- Schema: billing_addresses.vat_id + invoice_billing_addresses.vat_id
  (Snapshot pro Rechnung), invoices.tax_note; Profil-Formular schreibt
  die vorhandene USt-ID jetzt auch an die Rechnungsadresse
- ManualInvoiceService: rechnet auf Netto-Vertragsbasis
  (legacy_conditions.net_cents bzw. Netto-Katalogpreis) und bestimmt
  Steuer/is_netto/tax_note pro Rechnung ueber den VatResolver
- legacy:grandfather-subscriptions: leitet net_cents aus der letzten
  Legacy-Rechnung ab (brutto / 1,19 bzw. is_netto-Betrag direkt);
  fuer DE-Bestandskunden bleibt der Bruttobetrag unveraendert
  (199 brutto -> 167,23 netto + 31,77 USt = 199,00)
- Doku: Decision-Update 2.1 (Netto-Klarstellung), Phase-9-Plan,
  Checkliste, 05-DATABASE-MERGE 5.6; offen: VIES-Validierung der USt-ID

Tests: VatResolverTest (Datasets fuer alle Faelle), Reverse-Charge/
EU-/Drittland-Rechnungen, Netto-Ableitung; Suite 490 passed, 4 skipped.
Pint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:58:43 +00:00
1cd4d8e33a P6.6: legacy:grandfather-subscriptions — aktive Legacy-Abos aus dem Rechnungsarchiv migrieren
Kriterien vom Auftraggeber (12.06.2026): Quelle der Aktiv-Erkennung ist
ausschliesslich das read-only Rechnungsarchiv legacy_invoices (D-12).
Legacy-Rechnungen bleiben Archiv; neue manuelle Rechnungen entstehen im
MAN-Rechnungskreis.

- Aktiv-Regel: juengste Rechnung pro (Portal, Legacy-Vereinbarung) mit
  payment_option.type=recurring und user_payment_option.status=active;
  next_due_date max. --grace-months (Default 12) ueberfaellig, sonst
  stale -> bleibt reines Archiv. Einmal-Kaeufe werden nie uebernommen.
- Uebernahme als grandfathered in user_payment_options:
  current_period_end = next_due_date, Betraege/Intervall der letzten
  Legacy-Rechnung in legacy_conditions -> der taegliche MAN-Lauf
  (billing:generate-manual-invoices) fakturiert zum gewohnten
  jaehrlichen Rhythmus weiter. Versteckte Katalog-Platzhalter
  LEGACY-{PE|BP}-{Artikel} in payment_options.
- Replay-faehig (D-18): Re-Runs aktualisieren anhand der Legacy-IDs in
  legacy_conditions statt zu duplizieren — die Kern-Migration laeuft
  kurz vor dem Relaunch erneut.
- Optionen: --dry-run, --as-of, --grace-months, --no-report; JSON-Report
  nach storage/app/migration/. Dry-Run gegen Test-Snapshot: 22 aktive
  jaehrliche Vereinbarungen, davon 4 sofort faellig, 0 stale.
- Doku: MIGRATION-STEPS.md (Runbook-Reihenfolge nach archive-invoices),
  05-DATABASE-MERGE §5.6, 12-NAECHSTE-SCHRITTE 6.6, 08-PROGRESS,
  PHASE-9-Plan + Checkliste.

Tests: GrandfatherLegacySubscriptionsTest (7, inkl. End-to-End
Migration -> MAN-Rechnung mit Legacy-Betraegen). Suite: 475 passed,
4 skipped. Pint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:35:48 +00:00
d548f4b235 Phase 9D: Tarif-Datenmodell, Cashier und hybride Rechnungskreise STR-/MAN-
Tarif-Datenmodell (Decision-Update):
- plans: Starter/Business/Pro/Agency mit Monats-/Jahrespreis (Jahres =
  10 x Monat), PM-Kontingent, Tageslimit, Stripe-IDs; idempotenter Seeder
- single_purchases: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit
  Status-Lifecycle und Stripe-Checkout-Referenzen
- laravel/cashier ^16.5 installiert (freigegeben); User ist Billable,
  Cashier-Migrationen published + ausgefuehrt; lokale invoices()-Relation
  ueberschreibt bewusst die Cashier-Methode

Hybride Rechnungskreise (Entscheidung 12.06.2026):
- invoice_number_sequences + InvoiceNumberGenerator: atomare fortlaufende
  Nummern pro Kreis (STR- fuer den neuen Stripe-Shop, MAN- fuer den
  manuellen Legacy-Kreis); Alt-Archiv legacy_invoices bleibt unveraendert
- ManualInvoiceService + billing:generate-manual-invoices (Scheduler
  taeglich 04:30): prueft aktive/grandfathered user_payment_options ohne
  Stripe-Subscription auf erreichtes Periodenende, friert die
  Rechnungsadresse als Snapshot ein, stellt die MAN-Rechnung aus
  (Zahlungsziel billing.manual_due_days) und schaltet die Periode weiter;
  Konditions-Overrides via legacy_conditions, sonst Netto-Preis +
  billing.vat_rate; nicht abrechenbare Faelle werden geloggt und
  beim naechsten Lauf erneut geprueft

Submit-Gate:
- User::hasActiveBooking() prueft jetzt echt (hinter
  billing.enforce_booking): Cashier-Abo, bezahlter Einzel-/Extra-PM-Kauf
  oder laufende Legacy-Vereinbarung (MAN-Kreis)

Suite: 468 passed, 4 skipped (17 neue Billing-Tests). Pint clean.
Offen fuer 9E: Stripe-Checkout/Webhooks, STR-Spiegelung, Slot-Logik auf
Plan-Kontingent, Migration der aktiven Legacy-Zahlungen in
user_payment_options.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:15:46 +00:00
4419d9ff43 Phase 9 Block 1: Gelb-Routing Direkt-Live, Slot-Verbrauch bei Veroeffentlichung, Submit-Gate
9A — Gelb geht direkt live (Entscheidung 12.06.2026):
- routeByClassification(): Gelb durchlaeuft denselben Auto-Publish-Pfad
  wie Gruen (autoPublishApproved); nur Rot wird abgelehnt
- Scheduler publiziert faellige gelbe + gruene PMs; unklassifizierte
  bleiben als Fallback in der manuellen Queue

9B — Slot-Verbrauch bei Veroeffentlichung (Decision-Update 3.2):
- Increment aus submitForReview() entfernt; publish() und
  changeStatusFromAdmin() zaehlen idempotent beim ersten
  published-Uebergang (Pruefung ueber Status-Logs); Rot kostet nichts
- Submit-Guard: Einreichen erfordert freien Slot
  (QuotaExceededException, API 422)

9C — Submit-Gate vorbereitet (Decision-Update 5.1):
- User::hasActiveBooking()-Stub hinter config/billing.php
  (enforce_booking, Default aus); Tarif-Modul ersetzt nur den Rumpf
- Einreichungs-Modal zeigt ohne Buchung einen Buchungs-Hinweis;
  Server-Guard (BookingRequiredException), API antwortet 402
- Fix: Customer-Create legte PMs bei "Zur Pruefung senden" direkt mit
  Status review an (vorbei an Blacklist/Quota/KI/Status-Log) — laeuft
  jetzt immer ueber submitForReview()

Suite: 451 passed, 4 skipped (9 neue Tests). Pint clean.
Plan: docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md (Block 2 nach Review-Stopp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:47:06 +00:00
8d8d957884 Doku: Status-Sync 11./12.06., Decision-Update Preisstruktur und Phase-9-Plan
- Decision-Update Preisstruktur & Veroeffentlichungs-Flow aufgenommen
  (Launch-Tarife, Slot-Verbrauch bei Veroeffentlichung, Submit-Gate,
  Launch-Credits) inkl. Klarstellung 12.06.: Gelb geht direkt live,
  keine manuelle Pruef-Queue, nur Rot wird abgelehnt
- Alle Status-Dokumente auf den Code-Stand gezogen: README-Index,
  STATUS-ABGLEICH (KI-Pipeline, Bilder/Lizenzen, Pricing), Checkliste
  (KI- und Titelbild-Bloecke, Launch-To-dos), Admin-User,
  user-zusammenhaenge (Datenmodell-Delta), Entwicklungsplan KI-Pruefung
  (Phase 0 abgehakt, Decision-Abgleich)
- Ueberschriebene Tarif-Abschnitte in Konzept-Update 1/2 und
  Relaunch-Konzept mit Superseded-/IST-Hinweisen markiert
- Neues Plan-Dokument PHASE-9-FLOW-UND-TARIFE-PLAN.md (9A-9J)
- Phase-8-Roadmap-Doku (20-PHASE-8-USER-PANEL.md) + PROGRESS-Eintraege

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:20:22 +00:00
a000238ca8 User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline
Phase 8 (Rest) + Umbauten vom 10./11.06.:
- Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker,
  PressReleaseCoverImage-Resolver
- Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen,
  Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise)
- Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt),
  geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE)
- Quota-Stub (users.press_release_quota) + monatlicher Reset-Command
- Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf
  filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout)

KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans):
- API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route
  durch denselben Funnel (Blacklist, Quota, Status-Log)
- Klassifikation Rot/Gelb/Gruen asynchron (Queue classification,
  OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log
- Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen ->
  Auto-Publish; Scheduler publiziert nur gruene faellige PMs
- Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl.
  Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung
- Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override

Suite: 442 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:30:13 +00:00
0efabaf446 Multi-Domain-Asset-Infrastruktur: geteilte Vite-Konfiguration und DomainAssetContext
- vite.shared.js als gemeinsame Quelle fuer Ports, Hot-Files, HMR-Hosts
  und CORS-Origins der beiden Vite-Builds (Portal/Web)
- App\Support\DomainAssetContext kapselt die Vite-Build-Directory-
  Konfiguration pro Domain (ThemeServiceProvider + Auth-Layout nutzen ihn)
- Tailwind-Portal-Content-Globs auf die tatsaechliche View-Struktur gezogen
- Dev-Beispiel-Routen + Tests (DomainAssetContextTest, DevExampleRoutesTest)
- Aufraeumen: versehentliche Leerdatei dev:web entfernt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:09 +00:00
4bb9094207 29-05-2026 Optimierungen Fixes am Code 2026-05-29 12:42:05 +00:00
274 changed files with 21711 additions and 1789 deletions

View file

@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
@ -9,7 +10,7 @@ trait PasswordValidationRules
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{

View file

@ -30,7 +30,7 @@ class GenerateDomainFavicons extends Command
$faviconDir = public_path('img/favicons');
// Erstelle das Favicon-Verzeichnis, wenn es nicht existiert
if (!File::exists($faviconDir)) {
if (! File::exists($faviconDir)) {
File::makeDirectory($faviconDir, 0755, true);
$this->info("Verzeichnis {$faviconDir} erstellt.");
}
@ -53,8 +53,9 @@ class GenerateDomainFavicons extends Command
$faviconPath = "{$faviconDir}/{$theme}-favicon.ico";
// Wenn die Datei bereits existiert, frage, ob sie überschrieben werden soll
if (File::exists($faviconPath) && !$this->confirm("Favicon für '{$theme}' existiert bereits. Überschreiben?")) {
if (File::exists($faviconPath) && ! $this->confirm("Favicon für '{$theme}' existiert bereits. Überschreiben?")) {
$this->info("Favicon für '{$theme}' übersprungen.");
continue;
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Console\Commands;
use App\Services\Billing\ManualInvoiceService;
use Illuminate\Console\Command;
/**
* Fälligkeitsprüfung des manuellen Rechnungskreises (MAN-).
*
* 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.
*/
class GenerateManualInvoices extends Command
{
protected $signature = 'billing:generate-manual-invoices
{--dry-run : Nur anzeigen, was fällig ist}
{--limit=50 : Maximale Anzahl Vereinbarungen pro Lauf}';
protected $description = 'Stellt fällige Rechnungen des manuellen Legacy-Rechnungskreises (MAN-) aus';
public function handle(ManualInvoiceService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$due = $service->duePaymentOptions(limit: $limit);
if ($due->isEmpty()) {
$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));
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,292 @@
<?php
namespace App\Console\Commands;
use App\Enums\UserPaymentOptionStatus;
use App\Models\LegacyInvoice;
use App\Models\PaymentOption;
use App\Models\UserPaymentOption;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
/**
* Leitet die noch aktiven, jährlich wiederkehrenden Legacy-Zahlungs-
* vereinbarungen aus dem Rechnungsarchiv ab und migriert sie als
* `grandfathered` in `user_payment_options` (D-13, Kriterien 12.06.2026).
*
* Quelle ist ausschließlich das read-only Archiv `legacy_invoices` (D-12):
* Pro (Portal, Legacy-Vereinbarung) zählt die jüngste Rechnung. Aktiv ist,
* was dort `payment_option.type = recurring`, `user_payment_option.status
* = active` und eine nicht zu weit zurückliegende `next_due_date` trägt.
* Alle anderen Rechnungen bleiben unangetastet im Archiv.
*
* Die migrierte Vereinbarung trägt `current_period_end = next_due_date`
* damit stellt `billing:generate-manual-invoices` (MAN-Kreis) die nächste
* Rechnung zum gewohnten Rhythmus mit den Beträgen der letzten
* Legacy-Rechnung aus.
*
* Wiederholbar (D-18): Re-Runs aktualisieren bestehende Einträge anhand
* der Legacy-IDs in `legacy_conditions`, statt Duplikate anzulegen
* die Kern-Migration läuft kurz vor dem Relaunch erneut.
*
* Verwendung:
* php artisan legacy:grandfather-subscriptions --dry-run
* php artisan legacy:grandfather-subscriptions --as-of=2026-06-12
* php artisan legacy:grandfather-subscriptions
*/
class GrandfatherLegacySubscriptions extends Command
{
protected $signature = 'legacy:grandfather-subscriptions
{--dry-run : Nur anzeigen, nichts schreiben}
{--as-of= : Stichtag für die Aktiv-Prüfung (Default: heute)}
{--grace-months=12 : Wie lange eine überfällige next_due_date noch als aktiv gilt}
{--no-report : Keinen JSON-Report schreiben}';
protected $description = 'Migriert aktive wiederkehrende Legacy-Zahlungen aus dem Rechnungsarchiv nach user_payment_options (grandfathered).';
private const CHUNK_SIZE = 200;
public function handle(): int
{
$isDryRun = (bool) $this->option('dry-run');
$asOf = $this->option('as-of') ? Carbon::parse($this->option('as-of'))->startOfDay() : today();
$graceMonths = max(0, (int) $this->option('grace-months'));
$staleBefore = $asOf->copy()->subMonths($graceMonths);
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$candidates = $this->collectLatestRecurringAgreements();
$report = [
'generated_at' => now()->toIso8601String(),
'as_of' => $asOf->toDateString(),
'grace_months' => $graceMonths,
'dry_run' => $isDryRun,
'created' => [],
'updated' => [],
'stale_skipped' => [],
'immediately_due' => [],
];
foreach ($candidates as $candidate) {
$nextDue = $candidate['next_due_date'];
if ($nextDue->lessThan($staleBefore)) {
$report['stale_skipped'][] = $this->describe($candidate);
continue;
}
if ($nextDue->lessThanOrEqualTo($asOf)) {
$report['immediately_due'][] = $this->describe($candidate);
}
if ($isDryRun) {
$this->line(sprintf('[dry-run] %s', $this->describe($candidate)));
continue;
}
$paymentOption = $this->resolveCatalogOption($candidate);
$existing = $this->findExisting($candidate);
$attributes = [
'user_id' => $candidate['user_id'],
'payment_option_id' => $paymentOption->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => $candidate['valid_until_date']?->toDateString(),
'current_period_start' => $candidate['period_start']->toDateString(),
'current_period_end' => $nextDue->toDateString(),
'stripe_subscription_id' => null,
'legacy_conditions' => $candidate['legacy_conditions'],
];
if ($existing) {
$existing->update($attributes);
$report['updated'][] = $this->describe($candidate);
} else {
UserPaymentOption::query()->create($attributes);
$report['created'][] = $this->describe($candidate);
}
}
$this->table(
['Ergebnis', 'Anzahl'],
[
['Kandidaten (aktiv, recurring)', count($candidates)],
['Neu angelegt', count($report['created'])],
['Aktualisiert (Re-Run)', count($report['updated'])],
['Übersprungen (stale)', count($report['stale_skipped'])],
['Davon sofort fällig', count($report['immediately_due'])],
],
);
foreach ($report['immediately_due'] as $line) {
$this->warn('Sofort fällig (MAN-Lauf rechnet beim nächsten Lauf ab): '.$line);
}
if (! $this->option('no-report')) {
$path = sprintf('migration/grandfather-subscriptions-%s.json', now()->format('Ymd-His'));
Storage::put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->info("Report: storage/app/{$path}");
}
return self::SUCCESS;
}
/**
* Jüngste Archiv-Rechnung pro (Portal, Legacy-Vereinbarung) mit
* aktiver wiederkehrender Zahlungsoption.
*
* @return array<int, array<string, mixed>>
*/
private function collectLatestRecurringAgreements(): array
{
$latest = [];
LegacyInvoice::query()
->whereNotNull('user_id')
->whereNotNull('pdf_payload')
->orderBy('id')
->chunk(self::CHUNK_SIZE, function ($invoices) use (&$latest): void {
foreach ($invoices as $invoice) {
$payload = $invoice->pdf_payload;
$upo = $payload['user_payment_option'] ?? null;
$option = $payload['payment_option'] ?? null;
if (! $upo || ! $option) {
continue;
}
if (($option['type'] ?? null) !== 'recurring' || ($upo['status'] ?? null) !== 'active') {
continue;
}
$key = $invoice->legacy_portal->value.'#'.$upo['id'];
$current = $latest[$key] ?? null;
if ($current && $current->invoice_date->greaterThanOrEqualTo($invoice->invoice_date)) {
continue;
}
$latest[$key] = $invoice;
}
});
return array_values(array_map(fn (LegacyInvoice $invoice): array => $this->toCandidate($invoice), $latest));
}
/**
* @return array<string, mixed>
*/
private function toCandidate(LegacyInvoice $invoice): array
{
$payload = $invoice->pdf_payload;
$snapshot = $invoice->raw_snapshot ?? [];
$upo = $payload['user_payment_option'];
$option = $payload['payment_option'];
$nextDue = isset($upo['next_due_date'])
? Carbon::parse($upo['next_due_date'])->startOfDay()
: Carbon::parse($snapshot['service_period_end_date'] ?? $invoice->invoice_date)->startOfDay();
$periodStart = isset($snapshot['service_period_begin_date'])
? Carbon::parse($snapshot['service_period_begin_date'])->startOfDay()
: $nextDue->copy()->subYear();
return [
'user_id' => $invoice->user_id,
'legacy_portal' => $invoice->legacy_portal->value,
'legacy_upo_id' => (int) $upo['id'],
'article_number' => (string) ($option['article_number'] ?? 'UNBEKANNT'),
'next_due_date' => $nextDue,
'period_start' => $periodStart,
'valid_until_date' => isset($upo['valid_until_date']) && $upo['valid_until_date']
? Carbon::parse($upo['valid_until_date'])->startOfDay()
: null,
'legacy_conditions' => [
'legacy_portal' => $invoice->legacy_portal->value,
'legacy_user_payment_option_id' => (int) $upo['id'],
'legacy_payment_option_id' => (int) ($option['id'] ?? 0),
'article_number' => $option['article_number'] ?? null,
'name' => $payload['payment_option_translation']['name'] ?? null,
'interval' => 'yearly',
'net_cents' => $this->deriveNetCents($invoice),
'last_total_cents' => $invoice->total_cents,
'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false),
'source_invoice_number' => $invoice->number,
'source_invoice_date' => $invoice->invoice_date->toDateString(),
],
];
}
/**
* Netto-Vertragsbasis aus der letzten Legacy-Rechnung. Legacy fakturierte
* brutto (Steuer inkludiert, z. B. 199,00 ); steuerbefreite Kunden
* erhielten den Netto-Ausweis (`is_netto`, z. B. 167,23 ). Die neue
* Rechnungsstellung arbeitet immer auf Netto-Basis die Steuer wird
* pro Rechnung über den VatResolver bestimmt.
*/
private function deriveNetCents(LegacyInvoice $invoice): int
{
$isNetto = (bool) (($invoice->raw_snapshot ?? [])['is_netto'] ?? false);
if ($isNetto) {
return $invoice->total_cents;
}
$vatRate = (float) config('billing.vat_rate', 0.19);
return (int) round($invoice->total_cents / (1 + $vatRate));
}
/**
* Versteckter Katalog-Eintrag pro (Portal, Legacy-Artikel) die
* verbindlichen Beträge pro Vereinbarung liegen in legacy_conditions.
*/
private function resolveCatalogOption(array $candidate): PaymentOption
{
$portalShort = $candidate['legacy_portal'] === 'presseecho' ? 'PE' : 'BP';
$articleNumber = sprintf('LEGACY-%s-%s', $portalShort, $candidate['article_number']);
return PaymentOption::query()->firstOrCreate(
['article_number' => $articleNumber],
[
'type' => 'recurring',
// Katalogpreise sind netto (Entscheidung 12.06.2026).
'price_cents' => $candidate['legacy_conditions']['net_cents'],
'currency' => 'EUR',
'interval' => 'yearly',
'is_hidden' => true,
],
);
}
private function findExisting(array $candidate): ?UserPaymentOption
{
return UserPaymentOption::query()
->where('user_id', $candidate['user_id'])
->get()
->first(function (UserPaymentOption $option) use ($candidate): bool {
$conditions = $option->legacy_conditions ?? [];
return ($conditions['legacy_portal'] ?? null) === $candidate['legacy_portal']
&& (int) ($conditions['legacy_user_payment_option_id'] ?? 0) === $candidate['legacy_upo_id'];
});
}
private function describe(array $candidate): string
{
return sprintf(
'User #%d · %s · Legacy-UPO #%d · fällig %s · netto %s €',
$candidate['user_id'],
$candidate['legacy_portal'],
$candidate['legacy_upo_id'],
$candidate['next_due_date']->toDateString(),
number_format($candidate['legacy_conditions']['net_cents'] / 100, 2, ',', '.'),
);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
@ -11,11 +12,13 @@ use Illuminate\Support\Str;
use Throwable;
/**
* Veröffentlicht Pressemitteilungen mit Status `review` und einem
* `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
* Veröffentlicht Pressemitteilungen mit Status `review`, der KI-Klassifikation
* `green` und einem `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
*
* Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent:
* berührt nur PRs in Review-Status bereits publishte werden ignoriert.
* berührt nur grüne PRs in Review-Status bereits publishte werden ignoriert.
* Gelb eingestufte PMs bleiben bewusst in der manuellen Admin-Queue, auch wenn
* ihr Termin fällig ist.
*
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
* Mail-Benachrichtigung des Autors.
@ -41,8 +44,15 @@ class PublishScheduledPressReleases extends Command
$now = now();
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
// Fallback in der manuellen Queue.
$candidates = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Review->value)
->whereIn('classification', [
PressReleaseClassification::Green->value,
PressReleaseClassification::Yellow->value,
])
->whereNotNull('scheduled_at')
->where('scheduled_at', '<=', $now)
->orderBy('scheduled_at')

View file

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Setzt den monatlichen PM-Kontingent-Verbrauch aller User zurück.
*
* Temporärer Stub bis zum echten Tarif-/Credit-Modul. Läuft per Scheduler
* am Monatsanfang (siehe routes/console.php).
*/
class ResetMonthlyPressReleaseQuota extends Command
{
/**
* @var string
*/
protected $signature = 'press-releases:reset-monthly-quota';
/**
* @var string
*/
protected $description = 'Setzt den monatlichen PM-Kontingent-Verbrauch (press_release_quota_used_this_month) aller User auf 0 zurück.';
public function handle(): int
{
$affected = User::query()
->where('press_release_quota_used_this_month', '>', 0)
->update(['press_release_quota_used_this_month' => 0]);
$this->info(sprintf('Kontingent-Verbrauch für %d User zurückgesetzt.', $affected));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* Arbeitet die KI-Klassifikations-Queue zum Testen einmalig ab ohne
* dauerhaft laufenden Queue-Worker.
*
* Standardmäßig werden alle anstehenden Jobs verarbeitet und der Worker
* beendet sich danach (--stop-when-empty). Mit --once nur ein einzelner Job.
*/
class RunClassificationQueue extends Command
{
protected $signature = 'classification:work {--once : Nur einen einzelnen Job verarbeiten}';
protected $description = 'Verarbeitet die KI-Klassifikations-Queue einmalig (ohne Dauer-Worker).';
public function handle(): int
{
$options = [
'--queue' => 'classification',
'--tries' => 3,
];
if ($this->option('once')) {
$options['--once'] = true;
} else {
$options['--stop-when-empty'] = true;
}
return $this->call('queue:work', $options);
}
}

View file

@ -0,0 +1,140 @@
<?php
namespace App\Console\Commands;
use App\Models\Plan;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
/**
* Legt die Tarife aus `plans` als Produkte/Preise in Stripe an und pflegt
* die Stripe-IDs zurück (Phase 9E).
*
* Preise sind NETTO (`tax_behavior: exclusive`) die Steuer wird im
* Checkout ergänzt (Entscheidung 12.06.2026). Idempotent: vorhandene
* IDs werden übersprungen; Preisänderungen erzeugen in Stripe bewusst
* neue Price-Objekte (Stripe-Preise sind unveränderlich), der alte Preis
* bleibt für Bestandsabos gültig.
*/
class SyncStripePlans extends Command
{
protected $signature = 'billing:sync-stripe-plans
{--dry-run : Nur anzeigen, was angelegt würde}';
protected $description = 'Synchronisiert die Tarife aus plans als Netto-Produkte/Preise nach Stripe';
public function handle(): int
{
if (! config('cashier.secret')) {
$this->error('STRIPE_SECRET ist nicht gesetzt.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$stripe = Cashier::stripe();
foreach (Plan::query()->active()->get() as $plan) {
if ($plan->stripe_product_id && $plan->stripe_price_id_monthly && $plan->stripe_price_id_yearly) {
$this->line("{$plan->slug}: vollständig verknüpft, übersprungen.");
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry-run] %s: Produkt + Preise %s €/Monat, %s €/Jahr (netto) anlegen.',
$plan->slug,
number_format($plan->monthly_price_cents / 100, 2, ',', '.'),
number_format($plan->yearly_price_cents / 100, 2, ',', '.'),
));
continue;
}
$productId = $plan->stripe_product_id;
if (! $productId) {
$product = $stripe->products->create([
'name' => $plan->name,
'metadata' => ['plan_slug' => $plan->slug],
]);
$productId = $product->id;
}
$monthlyId = $plan->stripe_price_id_monthly
?: $this->createPrice($stripe, $productId, $plan, $plan->monthly_price_cents, 'month');
$yearlyId = $plan->stripe_price_id_yearly
?: $this->createPrice($stripe, $productId, $plan, $plan->yearly_price_cents, 'year');
$plan->update([
'stripe_product_id' => $productId,
'stripe_price_id_monthly' => $monthlyId,
'stripe_price_id_yearly' => $yearlyId,
]);
$this->info("{$plan->slug}: {$productId} · monatlich {$monthlyId} · jährlich {$yearlyId}");
}
$this->syncSinglePmPrice($stripe, $dryRun);
return self::SUCCESS;
}
/**
* Legt das Einmal-Produkt „Einzel-Pressemitteilung" an (Netto-Preis aus
* billing.single_pm_price_cents). Die Price-ID landet bewusst in der ENV
* (STRIPE_PRICE_SINGLE_PM) statt in einer Tabelle es gibt genau einen
* solchen Preis, und ohne ENV bleibt der Checkout deaktiviert.
*/
private function syncSinglePmPrice(object $stripe, bool $dryRun): void
{
if (config('billing.single_pm_stripe_price_id')) {
$this->line('einzel-pm: bereits verknüpft (STRIPE_PRICE_SINGLE_PM), übersprungen.');
return;
}
$amount = (int) config('billing.single_pm_price_cents');
if ($dryRun) {
$this->line(sprintf(
'[dry-run] einzel-pm: Einmal-Produkt + Preis %s € (netto) anlegen.',
number_format($amount / 100, 2, ',', '.'),
));
return;
}
$product = $stripe->products->create([
'name' => 'Einzel-Pressemitteilung',
'metadata' => ['purpose' => 'single_pm'],
]);
$price = $stripe->prices->create([
'product' => $product->id,
'currency' => 'eur',
'unit_amount' => $amount,
'tax_behavior' => 'exclusive',
'metadata' => ['purpose' => 'single_pm'],
]);
$this->info("einzel-pm: {$product->id} · {$price->id}");
$this->warn("Bitte in die .env eintragen: STRIPE_PRICE_SINGLE_PM={$price->id}");
}
private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
{
$price = $stripe->prices->create([
'product' => $productId,
'currency' => strtolower($plan->currency),
'unit_amount' => $unitAmount,
'tax_behavior' => 'exclusive',
'recurring' => ['interval' => $interval],
'metadata' => ['plan_slug' => $plan->slug],
]);
return $price->id;
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
\App\Console\Commands\GenerateDomainFavicons::class,
];
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Enums;
/**
* Lizenz-/Herkunftstyp eines Pressemitteilungs-Bildes.
*
* Dient der rechtssicheren Erfassung von Bildrechten beim Upload.
*/
enum ImageLicenseType: string
{
case Own = 'own';
case CreativeCommons = 'cc';
case Commercial = 'commercial';
case Consent = 'consent';
case PressPr = 'press_pr';
case PublicDomain = 'public_domain';
case AiGenerated = 'ai_generated';
case Other = 'other';
/**
* Lesbares Label für die UI.
*/
public function label(): string
{
return match ($this) {
self::Own => 'Eigene Aufnahme',
self::Consent => 'Vom Urheber / Fotografen freigegeben',
self::Commercial => 'Agentur-/Stockbild-Lizenz',
self::CreativeCommons => 'Creative-Commons-Lizenz',
self::PressPr => 'Presse-/PR-Bild mit Nutzungsfreigabe',
self::PublicDomain => 'Gemeinfrei / Public Domain / CC0',
self::AiGenerated => 'KI-generiert (z. B. Midjourney, DALL·E, Firefly)',
self::Other => 'Sonstige Lizenz / Sondervereinbarung',
};
}
/**
* Ob für diesen Typ eine Lizenz-URL Pflicht ist.
*/
public function requiresLicenseUrl(): bool
{
return in_array($this, [self::CreativeCommons, self::Commercial, self::PressPr], true);
}
/**
* Ob zusaetzliche Lizenzdetails verpflichtend sind.
* Bei KI-Bildern ist das Detail das verwendete Tool (AI-Act-Kennzeichnung).
*/
public function requiresLicenseDetail(): bool
{
return in_array($this, [self::CreativeCommons, self::AiGenerated, self::Other], true);
}
/**
* Rein KI-generierte Bilder haben keinen menschlichen Urheber (§ 2 UrhG);
* maßgeblich sind die Anbieter-Bedingungen und die Kennzeichnungspflicht
* aus Art. 50 EU AI Act (ab 02.08.2026).
*/
public function isAiGenerated(): bool
{
return $this === self::AiGenerated;
}
/**
* Optionsliste für Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
}

View file

@ -16,4 +16,23 @@ enum Portal: string
self::Both => 'Beide Portale',
};
}
public function abbreviation(): string
{
return match ($this) {
self::Presseecho => 'PE',
self::Businessportal24 => 'B24',
self::Both => 'PE+B24',
};
}
public static function stripTrailingAbbreviation(string $value): string
{
$abbreviations = implode('|', array_map(
fn (self $portal): string => preg_quote($portal->abbreviation(), '/'),
self::cases(),
));
return trim((string) preg_replace('/\s*\(('.$abbreviations.')\)\s*$/u', '', $value));
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
/**
* Klassifikations-Score („Red Flag") laut Konzept §15.1.
*
* Entscheidet, ob eine Pressemitteilung überhaupt veröffentlicht wird:
* - Green unauffällig, Veröffentlichungspfad
* - Yellow grenzwertig, manuelle Review-Queue
* - Red unzulässig, zurück an den Autor
*/
enum PressReleaseClassification: string
{
case Green = 'green';
case Yellow = 'yellow';
case Red = 'red';
public function label(): string
{
return match ($this) {
self::Green => 'Grün',
self::Yellow => 'Gelb',
self::Red => 'Rot',
};
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Enums;
/**
* Content-Score-Stufe (Außenkommunikation, Konzept Update 2).
*
* Der numerische Score (0100) bleibt plattform-intern; nach außen wird er auf
* drei Stufen gemappt. Schwellen sind über config/scoring.php kalibrierbar.
*/
enum PressReleaseContentTier: string
{
case Standard = 'standard';
case Geprueft = 'gepruft';
case Hochwertig = 'hochwertig';
/**
* Leitet die Stufe aus dem numerischen Score ab (kalibrierbar über
* config('scoring.content_score.tiers')).
*/
public static function fromScore(int $score): self
{
$tiers = config('scoring.content_score.tiers', []);
$hochwertig = (int) ($tiers['hochwertig'] ?? 80);
$gepruft = (int) ($tiers['gepruft'] ?? 60);
return match (true) {
$score >= $hochwertig => self::Hochwertig,
$score >= $gepruft => self::Geprueft,
default => self::Standard,
};
}
public function label(): string
{
return match ($this) {
self::Standard => 'Standard',
self::Geprueft => 'Geprüft',
self::Hochwertig => 'Hochwertig',
};
}
/**
* Ob die Stufe öffentlich als Vertrauensindikator gezeigt wird. Standard
* wird laut Update 2 bewusst nicht beworben (kein Badge/Label).
*/
public function isPubliclyBadged(): bool
{
return $this !== self::Standard;
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace App\Enums;
/**
* Wiederverwendbares Set farbiger SVG-Titelbild-Platzhalter für
* Pressemitteilungen ohne eigenes Bild.
*
* Die Werte entsprechen den Dateinamen in
* `public/images/press-release-placeholders/<value>.svg`.
*/
enum PressReleasePlaceholder: string
{
case GridBlue = '01-grid-blue';
case GridGreen = '02-grid-green';
case GridAmber = '03-grid-amber';
case LinesBlue = '04-lines-blue';
case LinesGreen = '05-lines-green';
case LinesAmber = '06-lines-amber';
case DotsBlue = '07-dots-blue';
case DotsGreen = '08-dots-green';
case DotsAmber = '09-dots-amber';
case WavesBlue = '10-waves-blue';
case WavesGreen = '11-waves-green';
case WavesAmber = '12-waves-amber';
case EditorialBlue = '13-editorial-blue';
case EditorialGreen = '14-editorial-green';
case EditorialAmber = '15-editorial-amber';
case SignalBlue = '16-signal-blue';
case SignalGreen = '17-signal-green';
case SignalAmber = '18-signal-amber';
/**
* Default-Variante, wenn nichts gesetzt ist.
*/
public static function default(): self
{
return self::GridBlue;
}
/**
* Liefert die Variante zu einem Roh-Wert oder den Default-Fallback.
*/
public static function fromValueOrDefault(?string $value): self
{
return self::tryFrom((string) $value) ?? self::default();
}
/**
* Deterministische Variante aus einem Seed (z. B. PM-ID/Titel), damit
* dieselbe PM immer denselben Platzhalter bekommt.
*/
public static function fromSeed(int|string $seed): self
{
$cases = self::cases();
return $cases[abs(crc32((string) $seed)) % count($cases)];
}
/**
* Öffentlicher Asset-Pfad relativ zu `public/`.
*/
public function path(): string
{
return 'images/press-release-placeholders/'.$this->value.'.svg';
}
/**
* Lesbares Label für die UI (Picker-Tooltips etc.).
*/
public function label(): string
{
$pattern = match (true) {
str_contains($this->value, 'grid') => 'Raster',
str_contains($this->value, 'lines') => 'Linien',
str_contains($this->value, 'dots') => 'Punkte',
str_contains($this->value, 'waves') => 'Wellen',
str_contains($this->value, 'editorial') => 'Editorial',
default => 'Signal',
};
$color = match (true) {
str_contains($this->value, 'blue') => 'Blau',
str_contains($this->value, 'green') => 'Grün',
default => 'Bernstein',
};
return $pattern.' · '.$color;
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum SinglePurchaseStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Consumed = 'consumed';
case Refunded = 'refunded';
public function label(): string
{
return match ($this) {
self::Pending => 'Ausstehend',
self::Paid => 'Bezahlt',
self::Consumed => 'Eingelöst',
self::Refunded => 'Erstattet',
};
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Enums;
enum SinglePurchaseType: string
{
case SinglePm = 'single_pm';
case ExtraPm = 'extra_pm';
case Boost = 'boost';
case ProofPdf = 'proof_pdf';
public function label(): string
{
return match ($this) {
self::SinglePm => 'Einzel-Pressemitteilung',
self::ExtraPm => 'Extra-PM',
self::Boost => 'Boost / Platzierung',
self::ProofPdf => 'Veröffentlichungsnachweis (PDF)',
};
}
/**
* Käufe, die zum Einreichen/Veröffentlichen einer PM berechtigen
* (relevant für das Submit-Gate und den Slot-Verbrauch).
*/
public function grantsSubmission(): bool
{
return in_array($this, [self::SinglePm, self::ExtraPm], true);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
/**
* Ergebnis der USt-ID-Prüfung (Format + optionale eVatR-Online-Abfrage).
*/
enum VatIdCheckStatus: string
{
case Valid = 'valid';
case Invalid = 'invalid';
case FormatInvalid = 'format_invalid';
case Unverified = 'unverified';
public function isUsable(): bool
{
return $this !== self::Invalid && $this !== self::FormatInvalid;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Enums;
/**
* USt-Behandlung einer Rechnung (Entscheidung 12.06.2026):
* Deutschland grundsätzlich immer mit Steuer, EU-Ausland nur mit gültiger
* USt-ID befreit (Reverse Charge), Drittländer grundsätzlich befreit.
*/
enum VatTreatment: string
{
case Domestic = 'domestic';
case EuConsumer = 'eu_consumer';
case ReverseCharge = 'reverse_charge';
case ThirdCountry = 'third_country';
public function isTaxExempt(): bool
{
return in_array($this, [self::ReverseCharge, self::ThirdCountry], true);
}
public function label(): string
{
return match ($this) {
self::Domestic => 'Inland (Deutschland)',
self::EuConsumer => 'EU ohne USt-ID',
self::ReverseCharge => 'EU mit USt-ID (Reverse Charge)',
self::ThirdCountry => 'Drittland (steuerbefreit)',
};
}
public function taxNote(): ?string
{
return match ($this) {
self::ReverseCharge => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge, Art. 196 MwStSystRL).',
self::ThirdCountry => 'Nicht im Inland steuerbare Leistung (§ 3a Abs. 2 UStG).',
default => null,
};
}
}

View file

@ -2,12 +2,17 @@
namespace App\Http\Controllers\Api\V1;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
use App\Http\Resources\PressReleaseResource;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -46,7 +51,10 @@ class PressReleaseController extends Controller
$company->portal->value,
$validated['language'],
),
'status' => $validated['status'] ?? 'draft',
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
// `review` erfolgt ausschließlich über die explizite submit-Route,
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
'status' => PressReleaseStatus::Draft->value,
]);
return PressReleaseResource::make(
@ -101,11 +109,59 @@ class PressReleaseController extends Controller
$pressRelease->save();
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
if ($pressRelease->wasChanged(['title', 'text'])) {
$service = app(PressReleaseService::class);
$service->reclassifyIfClassified($pressRelease);
$service->rescoreIfScored($pressRelease);
}
return PressReleaseResource::make(
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
);
}
/**
* Reicht eine PM zur Prüfung ein der einzige API-Weg nach `review`.
*
* Kapselt denselben Funnel wie das Web-Formular: Blacklist-Hard-Filter,
* Statuswechsel, Status-Log und Quota werden über den
* `PressReleaseService` garantiert durchlaufen. `published` bleibt über die
* API unerreichbar.
*/
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
abort_unless($pressRelease !== null, 403);
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
return response()->json([
'message' => 'Only draft or rejected press releases may be submitted for review.',
], 409);
}
try {
$service->submitForReview($pressRelease);
} catch (BookingRequiredException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], 402);
} catch (QuotaExceededException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], 422);
} catch (BlacklistViolationException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], 422);
}
return PressReleaseResource::make(
$pressRelease->fresh()->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
);
}
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);

View file

@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Services\Billing\StripeCheckoutService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Laravel\Cashier\Checkout;
/**
* Einstieg in die Stripe-Checkouts (Phase 9E): Tarif-Abo und Einzel-PM.
*
* Die Routen leiten direkt zur Stripe-Checkout-Seite weiter; Erfolg und
* Abbruch landen auf der Buchungs-Seite (?checkout=erfolg|abbruch). Die
* Erfüllung übernimmt der Webhook (ProcessStripeWebhook): Cashier legt das
* Abo an, `checkout.session.completed` markiert den Einmalkauf als bezahlt.
*/
class CheckoutController extends Controller
{
public function __construct(private readonly StripeCheckoutService $checkout) {}
public function subscription(Request $request, string $planSlug, string $interval): Checkout|RedirectResponse
{
$plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail();
$user = $request->user();
if (! $user->hasCompleteBillingAddress()) {
return $this->backToProfile();
}
if ($user->subscribed()) {
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.'));
}
$priceId = $interval === 'yearly'
? $plan->stripe_price_id_yearly
: $plan->stripe_price_id_monthly;
if (! $priceId) {
return $this->backToBookings(__('Dieser Tarif ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
}
return $this->checkout->forSubscription($user, $plan, $interval);
}
public function singlePm(Request $request): Checkout|RedirectResponse
{
if (! $request->user()->hasCompleteBillingAddress()) {
return $this->backToProfile();
}
if (! config('billing.single_pm_stripe_price_id')) {
return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
}
$purchase = SinglePurchase::query()->create([
'user_id' => $request->user()->id,
'type' => SinglePurchaseType::SinglePm->value,
'status' => SinglePurchaseStatus::Pending->value,
'price_cents' => (int) config('billing.single_pm_price_cents'),
'currency' => 'EUR',
]);
return $this->checkout->forSinglePurchase($request->user(), $purchase);
}
/**
* Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode,
* Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll.
*/
public function billingPortal(Request $request): RedirectResponse
{
$user = $request->user();
if (! $user->hasStripeId() || ! $user->subscribed()) {
return $this->backToBookings(__('Es besteht kein aktives Abo, das verwaltet werden könnte.'));
}
return redirect()->away($this->checkout->billingPortalUrl($user));
}
private function backToBookings(string $notice): RedirectResponse
{
return redirect()
->route('me.bookings.index')
->with('checkout-notice', $notice);
}
/**
* Buchungs-Voraussetzung (12.06.2026): ohne vollständige
* Rechnungsadresse kein Checkout der Hinweis erscheint direkt auf
* der Profil-Seite über dem Rechnungsadress-Formular.
*/
private function backToProfile(): RedirectResponse
{
return redirect()
->route('me.profile')
->with('checkout-notice', __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung und wird an Stripe übergeben.'));
}
}

View file

@ -2,10 +2,8 @@
namespace App\Http\Requests\Api\V1;
use App\Enums\PressReleaseStatus;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePressReleaseRequest extends FormRequest
{
@ -32,10 +30,6 @@ class StorePressReleaseRequest extends FormRequest
'text' => ['required', 'string'],
'backlink_url' => ['nullable', 'url', 'max:255'],
'keywords' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in([
PressReleaseStatus::Draft->value,
PressReleaseStatus::Review->value,
])],
'teaser_begin' => ['nullable', 'integer', 'min:0'],
'teaser_end' => ['nullable', 'integer', 'min:0'],
'no_export' => ['nullable', 'boolean'],

View file

@ -2,10 +2,8 @@
namespace App\Http\Requests\Api\V1;
use App\Enums\PressReleaseStatus;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePressReleaseRequest extends FormRequest
{
@ -32,10 +30,6 @@ class UpdatePressReleaseRequest extends FormRequest
'text' => ['sometimes', 'required', 'string'],
'backlink_url' => ['nullable', 'url', 'max:255'],
'keywords' => ['nullable', 'string', 'max:255'],
'status' => ['sometimes', Rule::in([
PressReleaseStatus::Draft->value,
PressReleaseStatus::Review->value,
])],
'teaser_begin' => ['nullable', 'integer', 'min:0'],
'teaser_end' => ['nullable', 'integer', 'min:0'],
'no_export' => ['nullable', 'boolean'],

View file

@ -0,0 +1,107 @@
<?php
namespace App\Jobs;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
/**
* Klassifiziert eine Pressemitteilung asynchron (Red Flag, Konzept §15.1).
*
* Läuft auf der dedizierten Queue „classification". Nutzt den konfigurierten
* KI-Treiber; bei Ausfall wird auf den deterministischen Treiber
* zurückgefallen, damit eine Einreichung nie an einer KI-Störung hängen
* bleibt. Ergebnis landet in `press_releases.classification`/`classified_at`
* und als unveränderlicher Eintrag in `ki_audits`.
*
* Das Ergebnis wird gespeichert/auditiert und anschließend über
* PressReleaseService::routeByClassification() in den Status überführt
* (Rot→rejected, Gelb→review, Grün→publish).
*/
class ClassifyPressRelease implements ShouldQueue
{
use Queueable;
public int $tries = 3;
/**
* @param int $pressReleaseId Zu prüfende PM
* @param bool $route Ob das Ergebnis den Status steuert (Rot→reject,
* Grün→publish). Beim regulären Einreichen `true`;
* beim manuellen Admin-Re-Check `false` (nur
* klassifizieren/auditieren, Status unverändert).
* @param string|null $providerOverride Optionaler abweichender Treiber
* (z. B. 'openai'|'deterministic') für diesen Lauf.
*/
public function __construct(
public readonly int $pressReleaseId,
public readonly bool $route = true,
public readonly ?string $providerOverride = null,
) {}
public function handle(ClassificationManager $manager, PressReleaseService $service): void
{
$pressRelease = PressRelease::withoutGlobalScopes()->find($this->pressReleaseId);
if ($pressRelease === null) {
return;
}
$result = $this->classify($manager, $pressRelease);
$pressRelease->forceFill([
'classification' => $result->classification->value,
'classified_at' => now(),
])->save();
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'provider' => $result->provider,
'model' => $result->model,
'result' => $result->classification->value,
'reason' => $result->reasonText(),
'raw_response' => $result->rawResponse,
'created_at' => now(),
]);
if (! $this->route) {
return;
}
try {
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText());
} catch (BlacklistViolationException) {
// publish() hat die PM bereits abgelehnt und den Autor benachrichtigt.
}
}
/**
* Klassifiziert über den aktiven (oder explizit gewählten) Treiber; bei
* Fehler greift der deterministische Fallback, damit das Ergebnis
* nachvollziehbar bleibt.
*/
private function classify(ClassificationManager $manager, PressRelease $pressRelease): ClassificationResult
{
$provider = $this->providerOverride ?: $manager->getDefaultDriver();
try {
return $manager->driver($provider)->classify($pressRelease);
} catch (\Throwable $exception) {
Log::warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [
'press_release_id' => $pressRelease->id,
'provider' => $provider,
'error' => $exception->getMessage(),
]);
return $manager->driver('deterministic')->classify($pressRelease);
}
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Jobs;
use App\Enums\PressReleaseContentTier;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Services\PressRelease\ContentScore\ContentScoreManager;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
/**
* Berechnet den Content-Score einer Pressemitteilung asynchron (Konzept §15.2).
*
* Läuft auf der Queue „classification" (gemeinsam mit der Klassifikation, ein
* Drain-Befehl). Nutzt den konfigurierten Treiber; bei Ausfall greift der
* deterministische Fallback. Ergebnis: `content_score` + abgeleitete
* `content_tier` + `scored_at`, plus `ki_audits`-Eintrag (type=content_score).
*
* Reine Qualitätsbewertung ändert nie den Status (das macht die
* Klassifikation).
*/
class ScorePressRelease implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(
public readonly int $pressReleaseId,
public readonly ?string $providerOverride = null,
) {}
public function handle(ContentScoreManager $manager): void
{
$pressRelease = PressRelease::withoutGlobalScopes()->find($this->pressReleaseId);
if ($pressRelease === null) {
return;
}
$result = $this->score($manager, $pressRelease);
$tier = PressReleaseContentTier::fromScore($result->score);
$pressRelease->forceFill([
'content_score' => $result->score,
'content_tier' => $tier->value,
'scored_at' => now(),
])->save();
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CONTENT_SCORE,
'provider' => $result->provider,
'model' => $result->model,
'result' => (string) $result->score,
'reason' => $tier->label(),
'raw_response' => $result->rawResponse,
'created_at' => now(),
]);
}
/**
* Bewertet über den aktiven (oder explizit gewählten) Treiber; bei Fehler
* greift der deterministische Fallback.
*/
private function score(ContentScoreManager $manager, PressRelease $pressRelease): ContentScoreResult
{
$provider = $this->providerOverride ?: $manager->getDefaultDriver();
try {
return $manager->driver($provider)->score($pressRelease);
} catch (\Throwable $exception) {
Log::warning('Content-Score fiel auf den deterministischen Treiber zurück.', [
'press_release_id' => $pressRelease->id,
'provider' => $provider,
'error' => $exception->getMessage(),
]);
return $manager->driver('deterministic')->score($pressRelease);
}
}
}

View file

@ -0,0 +1,146 @@
<?php
namespace App\Listeners;
use App\Enums\InvoiceStatus;
use App\Enums\SinglePurchaseStatus;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Services\Billing\InvoiceNumberGenerator;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
/**
* Verarbeitet Stripe-Webhooks über das Cashier-Event (Phase 9E).
*
* Cashier selbst pflegt den Subscription-Zustand; dieser Listener ergänzt
* die beiden projektspezifischen Schritte des hybriden Rechnungsmodells:
*
* 1. **STR-Spiegelung**: Jede bezahlte Stripe-Rechnung wird als lokale
* Rechnung im STR-Kreis gespiegelt (fortlaufende Nummer aus dem
* InvoiceNumberGenerator, Adress-Snapshot aus dem Stripe-Payload).
* Stripe bleibt Zahlungs-/Beleg-Quelle, `invoices` der lückenlose
* lokale Rechnungsausgang.
* 2. **Einmalkauf-Erfüllung**: `checkout.session.completed` markiert den
* referenzierten `single_purchases`-Datensatz als bezahlt
* (Metadata-Schlüssel `single_purchase_id`, gesetzt beim Checkout).
*/
class ProcessStripeWebhook
{
public function __construct(private readonly InvoiceNumberGenerator $numbers) {}
public function handle(WebhookReceived $event): void
{
match ($event->payload['type'] ?? null) {
'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []),
'checkout.session.completed' => $this->fulfillSinglePurchase($event->payload['data']['object'] ?? []),
default => null,
};
}
/**
* @param array<string, mixed> $stripeInvoice
*/
private function mirrorPaidInvoice(array $stripeInvoice): void
{
$stripeInvoiceId = $stripeInvoice['id'] ?? null;
if (! $stripeInvoiceId) {
return;
}
// Idempotent: Stripe liefert Webhooks mindestens einmal.
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
return;
}
$user = Cashier::findBillable($stripeInvoice['customer'] ?? null);
if (! $user instanceof User) {
Log::warning('STR-Spiegelung übersprungen: kein Billable zum Stripe-Customer.', [
'stripe_invoice_id' => $stripeInvoiceId,
'stripe_customer' => $stripeInvoice['customer'] ?? null,
]);
return;
}
$subtotal = (int) ($stripeInvoice['subtotal'] ?? 0);
$tax = (int) ($stripeInvoice['tax'] ?? 0);
$total = (int) ($stripeInvoice['total'] ?? $subtotal + $tax);
$invoice = Invoice::query()->create([
'user_id' => $user->id,
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
'number' => $this->numbers->nextStripeNumber(),
'status' => InvoiceStatus::Paid->value,
'amount_cents' => $subtotal,
'tax_cents' => $tax,
'total_cents' => $total,
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? 'eur')),
'is_netto' => $tax === 0,
'invoice_date' => now()->toDateString(),
'paid_at' => now(),
'stripe_invoice_id' => $stripeInvoiceId,
]);
Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [
'number' => $invoice->number,
'stripe_invoice_id' => $stripeInvoiceId,
]);
}
/**
* Adress-Snapshot pro Rechnung: bevorzugt die Adresse aus dem
* Stripe-Payload (maßgeblich für genau diese Rechnung), sonst die
* lokale Rechnungsadresse des Users.
*
* @param array<string, mixed> $stripeInvoice
*/
private function snapshotAddress(User $user, array $stripeInvoice): InvoiceBillingAddress
{
$stripeAddress = $stripeInvoice['customer_address'] ?? null;
$local = $user->billingAddress;
return InvoiceBillingAddress::query()->create([
'company' => $local?->company,
'name' => $stripeInvoice['customer_name'] ?? $local?->name ?? $user->name,
'address1' => $stripeAddress['line1'] ?? $local?->address1 ?? '',
'address2' => $stripeAddress['line2'] ?? $local?->address2,
'postal_code' => $stripeAddress['postal_code'] ?? $local?->postal_code ?? '',
'city' => $stripeAddress['city'] ?? $local?->city ?? '',
'country_code' => $stripeAddress['country'] ?? $local?->country_code ?? 'DE',
'vat_id' => $local?->vat_id,
]);
}
/**
* @param array<string, mixed> $session
*/
private function fulfillSinglePurchase(array $session): void
{
$purchaseId = $session['metadata']['single_purchase_id'] ?? null;
if (! $purchaseId) {
return;
}
$purchase = SinglePurchase::query()->find((int) $purchaseId);
if (! $purchase || $purchase->status !== SinglePurchaseStatus::Pending) {
return;
}
$purchase->update([
'status' => SinglePurchaseStatus::Paid->value,
'paid_at' => now(),
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
'stripe_payment_intent_id' => $session['payment_intent'] ?? null,
]);
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
}
}

View file

@ -14,16 +14,33 @@ class BillingAddress extends Model
'user_id',
'salutation_key',
'title',
'company',
'first_name',
'last_name',
'name',
'address1',
'address2',
'postal_code',
'city',
'country_code',
'vat_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Ist die Adresse vollständig genug für eine Rechnung bzw. Buchung?
* Maßgeblich: Empfänger (Name), Straße, PLZ, Ort und Land.
*/
public function isComplete(): bool
{
return filled($this->name)
&& filled($this->address1)
&& filled($this->postal_code)
&& filled($this->city)
&& filled($this->country_code);
}
}

View file

@ -23,6 +23,7 @@ class Invoice extends Model
'total_cents',
'currency',
'is_netto',
'tax_note',
'invoice_date',
'due_date',
'paid_at',

View file

@ -13,12 +13,14 @@ class InvoiceBillingAddress extends Model
protected $fillable = [
'salutation_key',
'title',
'company',
'name',
'address1',
'address2',
'postal_code',
'city',
'country_code',
'vat_id',
];
public function invoices(): HasMany

51
app/Models/KiAudit.php Normal file
View file

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Database\Factories\KiAuditFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Audit-Log jeder KI-Entscheidung (Klassifikation / Content-Score).
*
* Schreibt für jede Bewertung einen unveränderlichen Eintrag inkl.
* Anbieter, Modell, Ergebnis, Begründung und Roh-Antwort für
* Nachvollziehbarkeit und Nachweispflicht (DSGVO).
*/
class KiAudit extends Model
{
/** @use HasFactory<KiAuditFactory> */
use HasFactory;
public $timestamps = false;
public const TYPE_CLASSIFICATION = 'classification';
public const TYPE_CONTENT_SCORE = 'content_score';
protected $fillable = [
'press_release_id',
'type',
'provider',
'model',
'result',
'reason',
'raw_response',
'created_at',
];
protected function casts(): array
{
return [
'raw_response' => 'array',
'created_at' => 'datetime',
];
}
public function pressRelease(): BelongsTo
{
return $this->belongsTo(PressRelease::class);
}
}

51
app/Models/Plan.php Normal file
View file

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Tarif-Katalog laut Decision-Update (Starter/Business/Pro/Agency).
*
* Der Jahrespreis entspricht 10 Monatsbeiträgen und wird als
* „2 Monate gratis" kommuniziert. `press_release_quota` ist das
* monatliche PM-Kontingent, `daily_limit` der Flut-Schutz (null = ohne).
*/
class Plan extends Model
{
use HasFactory;
protected $fillable = [
'slug',
'name',
'monthly_price_cents',
'yearly_price_cents',
'currency',
'press_release_quota',
'daily_limit',
'stripe_product_id',
'stripe_price_id_monthly',
'stripe_price_id_yearly',
'is_active',
'sort_order',
];
protected function casts(): array
{
return [
'monthly_price_cents' => 'integer',
'yearly_price_cents' => 'integer',
'press_release_quota' => 'integer',
'daily_limit' => 'integer',
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true)->orderBy('sort_order');
}
}

View file

@ -3,6 +3,9 @@
namespace App\Models;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseContentTier;
use App\Enums\PressReleasePlaceholder;
use App\Enums\PressReleaseStatus;
use App\Models\Concerns\HasUniqueSlug;
use App\Scopes\PortalScope;
@ -14,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
class PressRelease extends Model
@ -21,6 +25,13 @@ class PressRelease extends Model
/** @use HasFactory<PressReleaseFactory> */
use HasFactory, HasUniqueSlug, SoftDeletes;
/**
* Anzeige-Zeitzone für vom Nutzer erfasste Termine (scheduled_at,
* embargo_at). In der Datenbank wird weiterhin UTC gespeichert
* (config app.timezone).
*/
public const DISPLAY_TIMEZONE = 'Europe/Berlin';
/**
* @return list<string>
*/
@ -37,6 +48,13 @@ class PressRelease extends Model
protected static function booted(): void
{
static::addGlobalScope(new PortalScope);
static::creating(function (self $pressRelease): void {
if (blank($pressRelease->placeholder_variant)) {
$seed = $pressRelease->uuid ?? $pressRelease->title ?? (string) now()->timestamp;
$pressRelease->placeholder_variant = PressReleasePlaceholder::fromSeed($seed)->value;
}
});
}
protected $fillable = [
@ -51,9 +69,15 @@ class PressRelease extends Model
'slug',
'text',
'boilerplate_override',
'placeholder_variant',
'backlink_url',
'keywords',
'status',
'classification',
'classified_at',
'content_score',
'content_tier',
'scored_at',
'hits',
'teaser_begin',
'teaser_end',
@ -69,7 +93,13 @@ class PressRelease extends Model
{
return [
'portal' => Portal::class,
'placeholder_variant' => PressReleasePlaceholder::class,
'status' => PressReleaseStatus::class,
'classification' => PressReleaseClassification::class,
'classified_at' => 'datetime',
'content_score' => 'integer',
'content_tier' => PressReleaseContentTier::class,
'scored_at' => 'datetime',
'hits' => 'integer',
'teaser_begin' => 'integer',
'teaser_end' => 'integer',
@ -81,6 +111,22 @@ class PressRelease extends Model
];
}
/**
* Geplanter Veröffentlichungstermin in der Anzeige-Zeitzone (Europe/Berlin).
*/
public function scheduledAtLocal(): ?Carbon
{
return $this->scheduled_at?->copy()->setTimezone(self::DISPLAY_TIMEZONE);
}
/**
* Sperrfrist (Embargo) in der Anzeige-Zeitzone (Europe/Berlin).
*/
public function embargoAtLocal(): ?Carbon
{
return $this->embargo_at?->copy()->setTimezone(self::DISPLAY_TIMEZONE);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
@ -116,6 +162,11 @@ class PressRelease extends Model
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
}
public function kiAudits(): HasMany
{
return $this->hasMany(KiAudit::class)->orderByDesc('created_at');
}
/**
* Display-ready text. Returns sanitized HTML for Phase-7+ PMs and
* <p>/<br>-wrapped legacy plain text for older imports.

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\ImageLicenseType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -19,6 +20,17 @@ class PressReleaseImage extends Model
'title',
'description',
'copyright',
'author',
'license_type',
'license_detail',
'license_url',
'source_url',
'persons_consent',
'people_rights_status',
'property_rights_status',
'rights_notes',
'rights_confirmed_at',
'is_ai_generated',
'is_preview',
'sort_order',
'width',
@ -32,6 +44,10 @@ class PressReleaseImage extends Model
{
return [
'variants' => 'array',
'license_type' => ImageLicenseType::class,
'persons_consent' => 'boolean',
'rights_confirmed_at' => 'datetime',
'is_ai_generated' => 'boolean',
'is_preview' => 'boolean',
'sort_order' => 'integer',
'width' => 'integer',

View file

@ -0,0 +1,68 @@
<?php
namespace App\Models;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Einmalkauf laut Decision-Update: Einzel-PM sowie die Launch-Credit-Posten
* Extra-PM, Boost und Veröffentlichungsnachweis-PDF. Ein bezahlter, noch
* nicht eingelöster Kauf mit `grantsSubmission()`-Typ erfüllt das
* Submit-Gate (`User::hasActiveBooking()`).
*/
class SinglePurchase extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'type',
'status',
'price_cents',
'currency',
'press_release_id',
'stripe_checkout_session_id',
'stripe_payment_intent_id',
'paid_at',
'consumed_at',
];
protected function casts(): array
{
return [
'type' => SinglePurchaseType::class,
'status' => SinglePurchaseStatus::class,
'price_cents' => 'integer',
'paid_at' => 'datetime',
'consumed_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function pressRelease(): BelongsTo
{
return $this->belongsTo(PressRelease::class);
}
/**
* Bezahlte, noch nicht eingelöste Käufe, die zum Einreichen berechtigen.
*/
public function scopeGrantingSubmission(Builder $query): Builder
{
return $query
->where('status', SinglePurchaseStatus::Paid->value)
->whereIn('type', [
SinglePurchaseType::SinglePm->value,
SinglePurchaseType::ExtraPm->value,
]);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Enums\UserPaymentOptionStatus;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
@ -21,7 +23,7 @@ use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
@ -43,6 +45,7 @@ class User extends Authenticatable
'legacy_portal',
'legacy_id',
'password',
'press_release_quota_used_this_month',
];
/**
@ -73,9 +76,155 @@ class User extends Authenticatable
'last_seen_at' => 'datetime',
'deleted_at' => 'datetime',
'password' => 'hashed',
'press_release_quota_used_this_month' => 'integer',
];
}
/**
* Adresse für die Stripe-Customer-Anlage (Cashier-Hook). Stripe Tax
* braucht eine gültige Kundenadresse falls lokal eine
* Rechnungsadresse gepflegt ist, wird sie direkt mitgegeben; sonst
* speichert der Checkout die dort erfasste Adresse (customer_update).
*
* @return array<string, string|null>|null
*/
public function stripeAddress(): ?array
{
$address = $this->billingAddress;
if (! $address) {
return null;
}
return [
'line1' => $address->address1,
'line2' => $address->address2,
'postal_code' => $address->postal_code,
'city' => $address->city,
'country' => $address->country_code,
];
}
/**
* Buchungs-Voraussetzung (Entscheidung 12.06.2026): Tarif- und
* Einzel-PM-Checkouts erfordern eine vollständige Rechnungsadresse.
*/
public function hasCompleteBillingAddress(): bool
{
return (bool) $this->billingAddress?->isComplete();
}
/**
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
* gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.
*/
public function currentPlan(): ?Plan
{
$subscription = $this->subscription();
if (! $subscription?->valid()) {
return null;
}
$priceId = $subscription->stripe_price;
if (! $priceId) {
return null;
}
return Plan::query()
->where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_yearly', $priceId)
->first();
}
/**
* Hat dieser User ein unbegrenztes PM-Kontingent?
*
* Entscheidung 12.06.2026: Bestandskunden (aktive/grandfathered
* Legacy-Vereinbarung) behalten ihren Bestandsschutz unverändert
* das Alt-Produkt sah unbegrenzte PMs vor. Solange der Launch-Schalter
* `billing.enforce_booking` aus ist, gilt das Kontingent für niemanden.
*/
public function hasUnlimitedPressReleaseQuota(): bool
{
if (! config('billing.enforce_booking')) {
return true;
}
return $this->userPaymentOptions()
->whereIn('status', [
UserPaymentOptionStatus::Active->value,
UserPaymentOptionStatus::Grandfathered->value,
])
->exists();
}
/**
* Verbleibendes PM-Kontingent: Rest des Plan-Monatskontingents plus
* bezahlte, noch nicht eingelöste Einzel-/Extra-PM-Käufe.
* Null bedeutet unbegrenzt.
*/
public function pressReleaseQuotaRemaining(): ?int
{
if ($this->hasUnlimitedPressReleaseQuota()) {
return null;
}
$planRemaining = max(
0,
($this->currentPlan()?->press_release_quota ?? 0) - (int) $this->press_release_quota_used_this_month,
);
return $planRemaining + $this->singlePurchases()->grantingSubmission()->count();
}
/**
* Gesamtes PM-Kontingent (Plan-Monatskontingent plus offene Einmalkäufe)
* für die Anzeige „verbleibend / gesamt". Null bedeutet unbegrenzt.
*/
public function pressReleaseQuotaTotal(): ?int
{
if ($this->hasUnlimitedPressReleaseQuota()) {
return null;
}
return ($this->currentPlan()?->press_release_quota ?? 0)
+ $this->singlePurchases()->grantingSubmission()->count();
}
/**
* Submit-Gate aus dem Decision-Update §5.1: Einreichen zur Prüfung
* erfordert eine aktive Buchung.
*
* Hybrides Modell: Eine Buchung ist entweder ein aktives Stripe-Abo
* (Cashier, STR-Kreis), ein bezahlter Einmalkauf (Einzel-PM/Extra-PM)
* oder eine laufende Legacy-Zahlungsvereinbarung (manueller MAN-Kreis).
* Solange `billing.enforce_booking` deaktiviert ist, bleibt das Gate
* offen (Launch-Schalter).
*/
public function hasActiveBooking(): bool
{
if (! config('billing.enforce_booking')) {
return true;
}
if ($this->subscribed()) {
return true;
}
if ($this->singlePurchases()->grantingSubmission()->exists()) {
return true;
}
return $this->userPaymentOptions()
->whereIn('status', [
UserPaymentOptionStatus::Active->value,
UserPaymentOptionStatus::Grandfathered->value,
])
->exists();
}
/**
* Get the user's initials
*/
@ -135,6 +284,16 @@ class User extends Authenticatable
return $this->hasMany(UserPaymentOption::class);
}
public function singlePurchases(): HasMany
{
return $this->hasMany(SinglePurchase::class);
}
/**
* Lokale Rechnungen (STR- und MAN-Kreis). Überschreibt bewusst die
* gleichnamige Cashier-Methode Stripe-Rechnungen werden beim
* Webhook-Sync (9E) in diese Tabelle gespiegelt.
*/
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);

View file

@ -22,6 +22,7 @@ use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
@ -51,6 +52,12 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https');
}
// Stripe Tax berechnet die USt im Checkout automatisch nach den
// gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer,
// EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich
// die USt-ID-Abfrage im Stripe Checkout.
Cashier::calculateTaxes();
AdminPreset::observe(AdminPerformanceCacheObserver::class);
Category::observe(AdminPerformanceCacheObserver::class);
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);

View file

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Support\DomainAssetContext;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\URL;
@ -16,7 +17,6 @@ class ThemeServiceProvider extends ServiceProvider
*/
public function register(): void
{
// Registriere die domains.php Konfigurationsdatei
$this->mergeConfigFrom(
base_path('config/domains.php'),
'domains'
@ -28,11 +28,10 @@ class ThemeServiceProvider extends ServiceProvider
*/
public function boot(): void
{
$host = Request::getHost(); // is domain_name
$themeOverride = Request::get('theme'); // Allow theme override via URL parameter
$host = Request::getHost();
$themeOverride = Request::get('theme');
// Standard-Werte für Domain, die nicht in der Konfiguration sind
$domainConfig = [
$defaults = [
'name' => config('app.name'),
'theme' => 'b2in',
'view_prefix' => 'b2in',
@ -41,68 +40,47 @@ class ThemeServiceProvider extends ServiceProvider
'domain_name' => config('app.domain_name'),
];
// Lade die Domain-Konfiguration
$confiDomains = config('domains.domains');
$domainConfig = DomainAssetContext::resolve(
$host,
$defaults,
config('domains.domains', []),
is_string($themeOverride) ? $themeOverride : null,
);
// Suche nach der aktuellen Domain in der Konfiguration
foreach ($confiDomains as $name => $config) {
if (is_array($config) && isset($config['domain_name']) && $config['domain_name'] === $host) {
$domainConfig = array_merge($domainConfig, $config);
break;
}
}
$staticAssetOrigin = DomainAssetContext::staticAssetOrigin($domainConfig);
$viteDevServerUrl = DomainAssetContext::viteDevServerUrl($domainConfig);
// Allow theme override via URL parameter (for testing)
if ($themeOverride && isset($confiDomains[$themeOverride])) {
$domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]);
}
// Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
$assetUrl = $domainConfig['url'];
// Grundlegende Konfiguration im Anwendungskontext verfügbar machen
config([
'app.theme' => $domainConfig['theme'],
'app.view_prefix' => $domainConfig['view_prefix'],
'app.domain_name' => $domainConfig['domain_name'],
'app.url' => $domainConfig['url'],
'app.asset_url' => $assetUrl, // Dynamische Asset-URL für die aktuelle Domain
'app.asset_url' => $staticAssetOrigin,
]);
// URL-Generator für die aktuelle Domain konfigurieren
// Dies ist wichtig, damit asset() und url() die richtige Domain verwenden
URL::forceRootUrl($domainConfig['url']);
URL::forceScheme(parse_url($domainConfig['url'], PHP_URL_SCHEME) ?: 'https');
// WICHTIG: Asset-Root direkt im UrlGenerator setzen
// Der asset() Helper verwendet einen separaten Asset-Root
/** @var UrlGenerator $urlGenerator */
$urlGenerator = app('url');
$urlGenerator->useAssetOrigin($assetUrl);
$urlGenerator->useAssetOrigin($staticAssetOrigin);
// Spezifischere Daten für die Views verfügbar machen
View::share('theme', $domainConfig['theme']);
View::share('viewPrefix', $domainConfig['view_prefix']);
View::share('domainName', $domainConfig['domain_name']);
View::share('domainConfig', $domainConfig);
View::share('domainUrl', $domainConfig['url']);
View::share('assetUrl', $assetUrl);
View::share('assetUrl', $staticAssetOrigin);
View::share('viteDevServerUrl', $viteDevServerUrl);
// Vite-Assets-Konfiguration für die aktuelle Domain
if (! app()->runningInConsole()) {
if (isset($domainConfig['assets_dir'])) {
Vite::useBuildDirectory($domainConfig['assets_dir']);
DomainAssetContext::configureVite($domainConfig);
}
if (app()->environment('local')) {
// Entwicklung: Vite Dev Server mit HMR
$viteDevServerUrl = env('VITE_DEV_SERVER_URL', 'https://assets.pressekonto.test');
Vite::useHotFile(public_path('hot'));
config(['app.vite_dev_server_url' => $viteDevServerUrl]);
View::share('viteDevServerUrl', $viteDevServerUrl);
} else {
// Produktion: Assets von der aktuellen Domain laden (kein CORS nötig)
Vite::useScriptTagAttributes(['crossorigin' => false]);
Vite::useStyleTagAttributes(['crossorigin' => false]);
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* Vergibt fortlaufende Rechnungsnummern pro Rechnungskreis (hybrides Modell):
*
* - `STR` neuer Stripe-Shop-Kreislauf (alle neuen Abschlüsse/Zahlungen)
* - `MAN` manueller Legacy-Kreis ab Relaunch (laufende Alt-Zahlungen
* werden weiter per Rechnung abgerechnet)
*
* Die Vergabe läuft atomar über einen Row-Lock auf der Sequenz-Tabelle,
* damit Nummern auch bei parallelen Prozessen (Webhook + Scheduler)
* lückenlos und eindeutig bleiben.
*/
class InvoiceNumberGenerator
{
public const CIRCLE_STRIPE = 'STR';
public const CIRCLE_MANUAL = 'MAN';
public function nextStripeNumber(): string
{
return $this->next(self::CIRCLE_STRIPE);
}
public function nextManualNumber(): string
{
return $this->next(self::CIRCLE_MANUAL);
}
public function next(string $circle): string
{
if (! in_array($circle, [self::CIRCLE_STRIPE, self::CIRCLE_MANUAL], true)) {
throw new InvalidArgumentException("Unbekannter Rechnungskreis: {$circle}");
}
$number = DB::transaction(function () use ($circle): int {
$sequence = DB::table('invoice_number_sequences')
->where('circle', $circle)
->lockForUpdate()
->first();
if (! $sequence) {
DB::table('invoice_number_sequences')->insert([
'circle' => $circle,
'next_number' => 2,
'created_at' => now(),
'updated_at' => now(),
]);
return 1;
}
DB::table('invoice_number_sequences')
->where('id', $sequence->id)
->update(['next_number' => $sequence->next_number + 1, 'updated_at' => now()]);
return (int) $sequence->next_number;
});
$padding = (int) config('billing.invoice_number_padding', 5);
return sprintf('%s-%s', $circle, str_pad((string) $number, $padding, '0', STR_PAD_LEFT));
}
}

View file

@ -0,0 +1,162 @@
<?php
namespace App\Services\Billing;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\UserPaymentOption;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Manueller Rechnungskreis (MAN-) für laufende Legacy-Zahlungen.
*
* Aktive Alt-Vereinbarungen leben in `user_payment_options` ohne
* `stripe_subscription_id`. Wie im Legacy-System wird im Hintergrund
* geprüft, wann eine Rechnung fällig ist (`current_period_end` erreicht),
* dann eine Rechnung im MAN-Kreis ausgestellt und die Periode
* weitergeschaltet. Neue Abschlüsse laufen ausschließlich über Stripe
* (STR-Kreis) und werden hier bewusst ausgeklammert.
*
* Preisbasis ist immer NETTO (Entscheidung 12.06.2026): `legacy_conditions.
* net_cents` (von der Grandfather-Migration aus den Brutto-/Netto-Beträgen
* der letzten Legacy-Rechnung abgeleitet), sonst der Netto-Preis der
* `payment_option`. Die Steuer wird zur Rechnungsstellung über den
* `VatResolver` bestimmt und sauber ausgewiesen (DE immer mit Steuer,
* EU nur mit gültiger USt-ID befreit, Drittland befreit).
*/
class ManualInvoiceService
{
public function __construct(
private readonly InvoiceNumberGenerator $numbers,
private readonly VatResolver $vat,
) {}
/**
* @return Collection<int, UserPaymentOption>
*/
public function duePaymentOptions(?Carbon $asOf = null, int $limit = 50): Collection
{
$asOf = $asOf ?? today();
return UserPaymentOption::query()
->whereIn('status', [
UserPaymentOptionStatus::Active->value,
UserPaymentOptionStatus::Grandfathered->value,
])
->whereNull('stripe_subscription_id')
->whereDate('current_period_end', '<=', $asOf)
->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();
$user = $option->user;
$interval = $this->billingInterval($option);
if (! $user) {
Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $option->id]);
return null;
}
if (! $interval) {
Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $option->id]);
return null;
}
$billingAddress = $user->billingAddress;
if (! $billingAddress) {
Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [
'user_payment_option_id' => $option->id,
'user_id' => $user->id,
]);
return null;
}
$netCents = $this->resolveNetCents($option);
// USt zur Rechnungsstellung bestimmen: DE immer mit Steuer, EU nur
// mit gültiger USt-ID befreit (Reverse Charge), Drittland befreit.
$treatment = $this->vat->resolve($billingAddress->country_code, $billingAddress->vat_id);
$taxCents = $this->vat->taxCentsFor($netCents, $treatment);
return DB::transaction(function () use ($option, $user, $billingAddress, $netCents, $taxCents, $treatment, $interval, $asOf): Invoice {
// 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,
]);
$invoice = Invoice::query()->create([
'user_id' => $user->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' => $option->paymentOption?->currency ?? 'EUR',
'is_netto' => $treatment->isTaxExempt(),
'tax_note' => $treatment->taxNote(),
'invoice_date' => $asOf,
'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)),
]);
$option->update([
'current_period_start' => $option->current_period_end,
'current_period_end' => $interval === 'yearly'
? $option->current_period_end->copy()->addYear()
: $option->current_period_end->copy()->addMonth(),
]);
return $invoice;
});
}
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);
}
}

View file

@ -0,0 +1,120 @@
<?php
namespace App\Services\Billing;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Checkout;
/**
* Dünner Wrapper um die Cashier-Checkout-Erzeugung (Phase 9E).
*
* Hier passiert ausschließlich der Stripe-Aufruf alle Guards (Tarif
* synchronisiert, kein Doppel-Abo, Preis konfiguriert) liegen im
* CheckoutController. So bleibt der Controller ohne Stripe-Anbindung
* testbar, indem dieser Service im Container gemockt wird.
*/
class StripeCheckoutService
{
/**
* Stripe-Checkout für ein Tarif-Abo (monatlich/jährlich). Die Steuer
* ergänzt Stripe Tax automatisch (Cashier::calculateTaxes, Netto-Preise).
*/
public function forSubscription(User $user, Plan $plan, string $interval): Checkout
{
$priceId = $interval === 'yearly'
? $plan->stripe_price_id_yearly
: $plan->stripe_price_id_monthly;
$this->syncTaxIdFromBillingAddress($user);
return $user
->newSubscription('default', $priceId)
->checkout($this->sessionOptions());
}
/**
* Lokal gepflegte USt-ID vor dem Checkout an den Stripe-Customer
* übergeben (User-Panel-Restarbeiten, 12.06.2026) Stripe Tax
* berücksichtigt sie dann ohne erneute Eingabe im Checkout. Fehler
* (z. B. von Stripe abgelehnte ID) blockieren den Checkout nicht:
* Stripe validiert die im Checkout erfasste ID ohnehin selbst.
*/
private function syncTaxIdFromBillingAddress(User $user): void
{
$vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id));
if ($vatId === '') {
return;
}
$prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2);
if ($prefixCountry !== 'DE' && ! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
return;
}
try {
$user->createOrGetStripeCustomer();
$alreadySet = collect($user->taxIds())
->contains(fn (object $taxId): bool => strtoupper((string) $taxId->value) === $vatId);
if (! $alreadySet) {
$user->createTaxId('eu_vat', $vatId);
}
} catch (\Throwable $exception) {
Log::warning('USt-ID konnte nicht an Stripe übergeben werden.', [
'user_id' => $user->id,
'error' => $exception->getMessage(),
]);
}
}
/**
* Gemeinsame Session-Optionen: Stripe Tax braucht eine gültige
* Kundenadresse die im Checkout erfasste Rechnungsadresse (und der
* Name, Pflicht bei USt-ID-Abfrage) wird darum am Stripe-Customer
* gespeichert (`customer_update: auto`).
*
* @return array<string, mixed>
*/
private function sessionOptions(): array
{
return [
'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']),
'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']),
'billing_address_collection' => 'required',
'customer_update' => [
'address' => 'auto',
'name' => 'auto',
],
];
}
/**
* URL zum Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung).
* Rücksprung auf die Buchungs-Seite.
*/
public function billingPortalUrl(User $user): string
{
return $user->billingPortalUrl(route('me.bookings.index'));
}
/**
* Stripe-Checkout für eine Einzel-PM. Die `single_purchase_id` in den
* Session-Metadaten schließt den Kreis: `checkout.session.completed`
* markiert den Kauf über ProcessStripeWebhook als bezahlt.
*/
public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout
{
$this->syncTaxIdFromBillingAddress($user);
return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [
...$this->sessionOptions(),
'metadata' => ['single_purchase_id' => (string) $purchase->id],
]);
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace App\Services\Billing;
use App\Models\Plan;
use Laravel\Cashier\Cashier;
use Stripe\StripeClient;
/**
* Spiegelt Tarif-Änderungen aus der Admin-Verwaltung sofort nach Stripe.
*
* Stripe-Preise sind unveränderlich: Eine Preisänderung legt deshalb einen
* neuen Price an (netto, `tax_behavior: exclusive` wie beim Sync-Command),
* deaktiviert den alten für neue Buchungen und schreibt die neue ID in
* `plans` zurück. Bestandsabos behalten bewusst ihren bisherigen Preis
* Stripe rechnet laufende Subscriptions auf dem alten Price-Objekt weiter.
*/
class StripePlanSyncService
{
public function isConfigured(): bool
{
return (bool) config('cashier.secret');
}
/**
* Gleicht einen lokal gespeicherten Tarif mit Stripe ab. `$changes`
* sind die tatsächlich geänderten Attribute (`Model::getChanges()`),
* damit nur die nötigen Stripe-Aufrufe passieren.
*
* @param array<string, mixed> $changes
*/
public function syncAfterUpdate(Plan $plan, array $changes): void
{
if (! $this->isConfigured()) {
return;
}
$stripe = Cashier::stripe();
if (! $plan->stripe_product_id) {
$this->createProductWithPrices($stripe, $plan);
return;
}
if (array_key_exists('name', $changes)) {
$stripe->products->update($plan->stripe_product_id, ['name' => $plan->name]);
}
if (array_key_exists('monthly_price_cents', $changes)) {
$plan->forceFill([
'stripe_price_id_monthly' => $this->rotatePrice(
$stripe,
$plan,
$plan->stripe_price_id_monthly,
$plan->monthly_price_cents,
'month',
),
])->save();
}
if (array_key_exists('yearly_price_cents', $changes)) {
$plan->forceFill([
'stripe_price_id_yearly' => $this->rotatePrice(
$stripe,
$plan,
$plan->stripe_price_id_yearly,
$plan->yearly_price_cents,
'year',
),
])->save();
}
}
/**
* Erstanlage für Tarife ohne Stripe-Verknüpfung gleiche Struktur wie
* `billing:sync-stripe-plans`, nur direkt aus der Admin-Oberfläche.
*/
private function createProductWithPrices(StripeClient $stripe, Plan $plan): void
{
$product = $stripe->products->create([
'name' => $plan->name,
'metadata' => ['plan_slug' => $plan->slug],
]);
$plan->forceFill([
'stripe_product_id' => $product->id,
'stripe_price_id_monthly' => $this->createPrice($stripe, $product->id, $plan, $plan->monthly_price_cents, 'month'),
'stripe_price_id_yearly' => $this->createPrice($stripe, $product->id, $plan, $plan->yearly_price_cents, 'year'),
])->save();
}
/**
* Neuen Preis anlegen und den bisherigen (falls vorhanden) für neue
* Buchungen deaktivieren. Gibt die neue Price-ID zurück.
*/
private function rotatePrice(StripeClient $stripe, Plan $plan, ?string $oldPriceId, int $unitAmount, string $interval): string
{
$newPriceId = $this->createPrice($stripe, (string) $plan->stripe_product_id, $plan, $unitAmount, $interval);
if ($oldPriceId) {
$stripe->prices->update($oldPriceId, ['active' => false]);
}
return $newPriceId;
}
private function createPrice(StripeClient $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
{
$price = $stripe->prices->create([
'product' => $productId,
'currency' => strtolower($plan->currency),
'unit_amount' => $unitAmount,
'tax_behavior' => 'exclusive',
'recurring' => ['interval' => $interval],
'metadata' => ['plan_slug' => $plan->slug],
]);
return $price->id;
}
}

View file

@ -0,0 +1,148 @@
<?php
namespace App\Services\Billing;
use App\Enums\VatIdCheckStatus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Prüft USt-IDs in zwei Stufen (User-Panel-Restarbeiten, 12.06.2026):
*
* 1. **Formatprüfung** (immer): EU-Formatrahmen über den VatResolver,
* inkl. Abgleich mit dem Land der Rechnungsadresse.
* 2. **eVatR-Online-Abfrage** (wenn möglich): REST-API des BZSt
* (`POST /app/v1/abfrage`, Status `evatr-0000` = gültig). Die Abfrage
* setzt die eigene deutsche USt-ID des Betreibers voraus
* (`billing.own_vat_id`) und kann nur ausländische EU-IDs bestätigen
* deutsche IDs und Ausfälle der API führen zu `Unverified`, nie zu
* einem harten Fehler. Stripe validiert die im Checkout erfasste
* USt-ID zusätzlich selbst; diese Prüfung verbessert die Eingabe-UX
* und sichert den manuellen MAN-Rechnungskreis ab.
*/
class VatIdValidationService
{
private const EVATR_ENDPOINT = 'https://api.evatr.vies.bzst.de/app/v1/abfrage';
private const STATUS_VALID = 'evatr-0000';
public function __construct(private readonly VatResolver $vatResolver) {}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
public function check(string $vatId, ?string $billingCountryCode = null): array
{
$vatId = strtoupper((string) preg_replace('/\s+/', '', $vatId));
if ($vatId === '') {
return $this->result(VatIdCheckStatus::Unverified, __('Keine USt-ID angegeben.'));
}
$prefix = substr($vatId, 0, 2);
$prefixCountry = $prefix === 'EL' ? 'GR' : $prefix;
if (! $this->vatResolver->isPlausibleVatId($vatId, $prefixCountry)) {
return $this->result(
VatIdCheckStatus::FormatInvalid,
__('Das Format der USt-ID ist ungültig (erwartet: Ländercode + Kennziffer, z. B. DE123456789).'),
);
}
$billingCountryCode = strtoupper((string) $billingCountryCode);
if ($billingCountryCode !== '' && ! $this->vatResolver->isPlausibleVatId($vatId, $billingCountryCode)) {
return $this->result(
VatIdCheckStatus::FormatInvalid,
__('Die USt-ID passt nicht zum Land der Rechnungsadresse (:country).', ['country' => $billingCountryCode]),
);
}
if ($prefix === 'DE') {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Deutsche USt-IDs können über eVatR nicht online bestätigt werden.'),
);
}
if (! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Online-Bestätigung ist nur für EU-USt-IDs möglich.'),
);
}
$ownVatId = (string) config('billing.own_vat_id');
if ($ownVatId === '') {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Online-Prüfung ist nicht konfiguriert (BILLING_OWN_VAT_ID).'),
);
}
return $this->confirmViaEvatr($vatId, $ownVatId);
}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
private function confirmViaEvatr(string $vatId, string $ownVatId): array
{
$cacheKey = 'evatr-check:'.$vatId;
/** @var array{status: VatIdCheckStatus, message: string}|null $cached */
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
try {
$response = Http::timeout(8)
->acceptJson()
->post(self::EVATR_ENDPOINT, [
'anfragendeUstid' => $ownVatId,
'angefragteUstid' => $vatId,
]);
} catch (\Throwable $exception) {
Log::warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]);
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht erreichbar.'),
);
}
if (! $response->successful()) {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht verfügbar.'),
);
}
$status = (string) $response->json('status');
$result = $status === self::STATUS_VALID
? $this->result(VatIdCheckStatus::Valid, __('USt-ID per eVatR (BZSt) bestätigt — gültig.'))
: $this->result(
VatIdCheckStatus::Invalid,
__('Die USt-ID wurde von eVatR (BZSt) nicht bestätigt (Status :status).', ['status' => $status ?: 'unbekannt']),
);
// Nur definitive Ergebnisse cachen — Ausfälle der API sollen beim
// nächsten Versuch erneut geprüft werden.
Cache::put($cacheKey, $result, now()->addHours(6));
return $result;
}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
private function result(VatIdCheckStatus $status, string $message): array
{
return ['status' => $status, 'message' => $message];
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Services\Billing;
use App\Enums\VatTreatment;
/**
* Bestimmt die USt-Behandlung einer Rechnung anhand der Rechnungsadresse
* (Entscheidung 12.06.2026):
*
* - Deutschland grundsätzlich immer mit Steuer (`billing.vat_rate`)
* - EU-Ausland nur mit gültiger USt-ID befreit (Reverse Charge),
* sonst mit Steuer
* - Drittland grundsätzlich steuerbefreit
*
* „Gültig" heißt aktuell: USt-ID vorhanden und formal plausibel
* (Ländercode + Kennziffer). Eine echte VIES-Validierung ist als
* Folgeschritt vorgesehen (siehe Phase-9-Plan).
*/
class VatResolver
{
public function resolve(?string $countryCode, ?string $vatId = null): VatTreatment
{
$countryCode = strtoupper((string) $countryCode);
if ($countryCode === '' || $countryCode === 'DE') {
return VatTreatment::Domestic;
}
if (! in_array($countryCode, (array) config('billing.eu_country_codes', []), true)) {
return VatTreatment::ThirdCountry;
}
return $this->isPlausibleVatId($vatId, $countryCode)
? VatTreatment::ReverseCharge
: VatTreatment::EuConsumer;
}
public function rateFor(VatTreatment $treatment): float
{
return $treatment->isTaxExempt() ? 0.0 : (float) config('billing.vat_rate', 0.19);
}
public function taxCentsFor(int $netCents, VatTreatment $treatment): int
{
return (int) round($netCents * $this->rateFor($treatment));
}
/**
* Formale Plausibilität: beginnt mit dem Ländercode der Adresse und
* trägt danach 213 alphanumerische Zeichen (EU-Formatrahmen).
* Public, damit der VatIdValidationService dieselbe Definition nutzt.
*/
public function isPlausibleVatId(?string $vatId, string $countryCode): bool
{
$vatId = strtoupper(preg_replace('/\s+/', '', (string) $vatId) ?? '');
if ($vatId === '') {
return false;
}
// Griechenland nutzt das Präfix EL statt GR.
$expectedPrefix = $countryCode === 'GR' ? 'EL' : $countryCode;
return (bool) preg_match('/^'.preg_quote($expectedPrefix, '/').'[A-Z0-9]{2,13}$/', $vatId);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services\Customer;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
@ -78,6 +79,30 @@ class CustomerCompanyContext
->get();
}
/**
* @return Collection<int, Company>
*/
public function searchCompaniesFor(User $user, string $term = '', ?int $selectedCompanyId = null, int $limit = 10): Collection
{
$term = Portal::stripTrailingAbbreviation($term);
$limit = max(1, $limit);
if ($term === '') {
return $this->companyOptionsWithSelected($user, $selectedCompanyId, $limit);
}
return $this->companyOptionQuery($user)
->where(function (Builder $query) use ($term): void {
$query->where('companies.name', 'like', '%'.$term.'%')
->orWhere('companies.slug', 'like', '%'.$term.'%')
->orWhere('companies.email', 'like', '%'.$term.'%');
})
->orderBy('companies.name')
->orderBy('companies.id')
->limit($limit)
->get();
}
public function companyCountFor(User $user): int
{
return $this->accessibleCompanyQuery($user)->count();
@ -181,6 +206,41 @@ class CustomerCompanyContext
]);
}
/**
* @return Builder<Company>
*/
private function companyOptionQuery(User $user): Builder
{
return $this->accessibleCompanyQuery($user)
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at']);
}
/**
* @return Collection<int, Company>
*/
private function companyOptionsWithSelected(User $user, ?int $selectedCompanyId, int $limit): Collection
{
$selectedCompany = $selectedCompanyId
? $this->companyOptionQuery($user)->whereKey($selectedCompanyId)->first()
: null;
$companies = $this->companyOptionQuery($user)
->when($selectedCompany, fn (Builder $query): Builder => $query->whereKeyNot($selectedCompany->id))
->latest('companies.created_at')
->latest('companies.id')
->limit($selectedCompany ? max(0, $limit - 1) : $limit)
->get();
if (! $selectedCompany) {
return $companies;
}
return $companies
->prepend($selectedCompany)
->unique('id')
->values();
}
private function userCanAccessCompany(User $user, int $companyId): bool
{
return $this->accessibleCompanyQuery($user)

View file

@ -43,6 +43,8 @@ class ImageService
'thumb' => ['width' => 320, 'height' => 240],
'medium' => ['width' => 800, 'height' => 600],
'large' => ['width' => 1600, 'height' => 1200],
// Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px.
'cover' => ['width' => 1280, 'height' => 580],
];
public const ALLOWED_LOGO_MIME_TYPES = [
@ -60,7 +62,7 @@ class ImageService
public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB
public function __construct(private readonly string $disk = 'public') {}
@ -99,8 +101,9 @@ class ImageService
}
/**
* Persists a freshly uploaded press release image and generates all
* variants. Original is stored under `press-releases/{id}/images`.
* Persists a freshly uploaded press release image, generates all variants
* and discards the original upload. The canonical stored path points to
* the cover variant to keep storage usage predictable.
*
* @return array{
* path: string,
@ -122,9 +125,6 @@ class ImageService
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
$absolute = $disk->path($relativePath);
$size = @getimagesize($absolute) ?: [null, null];
$variants = $this->generateVariants(
$disk,
$relativePath,
@ -133,11 +133,19 @@ class ImageService
cover: true,
);
$coverPath = $variants['cover'] ?? $relativePath;
$coverAbsolute = $disk->path($coverPath);
$coverSize = @getimagesize($coverAbsolute) ?: [null, null];
if ($coverPath !== $relativePath && $disk->exists($relativePath)) {
$disk->delete($relativePath);
}
return [
'path' => $relativePath,
'path' => $coverPath,
'variants' => $variants,
'width' => is_int($size[0] ?? null) ? $size[0] : null,
'height' => is_int($size[1] ?? null) ? $size[1] : null,
'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null,
'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null,
'mime' => $upload->getMimeType(),
];
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* Submit-Gate (Decision-Update §5.1): Einreichen zur Prüfung erfordert eine
* aktive Buchung. Speichern als Entwurf bleibt immer frei.
*/
class BookingRequiredException extends RuntimeException
{
public function __construct(string $message = 'Für das Einreichen zur Prüfung wird eine aktive Buchung benötigt.')
{
parent::__construct($message);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Services\PressRelease\Classification;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
use App\Services\PressRelease\Classification\Drivers\DeterministicClassificationDriver;
use App\Services\PressRelease\Classification\Drivers\OpenAiClassificationDriver;
use Illuminate\Support\Manager;
/**
* Löst den aktiven Klassifikations-Treiber anhand von
* config('scoring.classification.provider') auf (Laravel-Manager-Pattern).
*
* Weitere Anbieter (Anthropic, Gemini) werden später als zusätzliche
* create*Driver()-Methoden ergänzt.
*/
class ClassificationManager extends Manager
{
public function getDefaultDriver(): string
{
return (string) ($this->config->get('scoring.classification.provider') ?: 'deterministic');
}
public function createDeterministicDriver(): ClassificationDriver
{
return $this->container->make(DeterministicClassificationDriver::class);
}
public function createOpenaiDriver(): ClassificationDriver
{
return $this->container->make(OpenAiClassificationDriver::class);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Services\PressRelease\Classification;
use App\Enums\PressReleaseClassification;
/**
* Ergebnis einer KI-Klassifikation (Red Flag, Konzept §15.1).
*
* Provider-neutral: jeder Treiber liefert dasselbe Schema zurück, damit der
* Job das Ergebnis einheitlich persistieren und auditieren kann.
*/
final class ClassificationResult
{
/**
* @param list<string> $reasons Begründungen der KI (kann leer sein)
* @param array<string, mixed> $rawResponse Roh-Antwort für das Audit-Log
*/
public function __construct(
public readonly PressReleaseClassification $classification,
public readonly array $reasons,
public readonly string $provider,
public readonly ?string $model,
public readonly array $rawResponse,
) {}
public function reasonText(): ?string
{
return $this->reasons === [] ? null : implode('; ', $this->reasons);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\PressRelease\Classification\Contracts;
use App\Models\PressRelease;
use App\Services\PressRelease\Classification\ClassificationResult;
/**
* Vertrag für austauschbare Klassifikations-Treiber (OpenAI, Anthropic,
* Gemini, deterministischer Fallback). Auswahl über config/scoring.php.
*/
interface ClassificationDriver
{
/**
* Klassifiziert eine Pressemitteilung (green/yellow/red).
*
* Wirft bei API-Fehlern/Timeouts eine Exception; der aufrufende Job
* weicht dann auf den deterministischen Fallback aus.
*/
public function classify(PressRelease $pressRelease): ClassificationResult;
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Services\PressRelease\Classification\Drivers;
use App\Enums\PressReleaseClassification;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistService;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
/**
* Regelbasierter Fallback ohne externe API.
*
* Greift, wenn kein KI-Anbieter konfiguriert ist oder die KI ausfällt
* (Timeout, Rate-Limit, Fehler). Bewertet ausschließlich über die
* Wort-Blacklist: Treffer Rot, sonst Grün. Liefert nie Gelb.
*/
class DeterministicClassificationDriver implements ClassificationDriver
{
public function __construct(private readonly BlacklistService $blacklist) {}
public function classify(PressRelease $pressRelease): ClassificationResult
{
$word = $this->blacklist->findInPressRelease($pressRelease);
if ($word !== null) {
return new ClassificationResult(
classification: PressReleaseClassification::Red,
reasons: [sprintf('Unzulässiges Wort gefunden: "%s".', $word)],
provider: 'deterministic',
model: null,
rawResponse: ['matched_word' => $word],
);
}
return new ClassificationResult(
classification: PressReleaseClassification::Green,
reasons: [],
provider: 'deterministic',
model: null,
rawResponse: ['matched_word' => null],
);
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace App\Services\PressRelease\Classification\Drivers;
use App\Enums\PressReleaseClassification;
use App\Models\PressRelease;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Klassifikations-Treiber auf Basis der OpenAI Chat-Completions-API.
*
* Liest Zugangsdaten/Modell aus config/services.openai. Verlangt eine
* strukturierte JSON-Antwort `{classification, reasons[]}`. Bei fehlendem
* Key, Transport-/HTTP-Fehlern oder ungültiger Antwort wird eine Exception
* geworfen, sodass der Job auf den deterministischen Treiber ausweicht.
*/
class OpenAiClassificationDriver implements ClassificationDriver
{
public function classify(PressRelease $pressRelease): ClassificationResult
{
$config = config('services.openai');
$apiKey = $config['api_key'] ?? null;
if (blank($apiKey)) {
throw new RuntimeException('OpenAI API-Key ist nicht konfiguriert.');
}
$model = config('scoring.classification.model') ?: ($config['model'] ?? 'gpt-5.4-mini');
$timeout = (int) (config('scoring.classification.timeout') ?: ($config['timeout'] ?? 30));
$response = Http::withToken($apiKey)
->timeout($timeout)
->acceptJson()
->post($config['url'] ?? 'https://api.openai.com/v1/chat/completions', [
'model' => $model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $this->systemPrompt()],
['role' => 'user', 'content' => $this->userPrompt($pressRelease)],
],
]);
if ($response->failed()) {
throw new RuntimeException("OpenAI-Klassifikation fehlgeschlagen (HTTP {$response->status()}).");
}
$payload = $response->json();
$content = data_get($payload, 'choices.0.message.content');
if (! is_string($content) || trim($content) === '') {
throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.');
}
$parsed = json_decode($content, true);
if (! is_array($parsed) || ! isset($parsed['classification'])) {
throw new RuntimeException('OpenAI-Antwort war kein gültiges Klassifikations-JSON.');
}
$classification = PressReleaseClassification::tryFrom((string) $parsed['classification']);
if ($classification === null) {
throw new RuntimeException('OpenAI lieferte einen unbekannten Klassifikationswert.');
}
$reasons = array_values(array_filter(array_map(
static fn ($reason): string => (string) $reason,
is_array($parsed['reasons'] ?? null) ? $parsed['reasons'] : [],
)));
return new ClassificationResult(
classification: $classification,
reasons: $reasons,
provider: 'openai',
model: $model,
rawResponse: is_array($payload) ? $payload : [],
);
}
private function systemPrompt(): string
{
return <<<'PROMPT'
Du bist ein redaktioneller Prüf-Assistent für ein deutsches Presseportal.
Bewerte, ob eine eingereichte Pressemitteilung veröffentlicht werden darf.
Klassifiziere genau eine der drei Stufen:
- "green": unauffällig, kann veröffentlicht werden.
- "yellow": grenzwertig/unklar, sollte manuell geprüft werden.
- "red": unzulässig, darf nicht veröffentlicht werden.
Prüfe insbesondere diese Faktoren (Red Flags):
- reine Werbung statt journalistischer Pressemitteilung
- beleidigende, diskriminierende oder hetzerische Inhalte
- rechtlich heikle Aussagen (z. B. unbelegte Heil-/Gewinnversprechen,
Verleumdung, Aufruf zu Straftaten)
- Spam-Muster (sinnlose Keyword-Wiederholung, irreführende Links)
- unseriöse oder offensichtlich falsche Versprechen
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
{"classification": "green|yellow|red", "reasons": ["kurze Begründung", ...]}
Bei "green" darf "reasons" leer sein. Schreibe Begründungen auf Deutsch.
PROMPT;
}
private function userPrompt(PressRelease $pressRelease): string
{
$title = (string) $pressRelease->title;
$text = trim(strip_tags((string) $pressRelease->text));
return "Titel:\n{$title}\n\nText:\n{$text}";
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Services\PressRelease\ContentScore;
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
use App\Services\PressRelease\ContentScore\Drivers\DeterministicContentScoreDriver;
use App\Services\PressRelease\ContentScore\Drivers\OpenAiContentScoreDriver;
use Illuminate\Support\Manager;
/**
* Löst den aktiven Content-Score-Treiber anhand von
* config('scoring.content_score.provider') auf (Laravel-Manager-Pattern).
*/
class ContentScoreManager extends Manager
{
public function getDefaultDriver(): string
{
return (string) ($this->config->get('scoring.content_score.provider') ?: 'deterministic');
}
public function createDeterministicDriver(): ContentScoreDriver
{
return $this->container->make(DeterministicContentScoreDriver::class);
}
public function createOpenaiDriver(): ContentScoreDriver
{
return $this->container->make(OpenAiContentScoreDriver::class);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Services\PressRelease\ContentScore;
/**
* Ergebnis einer Content-Score-Berechnung (Qualität, Konzept §15.2).
*
* Provider-neutral: jeder Treiber liefert einen 0100-Score plus optionalen
* Faktor-Breakdown, damit der Job einheitlich persistieren/auditieren kann.
*/
final class ContentScoreResult
{
/**
* @param int $score 0100
* @param array<string, mixed> $breakdown Faktor-Aufschlüsselung (optional)
* @param array<string, mixed> $rawResponse Roh-Antwort für das Audit-Log
*/
public function __construct(
public readonly int $score,
public readonly array $breakdown,
public readonly string $provider,
public readonly ?string $model,
public readonly array $rawResponse,
) {}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\PressRelease\ContentScore\Contracts;
use App\Models\PressRelease;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
/**
* Vertrag für austauschbare Content-Score-Treiber (OpenAI, später Anthropic/
* Gemini, deterministischer Fallback). Auswahl über config/scoring.php.
*/
interface ContentScoreDriver
{
/**
* Berechnet den Content-Score (0100) einer Pressemitteilung.
*
* Wirft bei API-Fehlern/Timeouts eine Exception; der aufrufende Job weicht
* dann auf den deterministischen Fallback aus.
*/
public function score(PressRelease $pressRelease): ContentScoreResult;
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Services\PressRelease\ContentScore\Drivers;
use App\Models\PressRelease;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
/**
* Regelbasierter Fallback ohne externe API.
*
* Greift, wenn kein KI-Anbieter konfiguriert ist oder die KI ausfällt. Bewertet
* messbare, formale Faktoren (Textlänge, Bild, Quelle, Headline, Vollständigkeit)
* zu einem groben 0100-Score. Ersetzt keine inhaltliche KI-Bewertung, hält den
* Funnel aber lauffähig.
*/
class DeterministicContentScoreDriver implements ContentScoreDriver
{
public function score(PressRelease $pressRelease): ContentScoreResult
{
$breakdown = [];
$score = 40; // Basiswert für eine formal vorhandene PM
$breakdown['base'] = 40;
$textLength = $pressRelease->plainTextLength();
$lengthPoints = match (true) {
$textLength >= 1500 => 20,
$textLength >= 800 => 12,
$textLength >= 300 => 6,
default => 0,
};
$score += $lengthPoints;
$breakdown['length'] = $lengthPoints;
$subtitlePoints = filled($pressRelease->subtitle) ? 6 : 0;
$score += $subtitlePoints;
$breakdown['subtitle'] = $subtitlePoints;
$imagePoints = $pressRelease->images()->count() > 0 ? 10 : 0;
$score += $imagePoints;
$breakdown['image'] = $imagePoints;
$sourcePoints = filled($pressRelease->backlink_url) ? 8 : 0;
$score += $sourcePoints;
$breakdown['source'] = $sourcePoints;
$titleLength = mb_strlen((string) $pressRelease->title);
$headlinePoints = ($titleLength >= 30 && $titleLength <= 90) ? 8 : 0;
$score += $headlinePoints;
$breakdown['headline'] = $headlinePoints;
$keywordPoints = filled($pressRelease->keywords) ? 4 : 0;
$score += $keywordPoints;
$breakdown['keywords'] = $keywordPoints;
$contactPoints = $pressRelease->contacts()->count() > 0 ? 4 : 0;
$score += $contactPoints;
$breakdown['contact'] = $contactPoints;
$score = max(0, min(100, $score));
return new ContentScoreResult(
score: $score,
breakdown: $breakdown,
provider: 'deterministic',
model: null,
rawResponse: ['breakdown' => $breakdown, 'text_length' => $textLength],
);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Services\PressRelease\ContentScore\Drivers;
use App\Models\PressRelease;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Content-Score-Treiber auf Basis der OpenAI Chat-Completions-API.
*
* Liest Zugangsdaten aus config/services.openai, Modell/Timeout aus
* config/scoring.content_score. Verlangt strukturiertes JSON
* `{score, breakdown}`. Bei fehlendem Key, HTTP-/Transportfehlern oder
* ungültiger Antwort wird eine Exception geworfen (Job Fallback).
*/
class OpenAiContentScoreDriver implements ContentScoreDriver
{
public function score(PressRelease $pressRelease): ContentScoreResult
{
$openai = config('services.openai');
$apiKey = $openai['api_key'] ?? null;
if (blank($apiKey)) {
throw new RuntimeException('OpenAI API-Key ist nicht konfiguriert.');
}
$model = config('scoring.content_score.model') ?: ($openai['model'] ?? 'gpt-5.4-mini');
$timeout = (int) (config('scoring.content_score.timeout') ?: 30);
$response = Http::withToken($apiKey)
->timeout($timeout)
->acceptJson()
->post($openai['url'] ?? 'https://api.openai.com/v1/chat/completions', [
'model' => $model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $this->systemPrompt()],
['role' => 'user', 'content' => $this->userPrompt($pressRelease)],
],
]);
if ($response->failed()) {
throw new RuntimeException("OpenAI-Content-Score fehlgeschlagen (HTTP {$response->status()}).");
}
$payload = $response->json();
$content = data_get($payload, 'choices.0.message.content');
if (! is_string($content) || trim($content) === '') {
throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.');
}
$parsed = json_decode($content, true);
if (! is_array($parsed) || ! isset($parsed['score']) || ! is_numeric($parsed['score'])) {
throw new RuntimeException('OpenAI-Antwort war kein gültiges Score-JSON.');
}
$score = (int) max(0, min(100, (int) round((float) $parsed['score'])));
$breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : [];
return new ContentScoreResult(
score: $score,
breakdown: $breakdown,
provider: 'openai',
model: $model,
rawResponse: is_array($payload) ? $payload : [],
);
}
private function systemPrompt(): string
{
return <<<'PROMPT'
Du bewertest die handwerkliche Qualität einer deutschen Pressemitteilung
auf einer Skala von 0 bis 100 (Content-Score). Bewerte ausschließlich die
Qualität, nicht die Zulässigkeit.
Berücksichtige diese gewichteten Kategorien:
- Pressestil (20%): informativ statt werblich, aktive Sprache, Zitate
- Struktur (15%): Lead-Absatz, sinnvolle Absätze, pyramidaler Aufbau
- Lesbarkeit (10%): Satzlängen, angemessene Fachsprache
- Vollständigkeit (15%): Pressekontakt, Unternehmensinfo, Datum, Region
- Bildmaterial (10%): Bild vorhanden/erwähnt
- Quellen/Belege (10%): Verlinkungen, Studien, Datenquellen
- Headline-Stärke (10%): Länge, Klarheit, Keyword-Relevanz
- Originalität (10%): kein Boilerplate, individueller Ton
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
{"score": 0-100, "breakdown": {"pressestil": 0-20, "struktur": 0-15,
"lesbarkeit": 0-10, "vollstaendigkeit": 0-15, "bild": 0-10,
"quellen": 0-10, "headline": 0-10, "originalitaet": 0-10}}
PROMPT;
}
private function userPrompt(PressRelease $pressRelease): string
{
$title = (string) $pressRelease->title;
$text = trim(strip_tags((string) $pressRelease->text));
$hasImage = $pressRelease->images()->count() > 0 ? 'ja' : 'nein';
$source = filled($pressRelease->backlink_url) ? $pressRelease->backlink_url : '—';
return "Titel:\n{$title}\n\nBild vorhanden: {$hasImage}\nQuelle/Link: {$source}\n\nText:\n{$text}";
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Services\PressRelease;
use App\Enums\PressReleasePlaceholder;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
/**
* Auflösung des Titelbildes (Hero/Thumb) einer Pressemitteilung.
*
* Liefert immer ein darstellbares Bild: entweder das echte Vorschaubild
* (bevorzugt das als `is_preview` markierte, sonst das erste) oder den
* deterministischen SVG-Platzhalter aus dem Set.
*/
class PressReleaseCoverImage
{
/**
* Liefert die URL zum Titelbild. Fällt auf den SVG-Platzhalter zurück.
*
* Die Variante wird über eine Fallback-Kette aufgelöst, damit auch
* Altbestände ohne `cover`-Variante (1280x580) sauber rendern.
*
* @param string $variant Bevorzugte Bild-Variante (cover|large|medium|thumb).
*/
public function coverUrl(PressRelease $pressRelease, string $variant = 'cover'): string
{
$image = $this->coverImage($pressRelease);
if (! $image) {
return $this->placeholderUrl($pressRelease);
}
foreach ($this->fallbackChain($variant) as $key) {
$url = $image->variantUrl($key);
if ($url !== null) {
return $url;
}
}
return $image->url() ?? $this->placeholderUrl($pressRelease);
}
/**
* Fallback-Reihenfolge der Bild-Varianten, beginnend bei der gewünschten.
*
* @return list<string>
*/
private function fallbackChain(string $preferred): array
{
$chain = [$preferred, 'cover', 'large', 'medium', 'thumb'];
return array_values(array_unique($chain));
}
/**
* Ob für diese PM nur ein Platzhalter (kein echtes Bild) vorliegt.
*/
public function coverIsPlaceholder(PressRelease $pressRelease): bool
{
return $this->coverImage($pressRelease) === null;
}
/**
* Die Platzhalter-Variante dieser PM (mit Default-Fallback).
*/
public function placeholder(PressRelease $pressRelease): PressReleasePlaceholder
{
$variant = $pressRelease->placeholder_variant;
if ($variant instanceof PressReleasePlaceholder) {
return $variant;
}
return PressReleasePlaceholder::fromValueOrDefault($variant);
}
private function placeholderUrl(PressRelease $pressRelease): string
{
return asset($this->placeholder($pressRelease)->path());
}
private function coverImage(PressRelease $pressRelease): ?PressReleaseImage
{
$images = $pressRelease->relationLoaded('images')
? $pressRelease->images
: $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->get();
return $images->firstWhere('is_preview', true) ?? $images->first();
}
}

View file

@ -17,6 +17,8 @@ class PressReleaseHtmlSanitizer
{
private const string PURIFIER_PROFILE = 'press_release';
public function __construct(private readonly PressReleaseLinkPolicy $linkPolicy) {}
/**
* Sanitize HTML before persisting to the database.
*/
@ -44,6 +46,10 @@ class PressReleaseHtmlSanitizer
/**
* Produce a display-ready, safe HtmlString.
*
* Die Link-Policy (rel systemseitig: extern sponsored/nofollow,
* portalintern follow) greift hier beim Rendern so wirken
* Regel-Änderungen rückwirkend auf alle gespeicherten Inhalte.
*/
public function render(?string $text): HtmlString
{
@ -52,7 +58,7 @@ class PressReleaseHtmlSanitizer
}
if ($this->isHtml($text)) {
return new HtmlString($this->clean($text));
return new HtmlString($this->linkPolicy->apply($this->clean($text)));
}
$escaped = e($text);

View file

@ -0,0 +1,132 @@
<?php
namespace App\Services\PressRelease;
use DOMDocument;
use DOMElement;
use Illuminate\Support\Str;
/**
* Systemseitige `rel`-Auszeichnung für Links in Pressemitteilungen
* (Decision-Update „Verlinkung & Backlinks", 11.06.2026):
*
* - **Externe Links** (Kundenseite, Landingpage, Social)
* `rel="sponsored nofollow noopener"` + `target="_blank"`.
* Google-konform: vom Kunden gesetzte Links sind keine redaktionelle
* Empfehlung des Portals.
* - **Portalinterne Links** (Unternehmensprofil/Newsroom auf den eigenen
* Domains, relative Pfade) **follow** (kein `rel`) redaktionelle
* interne Verlinkung stärkt die eigene Domain-Architektur.
* - `mailto:`/`tel:` kein `rel`, kein `target`.
*
* Die Auszeichnung ist bewusst NICHT kundenseitig wählbar: vom Autor
* mitgelieferte `rel`-Attribute werden grundsätzlich überschrieben.
* Die Policy greift beim Rendern Änderungen wirken damit rückwirkend
* auf alle gespeicherten Inhalte.
*/
class PressReleaseLinkPolicy
{
public function apply(string $html): string
{
if (trim($html) === '' || ! str_contains($html, '<a')) {
return $html;
}
$document = new DOMDocument;
// Wrapper + XML-Prolog: UTF-8 erzwingen und Fragment-Struktur
// erhalten (loadHTML ergänzt sonst html/body selbst).
$loaded = @$document->loadHTML(
'<?xml encoding="utf-8" ?><div id="pr-link-policy-root">'.$html.'</div>',
LIBXML_NOERROR | LIBXML_NOWARNING,
);
if (! $loaded) {
return $html;
}
foreach ($document->getElementsByTagName('a') as $anchor) {
$this->applyToAnchor($anchor);
}
$root = $document->getElementById('pr-link-policy-root');
if (! $root) {
return $html;
}
$result = '';
foreach ($root->childNodes as $child) {
$result .= $document->saveHTML($child);
}
return $result;
}
private function applyToAnchor(DOMElement $anchor): void
{
$href = trim($anchor->getAttribute('href'));
// rel ist systemgesteuert — Autoren-Eingaben zählen nie.
$anchor->removeAttribute('rel');
if ($href === '' || Str::startsWith($href, ['mailto:', 'tel:', '#'])) {
$anchor->removeAttribute('target');
return;
}
if ($this->isInternal($href)) {
$anchor->removeAttribute('target');
return;
}
$anchor->setAttribute('rel', 'sponsored nofollow noopener');
$anchor->setAttribute('target', '_blank');
}
/**
* Intern = relative Pfade sowie absolute URLs auf eine der
* konfigurierten Portal-Domains (inkl. www-Variante).
*/
private function isInternal(string $href): bool
{
if (! preg_match('#^https?://#i', $href)) {
// Relative Pfade (/firma/...) — kein Protokoll, keine Domain.
return ! Str::startsWith($href, '//');
}
$host = strtolower((string) parse_url($href, PHP_URL_HOST));
if ($host === '') {
return false;
}
$internalHosts = $this->internalHosts();
return in_array($host, $internalHosts, true)
|| in_array(Str::after($host, 'www.'), $internalHosts, true);
}
/**
* @return list<string>
*/
private function internalHosts(): array
{
$hosts = [];
foreach ((array) config('domains.domains', []) as $domain) {
foreach ([$domain['domain_name'] ?? null, parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST)] as $candidate) {
if (is_string($candidate) && $candidate !== '') {
$host = strtolower($candidate);
$hosts[] = $host;
$hosts[] = Str::after($host, 'www.');
}
}
}
return array_values(array_unique($hosts));
}
}

View file

@ -2,7 +2,11 @@
namespace App\Services\PressRelease;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
@ -29,6 +33,25 @@ class PressReleaseService
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]);
$user = $pressRelease->user;
// Submit-Gate (Decision-Update §5.1): Einreichen erfordert eine aktive
// Buchung. Bis zum Tarif-Modul steuert billing.enforce_booking den Stub.
if ($user && ! $user->hasActiveBooking()) {
throw new BookingRequiredException;
}
// Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht
// (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn
// noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne
// verfügbares Kontingent automatisch veröffentlicht. Null bedeutet
// unbegrenzt (Bestandsschutz bzw. Gate noch nicht scharf).
$quotaRemaining = $user?->pressReleaseQuotaRemaining();
if ($user && $quotaRemaining !== null && $quotaRemaining <= 0) {
throw new QuotaExceededException;
}
$previous = $pressRelease->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
@ -43,9 +66,96 @@ class PressReleaseService
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
// des Ergebnisses übernimmt der Job über routeByClassification().
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
}
public function publish(PressRelease $pressRelease, string $source = 'admin'): void
/**
* Stößt eine erneute KI-Klassifikation an, wenn die PM bereits einmal
* klassifiziert wurde (Konzept §15.1: „Bei Änderung wird neu klassifiziert").
*
* Läuft als Re-Check **ohne Routing**: Bewertung + Audit werden
* aktualisiert, der Status bleibt unverändert. So führt das bloße
* Bearbeiten nie zu einer überraschenden automatischen Veröffentlichung
* oder Ablehnung die Entscheidung bleibt beim regulären Workflow/Admin.
*/
public function reclassifyIfClassified(PressRelease $pressRelease): void
{
if ($pressRelease->classification === null) {
return;
}
ClassifyPressRelease::dispatch($pressRelease->id, route: false)->onQueue('classification');
}
/**
* Stößt eine erneute Content-Score-Berechnung an, wenn die PM bereits
* einmal bewertet wurde (Konzept §15.2: „bei jeder Änderung neu berechnet").
*/
public function rescoreIfScored(PressRelease $pressRelease): void
{
if ($pressRelease->content_score === null) {
return;
}
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
}
/**
* Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses
* (Decision-Update §5.0, Entscheidung 12.06.2026). Wird vom
* ClassifyPressRelease-Job aufgerufen.
*
* - Rot Ablehnung mit Begründung an den Autor
* - Gelb/Grün automatische Veröffentlichung (sofort bzw. zum Termin);
* Gelb bleibt als interne Markierung erhalten (nicht
* boostbar, Admin-Signal), löst aber keine manuelle
* Prüfung aus
*
* Greift nur, solange die PM noch im Status `review` steht; manuelle
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
*/
public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void
{
if ($pressRelease->status !== PressReleaseStatus::Review) {
return;
}
if ($classification === PressReleaseClassification::Red) {
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki');
return;
}
$this->autoPublishApproved($pressRelease);
}
/**
* Veröffentlicht eine als Gelb oder Grün klassifizierte PM automatisch.
*
* Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der
* Scheduler die Publikation zum Termin. Andernfalls wird sofort
* publiziert optional mit einem Sicherheitsfenster
* (scoring.classification.green_delay_minutes).
*/
private function autoPublishApproved(PressRelease $pressRelease): void
{
if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) {
return;
}
$delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0);
$publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null;
$this->publish($pressRelease, 'ki', $publishedAtOverride);
}
public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
@ -61,15 +171,65 @@ class PressReleaseService
throw new BlacklistViolationException($reason, $word);
}
$this->consumePublishSlot($pressRelease);
$pressRelease->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($pressRelease),
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
$this->notifyAuthor($pressRelease, 'published');
}
/**
* Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers
* (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte
* PMs kosten nichts). Erneutes Publizieren etwa nach Archivierung
* zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem
* Schreiben des neuen Status-Logs aufgerufen werden.
*
* Verbrauchsreihenfolge: zuerst das Plan-Monatskontingent (Zähler
* `press_release_quota_used_this_month`), danach der älteste bezahlte
* Einzel-/Extra-PM-Kauf (wird mit der PM verknüpft und eingelöst).
* Unbegrenzte User (Bestandsschutz) verbrauchen nichts.
*/
private function consumePublishSlot(PressRelease $pressRelease): void
{
$alreadyPublishedOnce = PressReleaseStatusLog::query()
->where('press_release_id', $pressRelease->id)
->where('to_status', PressReleaseStatus::Published->value)
->exists();
if ($alreadyPublishedOnce) {
return;
}
$user = $pressRelease->user;
if (! $user || $user->hasUnlimitedPressReleaseQuota()) {
return;
}
$plan = $user->currentPlan();
if ($plan && (int) $user->press_release_quota_used_this_month < $plan->press_release_quota) {
$user->increment('press_release_quota_used_this_month');
return;
}
$user->singlePurchases()
->grantingSubmission()
->oldest('paid_at')
->first()
?->update([
'status' => SinglePurchaseStatus::Consumed->value,
'consumed_at' => now(),
'press_release_id' => $pressRelease->id,
]);
}
/**
* Bestimmt das wirksame `published_at` einer PM.
*
@ -83,14 +243,18 @@ class PressReleaseService
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
* öffentlichen Listing.
*
* `$override` setzt einen abweichenden Sofort-Zeitpunkt (z.B. das
* Grün-Sicherheitsfenster) und wirkt nur, wenn kein `scheduled_at` gesetzt
* ist ein geplanter Termin hat stets Vorrang.
*/
private function resolvePublishedAt(PressRelease $pressRelease): Carbon
private function resolvePublishedAt(PressRelease $pressRelease, ?Carbon $override = null): Carbon
{
if ($pressRelease->published_at) {
return $pressRelease->published_at;
}
$base = $pressRelease->scheduled_at ?: now();
$base = $pressRelease->scheduled_at ?: ($override ?? now());
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
return $pressRelease->embargo_at;
@ -99,7 +263,7 @@ class PressReleaseService
return $base;
}
public function reject(PressRelease $pressRelease, ?string $reason = null): void
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
@ -107,7 +271,7 @@ class PressReleaseService
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'admin');
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source);
$this->notifyAuthor($pressRelease, 'rejected', $reason);
}
@ -135,6 +299,10 @@ class PressReleaseService
{
$previous = $pressRelease->status;
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
$this->consumePublishSlot($pressRelease);
}
$pressRelease->update([
'status' => $status->value,
'published_at' => $status === PressReleaseStatus::Published

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* Das monatliche PM-Kontingent des Autors ist aufgebraucht Einreichen ist
* erst nach Reset/Upgrade (später: Extra-PM) wieder möglich.
*/
class QuotaExceededException extends RuntimeException
{
public function __construct(string $message = 'Das monatliche PM-Kontingent ist aufgebraucht.')
{
parent::__construct($message);
}
}

View file

@ -0,0 +1,145 @@
<?php
namespace App\Support;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
class DomainAssetContext
{
/**
* @param array<string, mixed> $defaults
* @param array<string, array<string, mixed>> $configuredDomains
* @return array<string, mixed>
*/
public static function resolve(
string $host,
array $defaults,
array $configuredDomains,
?string $themeOverride = null,
?Request $request = null,
): array {
if ($themeOverride !== null && isset($configuredDomains[$themeOverride])) {
return array_merge($defaults, $configuredDomains[$themeOverride]);
}
$portalHost = (string) config('domains.domain_portal');
if ($host === $portalHost) {
$request ??= request();
if (self::isBackendRequest($request)) {
return array_merge($defaults, $configuredDomains['portal'] ?? $defaults);
}
return array_merge($defaults, $configuredDomains['pressekonto'] ?? $defaults);
}
foreach ($configuredDomains as $config) {
if (! is_array($config)) {
continue;
}
if (($config['domain_name'] ?? null) === $host) {
return array_merge($defaults, $config);
}
}
return $defaults;
}
public static function isBackendRequest(Request $request): bool
{
if ($request->routeIs(
'dashboard',
'login',
'register',
'password.*',
'verification.*',
'two-factor.*',
'magic-links.*',
'me.*',
'settings.*',
'press-releases.preview',
)) {
return true;
}
$backendPrefixes = [
'admin',
'customer',
'dashboard',
'login',
'register',
'forgot-password',
'reset-password',
'magic-login',
'user',
'settings',
'flux',
'livewire',
];
$path = trim($request->path(), '/');
foreach ($backendPrefixes as $prefix) {
if ($path === $prefix || str_starts_with($path, "{$prefix}/")) {
return true;
}
}
return false;
}
public static function usesWebAssets(string $assetsDir): bool
{
return $assetsDir === 'build/web';
}
public static function hotFilePath(string $assetsDir): string
{
return self::usesWebAssets($assetsDir)
? public_path('hot-web')
: public_path('hot-portal');
}
/**
* @param array<string, mixed> $domainConfig
*/
public static function configureVite(array $domainConfig): void
{
$assetsDir = (string) ($domainConfig['assets_dir'] ?? 'build/portal');
Vite::useBuildDirectory($assetsDir);
Vite::useHotFile(self::hotFilePath($assetsDir));
}
/**
* @param array<string, mixed> $domainConfig
*/
public static function staticAssetOrigin(array $domainConfig): string
{
return (string) ($domainConfig['url'] ?? config('app.url'));
}
/**
* @param array<string, mixed> $domainConfig
*/
public static function viteDevServerUrl(array $domainConfig): string
{
if (isset($domainConfig['asset_url'])) {
return (string) $domainConfig['asset_url'];
}
$assetsDir = (string) ($domainConfig['assets_dir'] ?? 'build/portal');
return self::usesWebAssets($assetsDir)
? 'https://assets.pressekonto.test'
: 'https://assets.pressekonto.test';
}
public static function isViteDevServerRunning(string $assetsDir): bool
{
return is_file(self::hotFilePath($assetsDir));
}
}

View file

@ -11,6 +11,7 @@
"require": {
"php": "^8.2",
"blade-ui-kit/blade-heroicons": "^2.6",
"laravel/cashier": "^16.5",
"laravel/fortify": "^1.27",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.1",

328
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cbc29fc1cf64ca319c7c0ef7e0c1088c",
"content-hash": "7ad3d072c1669ef5d37e58ba10187b58",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -1369,6 +1369,95 @@
],
"time": "2025-08-22T14:27:06+00:00"
},
{
"name": "laravel/cashier",
"version": "v16.5.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/cashier-stripe.git",
"reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/b6bcd6b4d79acead34d00a5a528c904d67c5e08a",
"reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
"illuminate/http": "^10.0|^11.0|^12.0|^13.0",
"illuminate/log": "^10.0|^11.0|^12.0|^13.0",
"illuminate/notifications": "^10.0|^11.0|^12.0|^13.0",
"illuminate/pagination": "^10.0|^11.0|^12.0|^13.0",
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"illuminate/view": "^10.0|^11.0|^12.0|^13.0",
"moneyphp/money": "^4.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.1",
"stripe/stripe-php": "^17.3.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-kernel": "^6.0|^7.0|^8.0",
"symfony/polyfill-intl-icu": "^1.22.1",
"symfony/polyfill-php84": "^1.32"
},
"require-dev": {
"dompdf/dompdf": "^2.0|^3.0",
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10",
"spatie/laravel-ray": "^1.40"
},
"suggest": {
"dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).",
"ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.",
"spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Cashier\\CashierServiceProvider"
]
},
"branch-alias": {
"dev-master": "16.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Cashier\\": "src/",
"Laravel\\Cashier\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Dries Vints",
"email": "dries@laravel.com"
}
],
"description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.",
"keywords": [
"billing",
"laravel",
"stripe"
],
"support": {
"issues": "https://github.com/laravel/cashier/issues",
"source": "https://github.com/laravel/cashier"
},
"time": "2026-05-05T21:18:35+00:00"
},
{
"name": "laravel/fortify",
"version": "v1.36.2",
@ -2826,6 +2915,96 @@
},
"time": "2026-04-15T16:41:08+00:00"
},
{
"name": "moneyphp/money",
"version": "v4.9.0",
"source": {
"type": "git",
"url": "https://github.com/moneyphp/money.git",
"reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b",
"reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b",
"shasum": ""
},
"require": {
"ext-bcmath": "*",
"ext-filter": "*",
"ext-json": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cache/taggable-cache": "^1.1.0",
"doctrine/coding-standard": "^12.0",
"doctrine/instantiator": "^1.5.0 || ^2.0",
"ext-gmp": "*",
"ext-intl": "*",
"florianv/exchanger": "^2.8.1",
"florianv/swap": "^4.3.0",
"moneyphp/crypto-currencies": "^1.1.0",
"moneyphp/iso-currencies": "^3.4",
"php-http/message": "^1.16.0",
"php-http/mock-client": "^1.6.0",
"phpbench/phpbench": "^1.2.5",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1.9",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5.9",
"psr/cache": "^1.0.1 || ^2.0 || ^3.0",
"ticketswap/phpstan-error-formatter": "^1.1"
},
"suggest": {
"ext-gmp": "Calculate without integer limits",
"ext-intl": "Format Money objects with intl",
"florianv/exchanger": "Exchange rates library for PHP",
"florianv/swap": "Exchange rates library for PHP",
"psr/cache-implementation": "Used for Currency caching"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Money\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mathias Verraes",
"email": "mathias@verraes.net",
"homepage": "http://verraes.net"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
},
{
"name": "Frederik Bosch",
"email": "f.bosch@genkgo.nl"
}
],
"description": "PHP implementation of Fowler's Money pattern",
"homepage": "http://moneyphp.org",
"keywords": [
"Value Object",
"money",
"vo"
],
"support": {
"issues": "https://github.com/moneyphp/money/issues",
"source": "https://github.com/moneyphp/money/tree/v4.9.0"
},
"time": "2026-05-04T20:23:15+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@ -4306,6 +4485,65 @@
],
"time": "2026-03-17T22:46:46+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v17.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
"time": "2025-08-27T19:32:42+00:00"
},
{
"name": "symfony/clock",
"version": "v8.0.8",
@ -5467,6 +5705,94 @@
],
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "445c90e341fccda10311019cf82ff73bb7343945"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945",
"reference": "445c90e341fccda10311019cf82ff73bb7343945",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Icu\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's ICU-related data and classes",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"icu",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-25T11:52:53+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.36.0",

View file

@ -1,5 +1,7 @@
<?php
use App\Models\User;
return [
/*
@ -62,7 +64,7 @@ return [
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [

79
config/billing.php Normal file
View file

@ -0,0 +1,79 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Submit-Gate (Decision-Update §5.1)
|--------------------------------------------------------------------------
|
| "Speichern" ist immer frei; "Speichern & zur Prüfung einreichen" ist
| hinter eine aktive Buchung gegated. Bis das Tarif-Modul (Phase 9D/9E)
| die echte Buchungs-Prüfung liefert, bleibt das Gate deaktiviert
| User::hasActiveBooking() gibt dann für alle true zurück.
|
*/
'enforce_booking' => env('BILLING_ENFORCE_BOOKING', false),
/*
|--------------------------------------------------------------------------
| Hybride Rechnungskreise
|--------------------------------------------------------------------------
|
| Alle neuen Abschlüsse laufen über Stripe und erhalten fortlaufende
| Nummern im STR-Kreis. Laufende Legacy-Zahlungen werden ab Relaunch im
| eigenen MAN-Kreis weiter per Rechnung abgerechnet (Fälligkeitsprüfung
| via `billing:generate-manual-invoices`). Die Alt-Rechnungen aus den
| Ursprungsportalen bleiben unverändert in `legacy_invoices`.
|
*/
'invoice_number_padding' => 5,
// Zahlungsziel für Rechnungen des manuellen Kreises (Tage).
'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14),
/*
|--------------------------------------------------------------------------
| Einzel-Pressemitteilung (Pay-per-Release)
|--------------------------------------------------------------------------
|
| Netto-Preis laut Decision-Update. Die Stripe-Price-ID wird einmalig
| von `billing:sync-stripe-plans` angelegt und hier per ENV verdrahtet
| ohne sie ist der Einzel-PM-Checkout deaktiviert.
|
*/
'single_pm_price_cents' => 1900,
'single_pm_stripe_price_id' => env('STRIPE_PRICE_SINGLE_PM'),
/*
|--------------------------------------------------------------------------
| USt-Behandlung (Entscheidung 12.06.2026)
|--------------------------------------------------------------------------
|
| Alle neuen Preise sind NETTO. Die Steuer wird zur Rechnungsstellung
| anhand der Rechnungsadresse bestimmt (VatResolver): Deutschland immer
| mit Steuer, EU-Ausland nur mit gültiger USt-ID befreit (Reverse
| Charge), Drittländer grundsätzlich befreit.
|
*/
'vat_rate' => env('BILLING_VAT_RATE', 0.19),
// Eigene deutsche USt-ID des Betreibers — Pflichtangabe für die
// eVatR-Online-Bestätigung ausländischer EU-USt-IDs (BZSt-REST-API).
// Ohne sie bleibt die USt-ID-Prüfung eine reine Formatprüfung.
'own_vat_id' => env('BILLING_OWN_VAT_ID'),
// EU-Mitgliedstaaten (ISO 3166-1 alpha-2), Stand 2026 — ohne DE,
// das im VatResolver als Inland behandelt wird.
'eu_country_codes' => [
'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR',
'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL',
'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
],
];

130
config/cashier.php Normal file
View file

@ -0,0 +1,130 @@
<?php
use Laravel\Cashier\Console\WebhookCommand;
use Laravel\Cashier\Invoices\DompdfInvoiceRenderer;
// use Laravel\Cashier\Invoices\LaravelPdfInvoiceRenderer;
return [
/*
|--------------------------------------------------------------------------
| Stripe Keys
|--------------------------------------------------------------------------
|
| The Stripe publishable key and secret key give you access to Stripe's
| API. The "publishable" key is typically used when interacting with
| Stripe.js while the "secret" key accesses private API endpoints.
|
*/
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
/*
|--------------------------------------------------------------------------
| Cashier Path
|--------------------------------------------------------------------------
|
| This is the base URI path where Cashier's views, such as the payment
| verification screen, will be available from. You're free to tweak
| this path according to your preferences and application design.
|
*/
'path' => env('CASHIER_PATH', 'stripe'),
/*
|--------------------------------------------------------------------------
| Stripe Webhooks
|--------------------------------------------------------------------------
|
| Your Stripe webhook secret is used to prevent unauthorized requests to
| your Stripe webhook handling controllers. The tolerance setting will
| check the drift between the current time and the signed request's.
|
*/
'webhook' => [
'secret' => env('STRIPE_WEBHOOK_SECRET'),
'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300),
'events' => WebhookCommand::DEFAULT_EVENTS,
],
/*
|--------------------------------------------------------------------------
| Currency
|--------------------------------------------------------------------------
|
| This is the default currency that will be used when generating charges
| from your application. Of course, you are welcome to use any of the
| various world currencies that are currently supported via Stripe.
|
*/
'currency' => env('CASHIER_CURRENCY', 'usd'),
/*
|--------------------------------------------------------------------------
| Currency Locale
|--------------------------------------------------------------------------
|
| This is the default locale in which your money values are formatted in
| for display. To utilize other locales besides the default en locale
| verify you have the "intl" PHP extension installed on the system.
|
*/
'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
| Payment Confirmation Notification
|--------------------------------------------------------------------------
|
| If this setting is enabled, Cashier will automatically notify customers
| whose payments require additional verification. You should listen to
| Stripe's webhooks in order for this feature to function correctly.
|
*/
'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'),
/*
|--------------------------------------------------------------------------
| Invoice Settings
|--------------------------------------------------------------------------
|
| The following options determine how Cashier invoices are converted from
| HTML into PDFs. You're free to change the options based on the needs
| of your application or your preferences regarding invoice styling.
|
*/
'invoices' => [
// Supported: DompdfInvoiceRenderer::class, LaravelPdfInvoiceRenderer::class
'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class),
'options' => [
// Supported: 'letter', 'legal', 'A4'
'paper' => env('CASHIER_PAPER', 'letter'),
'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false),
],
],
/*
|--------------------------------------------------------------------------
| Stripe Logger
|--------------------------------------------------------------------------
|
| This setting defines which logging channel will be used by the Stripe
| library to write log messages. You are free to specify any of your
| logging channels listed inside the "logging" configuration file.
|
*/
'logger' => env('CASHIER_LOGGER'),
];

View file

@ -26,10 +26,10 @@ return [
'domain_name' => env('APP_PORTAL_NAME', 'pressekonto.test'),
'url' => env('APP_PORTAL_URL', 'https://pressekonto.test'),
'asset_url' => env('APP_PORTAL_ASSET_URL', 'https://assets.pressekonto.test'),
'theme' => 'main',
'theme' => 'portal',
'view_prefix' => 'portal',
'assets_dir' => 'build/portal',
'description' => 'Backend Pressekonto',
'description' => 'Backend/Admin auf pressekonto.test',
'color_scheme' => [
'primary' => env('APP_PORTAL_PRIMARY', '#526266'), //
'secondary' => env('APP_PORTAL_SECONDARY', '#82a0a7'), //

View file

@ -1,5 +1,7 @@
<?php
use Illuminate\Database\Eloquent\Model;
return [
/*
@ -52,7 +54,7 @@ return [
|
*/
'parent' => Illuminate\Database\Eloquent\Model::class,
'parent' => Model::class,
/*
|--------------------------------------------------------------------------
@ -522,7 +524,7 @@ return [
'path' => app_path('Models/Legacy/Presseecho'),
'namespace' => 'App\\Models\\Legacy\\Presseecho',
'connection' => true,
'parent' => Illuminate\Database\Eloquent\Model::class,
'parent' => Model::class,
'timestamps' => true,
'soft_deletes' => true,
'snake_attributes' => true,
@ -549,7 +551,7 @@ return [
'path' => app_path('Models/Legacy/Presseecho'),
'namespace' => 'App\\Models\\Legacy\\Presseecho',
'connection' => true,
'parent' => Illuminate\Database\Eloquent\Model::class,
'parent' => Model::class,
'timestamps' => true,
'soft_deletes' => true,
'snake_attributes' => true,
@ -575,7 +577,7 @@ return [
'path' => app_path('Models/Legacy/Businessportal'),
'namespace' => 'App\\Models\\Legacy\\Businessportal',
'connection' => true,
'parent' => Illuminate\Database\Eloquent\Model::class,
'parent' => Model::class,
'timestamps' => true,
'soft_deletes' => true,
'snake_attributes' => true,

View file

@ -1,5 +1,9 @@
<?php
use Spatie\Permission\DefaultTeamResolver;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return [
'models' => [
@ -13,7 +17,7 @@ return [
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Spatie\Permission\Models\Permission::class,
'permission' => Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
@ -24,7 +28,7 @@ return [
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Spatie\Permission\Models\Role::class,
'role' => Role::class,
],
@ -136,7 +140,7 @@ return [
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
'team_resolver' => DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
@ -183,7 +187,7 @@ return [
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
'expiration_time' => DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.

View file

@ -1,5 +1,8 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
@ -76,9 +79,9 @@ return [
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

71
config/scoring.php Normal file
View file

@ -0,0 +1,71 @@
<?php
/*
|--------------------------------------------------------------------------
| KI-Prüfung & Scoring
|--------------------------------------------------------------------------
|
| Zentrale, ohne Code-Änderung kalibrierbare Schwellen und Verhaltens-Flags
| für die automatisierte Prüfung von Pressemitteilungen (Konzept §15).
|
| Phase 2 legt die Konfiguration an; die Werte werden ab Phase 3
| (Klassifikation) bzw. Phase 4 (Routing) wirksam.
|
*/
return [
/*
|--------------------------------------------------------------------------
| Klassifikation (Red Flag, §15.1)
|--------------------------------------------------------------------------
|
| Aktiver Anbieter und Modell für den Klassifikations-Gate. Die konkreten
| Treiber (anthropic|gemini|openai|deterministic) folgen in Phase 3.
|
*/
'classification' => [
// Aktiver Treiber: openai|deterministic (Anthropic/Gemini folgen).
// Fällt der Anbieter aus (kein Key, Timeout, Fehler), greift im Job
// automatisch der deterministische Treiber.
'provider' => env('CLASSIFICATION_PROVIDER', 'openai'),
// Optional ein abweichendes Modell; leer => config('services.openai.model').
'model' => env('CLASSIFICATION_MODEL'),
// Sekunden, bevor auf den deterministischen Fallback-Treiber
// ausgewichen wird (Timeout/Rate-Limit/Ausfall).
'timeout' => (int) env('CLASSIFICATION_TIMEOUT', 15),
// Verzögerung in Minuten für „grün" eingestufte PMs als
// Sicherheitsfenster vor der automatischen Veröffentlichung
// (Konzept-Option, 0 = sofort).
'green_delay_minutes' => (int) env('CLASSIFICATION_GREEN_DELAY', 0),
// Ob „gelb" eingestufte PMs in die manuelle Admin-Queue gehen.
'yellow_to_manual_queue' => (bool) env('CLASSIFICATION_YELLOW_MANUAL', true),
],
/*
|--------------------------------------------------------------------------
| Content-Score (Qualitätsbewertung, §15.2 / Update 2)
|--------------------------------------------------------------------------
|
| Anbieter/Modell für die Score-Berechnung und Schwellen für die Ableitung
| der Stufe aus dem 0100-Score (Standard < 60 Geprüft < 80 Hochwertig).
| Schwellen werden laut Konzept nach 100200 echten PMs kalibriert.
|
*/
'content_score' => [
'provider' => env('CONTENT_SCORE_PROVIDER', 'openai'),
'model' => env('CONTENT_SCORE_MODEL'),
'timeout' => (int) env('CONTENT_SCORE_TIMEOUT', 30),
'tiers' => [
'hochwertig' => (int) env('CONTENT_SCORE_HOCHWERTIG', 80),
'gepruft' => (int) env('CONTENT_SCORE_GEPRUEFT', 60),
// alles darunter => 'standard'
],
],
];

View file

@ -34,5 +34,10 @@ return [
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
'openai' => [
'api_key' => env('OPENAI_API_KEY'),
'url' => env('OPENAI_API_URL', 'https://api.openai.com/v1/chat/completions'),
'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'),
'timeout' => env('OPENAI_TIMEOUT', 60),
],
];

View file

@ -0,0 +1,53 @@
<?php
namespace Database\Factories;
use App\Enums\PressReleaseClassification;
use App\Models\KiAudit;
use App\Models\PressRelease;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<KiAudit>
*/
class KiAuditFactory extends Factory
{
protected $model = KiAudit::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$classification = fake()->randomElement(PressReleaseClassification::cases());
return [
'press_release_id' => PressRelease::factory(),
'type' => KiAudit::TYPE_CLASSIFICATION,
'provider' => 'anthropic',
'model' => 'claude-sonnet-4-6',
'result' => $classification->value,
'reason' => fake()->optional()->sentence(),
'raw_response' => ['classification' => $classification->value, 'reasons' => []],
'created_at' => now(),
];
}
public function classification(PressReleaseClassification $classification): static
{
return $this->state(fn (): array => [
'type' => KiAudit::TYPE_CLASSIFICATION,
'result' => $classification->value,
]);
}
public function contentScore(int $score): static
{
return $this->state(fn (): array => [
'type' => KiAudit::TYPE_CONTENT_SCORE,
'result' => (string) $score,
]);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Models\Plan;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Plan>
*/
class PlanFactory extends Factory
{
protected $model = Plan::class;
public function definition(): array
{
$monthly = fake()->numberBetween(19, 199) * 100;
return [
'slug' => fake()->unique()->slug(2),
'name' => fake()->words(2, true),
'monthly_price_cents' => $monthly,
'yearly_price_cents' => $monthly * 10,
'currency' => 'EUR',
'press_release_quota' => fake()->numberBetween(3, 60),
'daily_limit' => null,
'is_active' => true,
'sort_order' => fake()->numberBetween(0, 10),
];
}
public function inactive(): static
{
return $this->state(fn (): array => ['is_active' => false]);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\SinglePurchase;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SinglePurchase>
*/
class SinglePurchaseFactory extends Factory
{
protected $model = SinglePurchase::class;
public function definition(): array
{
return [
'user_id' => User::factory(),
'type' => SinglePurchaseType::SinglePm,
'status' => SinglePurchaseStatus::Pending,
'price_cents' => 1900,
'currency' => 'EUR',
];
}
public function paid(): static
{
return $this->state(fn (): array => [
'status' => SinglePurchaseStatus::Paid,
'paid_at' => now(),
]);
}
public function consumed(): static
{
return $this->state(fn (): array => [
'status' => SinglePurchaseStatus::Consumed,
'paid_at' => now()->subDay(),
'consumed_at' => now(),
]);
}
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('press_releases', function (Blueprint $table) {
$table->string('placeholder_variant', 32)->nullable()->after('boilerplate_override');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('press_releases', function (Blueprint $table) {
$table->dropColumn('placeholder_variant');
});
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('press_release_images', function (Blueprint $table) {
$table->string('author')->nullable()->after('copyright');
$table->string('license_type', 32)->nullable()->after('author');
$table->string('license_url')->nullable()->after('license_type');
$table->boolean('persons_consent')->default(false)->after('license_url');
$table->timestamp('rights_confirmed_at')->nullable()->after('persons_consent');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('press_release_images', function (Blueprint $table) {
$table->dropColumn(['author', 'license_type', 'license_url', 'persons_consent', 'rights_confirmed_at']);
});
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedInteger('press_release_quota')->default(3);
$table->unsignedInteger('press_release_quota_used_this_month')->default(0);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['press_release_quota', 'press_release_quota_used_this_month']);
});
}
};

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('press_release_images', function (Blueprint $table) {
$table->string('license_detail', 120)->nullable()->after('license_type');
$table->string('source_url', 2048)->nullable()->after('license_url');
$table->string('people_rights_status', 40)->nullable()->after('persons_consent');
$table->string('property_rights_status', 40)->nullable()->after('people_rights_status');
$table->text('rights_notes')->nullable()->after('property_rights_status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('press_release_images', function (Blueprint $table) {
$table->dropColumn([
'license_detail',
'source_url',
'people_rights_status',
'property_rights_status',
'rights_notes',
]);
});
}
};

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('press_releases', function (Blueprint $table): void {
// KI-Klassifikation („Red Flag", Konzept §15.1). Nullable: erst ab
// Phase 3 befüllt, ändert in Phase 2 noch kein Verhalten.
$table->string('classification', 16)->nullable()->after('status');
$table->timestamp('classified_at')->nullable()->after('classification');
$table->index('classification', 'press_releases_classification_idx');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('press_releases', function (Blueprint $table): void {
$table->dropIndex('press_releases_classification_idx');
$table->dropColumn(['classification', 'classified_at']);
});
}
};

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ki_audits', function (Blueprint $table): void {
$table->id();
$table->foreignId('press_release_id')
->constrained()
->cascadeOnDelete();
// classification | content_score — welche KI-Bewertung dies protokolliert.
$table->string('type', 32);
$table->string('provider', 32)->nullable();
$table->string('model', 64)->nullable();
// Ergebnis als String (z. B. green/yellow/red) oder Score-Wert.
$table->string('result', 64)->nullable();
$table->text('reason')->nullable();
// Vollständige Roh-Antwort der KI für Nachvollziehbarkeit (DSGVO).
$table->json('raw_response')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['press_release_id', 'type'], 'ki_audits_pr_type_idx');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ki_audits');
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('press_releases', function (Blueprint $table): void {
// Content-Score (0100, Konzept §15.2) und abgeleitete Stufe
// (standard|gepruft|hochwertig, Update 2). Nullable: erst ab Phase 5
// befüllt.
$table->unsignedTinyInteger('content_score')->nullable()->after('classified_at');
$table->string('content_tier', 16)->nullable()->after('content_score');
$table->timestamp('scored_at')->nullable()->after('content_tier');
$table->index('content_tier', 'press_releases_content_tier_idx');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('press_releases', function (Blueprint $table): void {
$table->dropIndex('press_releases_content_tier_idx');
$table->dropColumn(['content_score', 'content_tier', 'scored_at']);
});
}
};

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex([
'stripe_id',
]);
$table->dropColumn([
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
]);
});
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id');
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'stripe_status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscription_items', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscription_items');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->string('meter_id')->nullable()->after('stripe_price');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->dropColumn('meter_id');
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->string('meter_event_name')->nullable()->after('quantity');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->dropColumn('meter_event_name');
});
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Fortlaufende Rechnungsnummern pro Rechnungskreis (hybrides Modell):
* STR- für den neuen Stripe-Shop-Kreislauf, MAN- für den manuellen
* Legacy-Kreis ab Relaunch. Vergabe atomar über Row-Lock im
* InvoiceNumberGenerator.
*/
public function up(): void
{
Schema::create('invoice_number_sequences', function (Blueprint $table) {
$table->id();
$table->string('circle', 8)->unique();
$table->unsignedBigInteger('next_number')->default(1);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('invoice_number_sequences');
}
};

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Tarif-Katalog laut Decision-Update (Starter/Business/Pro/Agency).
* Die Stripe-IDs werden beim Anlegen der Produkte in Stripe gepflegt (9E).
*/
public function up(): void
{
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('slug', 40)->unique();
$table->string('name', 80);
$table->unsignedInteger('monthly_price_cents');
$table->unsignedInteger('yearly_price_cents');
$table->string('currency', 3)->default('EUR');
$table->unsignedInteger('press_release_quota');
$table->unsignedInteger('daily_limit')->nullable();
$table->string('stripe_product_id', 60)->nullable();
$table->string('stripe_price_id_monthly', 60)->nullable();
$table->string('stripe_price_id_yearly', 60)->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['is_active', 'sort_order'], 'plans_active_sort_idx');
});
}
public function down(): void
{
Schema::dropIfExists('plans');
}
};

View file

@ -0,0 +1,46 @@
<?php
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Einmalkäufe laut Decision-Update: Einzel-PM (19 ) sowie die
* Launch-Credit-Posten Extra-PM, Boost und Veröffentlichungsnachweis-PDF.
*/
public function up(): void
{
Schema::create('single_purchases', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('type', array_map(
static fn (SinglePurchaseType $type): string => $type->value,
SinglePurchaseType::cases()
));
$table->enum('status', array_map(
static fn (SinglePurchaseStatus $status): string => $status->value,
SinglePurchaseStatus::cases()
))->default(SinglePurchaseStatus::Pending->value);
$table->unsignedInteger('price_cents');
$table->string('currency', 3)->default('EUR');
$table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete();
$table->string('stripe_checkout_session_id', 80)->nullable();
$table->string('stripe_payment_intent_id', 80)->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamp('consumed_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'type', 'status'], 'single_purchases_user_type_status_idx');
$table->index(['stripe_checkout_session_id'], 'single_purchases_stripe_session_idx');
});
}
public function down(): void
{
Schema::dropIfExists('single_purchases');
}
};

View file

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* USt-Behandlung für neue Rechnungen (Entscheidung 12.06.2026):
* Deutschland immer mit Steuer, EU nur mit gültiger USt-ID befreit
* (Reverse Charge), Drittländer grundsätzlich befreit. Dafür braucht
* die Rechnungsadresse eine USt-ID (inkl. Snapshot pro Rechnung) und
* die Rechnung einen Steuerhinweis-Text.
*/
public function up(): void
{
Schema::table('billing_addresses', function (Blueprint $table) {
$table->string('vat_id', 20)->nullable()->after('country_code');
});
Schema::table('invoice_billing_addresses', function (Blueprint $table) {
$table->string('vat_id', 20)->nullable()->after('country_code');
});
Schema::table('invoices', function (Blueprint $table) {
$table->string('tax_note', 191)->nullable()->after('is_netto');
});
}
public function down(): void
{
Schema::table('billing_addresses', function (Blueprint $table) {
$table->dropColumn('vat_id');
});
Schema::table('invoice_billing_addresses', function (Blueprint $table) {
$table->dropColumn('vat_id');
});
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn('tax_note');
});
}
};

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Entfernt die Stub-Spalte `press_release_quota` (Phase 9E): Das Limit kommt
* jetzt aus dem Tarif (`plans.press_release_quota`) bzw. aus Einmalkäufen.
* Der Verbrauchszähler `press_release_quota_used_this_month` bleibt bestehen.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('press_release_quota');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedInteger('press_release_quota')->default(3)->after('legacy_id');
});
}
};

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Rechnungsadresse vervollständigen (User-Panel-Restarbeiten, 12.06.2026):
* Anrede/Titel existierten bereits, es fehlten Firmenname sowie getrennte
* Vor-/Nachnamen. `name` bleibt als zusammengesetzte Empfängerzeile für
* die Rechnungs-Snapshots erhalten und wird beim Speichern aus
* Vor-/Nachname gefüllt. Die Snapshot-Tabelle bekommt `company` ebenfalls,
* damit der Firmenname pro Rechnung eingefroren wird.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('billing_addresses', function (Blueprint $table): void {
$table->string('company', 255)->nullable()->after('title');
$table->string('first_name', 80)->nullable()->after('company');
$table->string('last_name', 80)->nullable()->after('first_name');
});
Schema::table('invoice_billing_addresses', function (Blueprint $table): void {
$table->string('company', 255)->nullable()->after('title');
});
}
public function down(): void
{
Schema::table('billing_addresses', function (Blueprint $table): void {
$table->dropColumn(['company', 'first_name', 'last_name']);
});
Schema::table('invoice_billing_addresses', function (Blueprint $table): void {
$table->dropColumn('company');
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* KI-Kennzeichnung für PM-Bilder (12.06.2026): Flag für KI-generierte
* Titelbilder Grundlage für das öffentliche Label auf den Portal-Seiten
* (Art. 50 EU AI Act, Transparenzpflicht ab 02.08.2026) und für die
* redaktionelle Sicht im Admin.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('press_release_images', function (Blueprint $table): void {
$table->boolean('is_ai_generated')->default(false)->after('rights_confirmed_at');
});
}
public function down(): void
{
Schema::table('press_release_images', function (Blueprint $table): void {
$table->dropColumn('is_ai_generated');
});
}
};

View file

@ -0,0 +1,36 @@
<?php
namespace Database\Seeders;
use App\Models\Plan;
use Illuminate\Database\Seeder;
/**
* Launch-Tarifstruktur laut Decision-Update (11.06.2026):
* Jahrespreis = 10 Monatsbeiträge („2 Monate gratis"), Kontingente und
* Tageslimits aus §2/§3.3. Idempotent über den Slug.
*/
class PlanSeeder extends Seeder
{
public function run(): void
{
$plans = [
['slug' => 'starter', 'name' => 'Starter', 'monthly_price_cents' => 2900, 'press_release_quota' => 3, 'daily_limit' => null, 'sort_order' => 1],
['slug' => 'business', 'name' => 'Business', 'monthly_price_cents' => 4900, 'press_release_quota' => 10, 'daily_limit' => 2, 'sort_order' => 2],
['slug' => 'pro', 'name' => 'Pro', 'monthly_price_cents' => 9900, 'press_release_quota' => 25, 'daily_limit' => 3, 'sort_order' => 3],
['slug' => 'agency', 'name' => 'Agency', 'monthly_price_cents' => 19900, 'press_release_quota' => 60, 'daily_limit' => 5, 'sort_order' => 4],
];
foreach ($plans as $plan) {
Plan::query()->updateOrCreate(
['slug' => $plan['slug']],
[
...$plan,
'yearly_price_cents' => $plan['monthly_price_cents'] * 10,
'currency' => 'EUR',
'is_active' => true,
],
);
}
}
}

View file

@ -187,7 +187,7 @@ aber:
---
## Gesamt-Status (Stand 2026-05-20)
## Gesamt-Status (Stand 2026-05-29)
| Phase | Inhalt | Status |
|---|---|---|
@ -198,6 +198,7 @@ aber:
| 4 | Listen/Detail durchgehen (4A4J) | ✅ **komplett** |
| 5 | Dark Mode konsistent | ✅ **abgeschlossen** |
| 6 | Auth-Cleanup | ✅ **abgeschlossen** |
| 7 | Press-Release-Form-Refactor (7A7F) | ✅ **abgeschlossen** (`19-PHASE-7-…`) |
### Phase 4 — Sub-Päckchen im Detail
@ -219,13 +220,21 @@ aber:
> Die hub-flux-Roadmap ist mit Phase 6 **vollständig** abgeschlossen.
> Alle weiteren Themen sind eigene Initiativen.
**🟡 In Planung — Phase 7 (Press-Release-Form-Refactor):**
Mockup `User Neue Mitteilung presseportale.html` wird auf den
**✅ Abgeschlossen — Phase 7 (Press-Release-Form-Refactor):**
Mockup `User Neue Mitteilung presseportale.html` auf den
Customer-Create/Edit-Flow übertragen. Plan-Doc:
`19-PHASE-7-PRESS-RELEASE-FORM.md`.
Päckchen 7A (Migrations) → 7B (flux:editor + Sanitizer) →
7C (Customer-Create-UI) → 7D (Customer-Edit-UI) →
7E (Anhänge-Manager) → 7F (Scheduling/Embargo, optional).
`19-PHASE-7-PRESS-RELEASE-FORM.md`. Alle Päckchen umgesetzt:
7A (Migrations: subtitle/boilerplate_override/scheduled_at/
embargo_at + attachments-Tabelle + companies.boilerplate) →
7B (`flux:editor` + `PressReleaseHtmlSanitizer` via mews/purifier) →
7C (Customer-Create-UI, 2-Spalter mit sticky Settings-Sidebar) →
7D (Customer-Edit-UI) → 7E (Anhänge-Manager) →
7F (Scheduling/Embargo + `press-releases:publish-scheduled`
Command + 5-Min-Scheduler). Admin-Create/Edit ziehen optisch mit;
Scheduling-UI bleibt Customer-seitig. Detail siehe `PROGRESS.md`
(Eintrag 2026-05-22).
### Offene Folge-Initiativen
1. **Manueller Dark-Mode Smoke-Test**: Im Browser User-Menü →
Erscheinung → „Dunkel" und durch die Hauptseiten klicken
@ -233,10 +242,10 @@ Päckchen 7A (Migrations) → 7B (flux:editor + Sanitizer) →
Lesbar + konsistent. Kleine Polish-Runde, falls visuelle
Auffälligkeiten.
2. **PM-Form-Wizard-Refactor**: Mockup
`User Neue Mitteilung presseportale.html` auf den bestehenden
Press-Release-Create/Edit-Flow übertragen. Größere Aktion mit
eigener Phase.
2. **Admin-Create/Edit nachziehen**: Die Admin-PM-Forms haben das
Phase-7-Layout optisch übernommen, aber Scheduling/Embargo-UI
ist bewusst Customer-seitig geblieben. Bei Bedarf als kleines
Folge-Päckchen für Admins ergänzen.
3. **Web-Frontend-Block** (eigenständig, NICHT Teil von Phase 16):
Die noch ungenutzten Mockups

View file

@ -0,0 +1,103 @@
# Phase 8 · User-Panel-Konsolidierung & PM-Lifecycle
Stand: 2026-05-29 · **abgeschlossen**
Plan-Doc: [`docs/PHASE-8-USER-PANEL-PLAN.md`](../../../docs/PHASE-8-USER-PANEL-PLAN.md)
Abgleich: [`docs/STATUS-ABGLEICH-USER-PANEL.md`](../../../docs/STATUS-ABGLEICH-USER-PANEL.md)
Folge-Initiative nach Phase 7 (PM-Form-Refactor). Ziel: das User-Panel
produktionsreif abrunden — Show-Page-Lücken, Firmen-Liste auf
Mockup-Niveau, durchgängige PM-Titelbilder, rechtssichere Bild-Lizenzen
und ein bewusster Veröffentlichungs-Flow mit Kontingent-Vorbereitung.
## Päckchen-Übersicht
| ID | Thema | Status |
|---|---|---|
| 8A | Show-Page-Lücken (subtitle, scheduling, embargo, boilerplate_override) — Customer + Admin | ✅ |
| 8B | Listen-Indikatoren für Scheduling/Embargo | ✅ |
| 8C | Pressekontakt-Warn-Box in der Form-Sidebar | ✅ |
| 8D | Doku-Sync (`docs/user-admin/*`, `STATUS-ABGLEICH`) | ✅ |
| 8E | Firmen-Liste auf Mockup-Niveau (Counter-Strip, Saved-Views, Filter-Chips, Toggle, Rollen-Legende) | ✅ |
| 8F | SVG-Titelbild-Platzhalter-Set + Anzeige-Komponente + Picker-Modal | ✅ |
| 8G | Titelbild-Schema (`placeholder_variant`) + Cover-Resolver | ✅ |
| 8H | Bild-Upload mit Lizenz-Pflichtfeldern | ✅ |
| 8I | Veröffentlichungs-Modal (Rechtshinweis + Kontingent) | ✅ |
| 8J | Quota-Stub im Datenmodell + monatlicher Reset-Command | ✅ |
| 8K | Tests, Pint, Build, Doku-Abschluss | ✅ |
## Wichtigste Code-Artefakte (8F8J, neu in dieser Phase)
**Enums**
- `app/Enums/PressReleasePlaceholder.php` — 9 SVG-Varianten (Default,
Seed-deterministisch, Labels, Asset-Pfad).
- `app/Enums/ImageLicenseType.php` — 5 Lizenztypen, `requiresLicenseUrl()`.
**Assets / Komponenten**
- `public/images/press-release-placeholders/01..09-*.svg` (1600×900,
3 Muster × 3 Hub-Farben).
- `resources/views/components/portal/press-release-placeholder.blade.php`
rendert Bild/Platzhalter mit optionalem Titel-Overlay.
- `resources/views/livewire/components/press-release-placeholder-picker.blade.php`
FluxUI-Modal mit 3×3-Grid, dispatcht `placeholder-selected`.
**Services**
- `app/Services/PressRelease/PressReleaseCoverImage.php``coverUrl()`,
`coverIsPlaceholder()`, `placeholder()`. Bevorzugt `is_preview`-Bild,
fällt sonst auf den SVG-Platzhalter zurück.
**Schema (additive, nullable/Default-Migrationen)**
- `press_releases.placeholder_variant` (string 32, nullable).
- `press_release_images`: `author`, `license_type`, `license_url`,
`persons_consent` (default false), `rights_confirmed_at`.
- `users`: `press_release_quota` (default 3),
`press_release_quota_used_this_month` (default 0).
**Models**
- `PressRelease`: Cast + `creating`-Hook setzt deterministisch eine
Platzhalter-Variante, wenn keine gesetzt ist.
- `PressReleaseImage`: Lizenz-Felder + `license_type`-Enum-Cast.
- `User`: `pressReleaseQuotaRemaining()`.
**Service-Hook & Command**
- `PressReleaseService::submitForReview()` zählt
`press_release_quota_used_this_month` des Autors hoch (Stub).
- `app/Console/Commands/ResetMonthlyPressReleaseQuota.php` +
Scheduler-Eintrag (`monthlyOn(1, '00:05')`) in `routes/console.php`.
**Views**
- Customer-Show + Admin-Show: Hero-Titelbild via Cover-Resolver.
- Customer Create/Edit: Platzhalter-Vorschau + Picker-Einbindung
(`#[On('placeholder-selected')]`).
- Customer-Show: Veröffentlichungs-Modal (`confirm-submit-review`) mit
Rechts-Platzhalter, Kontingent-Badge und 3 Bestätigungs-Checkboxen
(Submit-Button via Alpine disabled bis alle gesetzt).
- Image-Manager: Lizenz-Felder + Anzeige in der Bild-Kachel.
## Tests (neu)
- `tests/Feature/PressReleasePlaceholderTest.php` (8) — Enum, Assets,
Picker, Cover-Resolver (Platzhalter + echtes Bild).
- `tests/Feature/PressReleaseImageLicenseTest.php` (3) — Pflichtfelder,
CC-ohne-URL, vollständiger Upload + `rights_confirmed_at`.
- `tests/Feature/PressReleaseQuotaTest.php` (3) — Remaining-Berechnung,
Increment bei Submit, monatlicher Reset.
- `tests/Feature/PressReleasePublishModalPhase8iTest.php` (2) — Modal-Inhalt
(Rechtshinweis + Kontingent), Submit-Flow.
Gesamt-Suite nach Phase 8: **375 passed, 4 skipped**. Pint clean,
`npm run build:portal` clean.
## Bewusste Abweichungen / offene Folge-Themen
- **8H — Upload-Control**: `flux:input type=file` statt `flux:file-upload`
(Pro-Dropzone mit aufwändigem Slot-Aufbau). Funktion identisch, kein
Risiko für den Upload-Flow. Dropzone-Optik ggf. separat nachziehen.
- **8I — Rechtstext** ist ein **Platzhalter** und vor Go-Live anwaltlich
zu prüfen.
- **8J — Quota** ist ein Stub. Die Schnittstelle
`User::pressReleaseQuotaRemaining()` bleibt stabil; das echte
Tarif-/Credit-Modul löst Spalten + Decrement-Logik später ab.
- Offen (Phase 2/3): Stock-/KI-Bildquellen, Wasserzeichen-Check,
Magic-Link-Flow für Pressekontakte, Statistik-/Abrechnungs-Tabs,
Anhänge-Reaktivierung (Security-Audit).

View file

@ -5,6 +5,517 @@
---
## 2026-06-12 · Tagesabschluss · PM-Vorschau-Feinschliff + Next Steps ✅
- **Was**: Letzte Review-Runde auf der PM-Vorschauseite: Sprache-Kachel
entfernt (Kürzel jetzt als Badge am Portal-Namen), Panel „Zugeordnete
Pressekontakte" zu **„Firma & Pressekontakt"** zusammengeführt — oben
die Firma als vollwertige `firm-card` wie in der Firmenübersicht
(Logo/Initialen, Name + Meta-Zeile, Portal-Pills, Aktiv-Badge,
PM-/Kontakt-KPIs, „Firma öffnen"), direkt darunter der zugeordnete
Pressekontakt bzw. der Leerhinweis.
- **Dateien**: `customer/press-releases/show.blade.php`,
`docs/README.md` (Stand + neuer `weiteres/`-Abschnitt).
- **Build/Test**: Suite 557 passed / 4 skipped, Pint clean.
- **Hinweis Doku**: Das umgesetzte Verlinkungs-Decision-Update wurde von
Kevin aus `docs/weiteres/` entfernt (Inhalt in §4 des
Preisstruktur-Decision-Updates integriert, Umsetzungsstand hier im Log).
- **Nächste Schritte (vereinbart 12.06.2026)**:
1. **Decision-Update „Phase-2-Funktionen & Magic-Link-Änderungsprozess"**
umsetzen (Boost + Veröffentlichungsnachweis für den Launch,
Magic-Link-Zugangs-/Änderungsprozess; überschneidet sich mit 9G
Tageslimit und 9I Launch-Credits).
2. **Decision-Update „Duplicate-Content & Duplicate-Checking"** umsetzen
(Cross-Portal-SEO vs. Duplikat-Erkennung eingereichter PMs).
3. Danach weiter laut Kevins Liste: Login-/Registrierungs-Flow
durchtesten (Zweig 3), Code-Optimierung (Zweig 4).
## 2026-06-12 · KI-generierte Bilder: Lizenztyp + Kennzeichnung ✅
- **Was**: Neuer Lizenztyp „KI-generiert" im Titelbild-Upload
(Entscheidung nach Kevins Frage): KI-Bilder haben keinen menschlichen
Urheber (§ 2 UrhG) — maßgeblich sind die Anbieter-Bedingungen, und ab
02.08.2026 greift die Kennzeichnungspflicht aus Art. 50 EU AI Act.
Formular-Schaltung bei Auswahl: Pflichtfeld „Verwendetes KI-Tool"
(statt CC-/Sonstiges-Detail), Urheber-Label wird „Verantwortlich für
die Erstellung", Hinweis-Box (kein Urheberrecht, Anbieter-Bedingungen,
öffentliche Kennzeichnung, keine realen Personen/Marken), zusätzlicher
Pflicht-Switch „Anbieter-Bedingungen geprüft". Der öffentliche
Bildnachweis wird automatisch vorgeschlagen („Bild: KI-generiert
(Tool)"), manuelle Eingaben werden nicht überschrieben. Neues Flag
`press_release_images.is_ai_generated` (Migration); Kennzeichnung
sichtbar als Badge im Upload-Manager und als Bildnachweis-Zeile unter
dem Titelbild auf Customer- und Admin-Detailseite — die öffentlichen
Portal-Seiten übernehmen das Label beim Web-Relaunch.
- **Dateien**: `app/Enums/ImageLicenseType.php` (AiGenerated),
`press-release-images-manager.blade.php`, `PressReleaseImage`-Model,
Migration, Customer-/Admin-Show (Bildnachweis-Zeile).
- **Build/Test**: 3 neue Tests (Pflichtfelder, Speichern inkl. Flag,
Nachweis-Vorschlag), Suite 557 passed / 4 skipped, Pint clean.
- **Offene Fragen**: Label-Ausspielung auf den öffentlichen Web-Seiten
beim Relaunch (Flag + copyright sind vorhanden).
## 2026-06-12 · Titelbild-Upload: Struktur + große Vorschau ✅
- **Was**: Das Bildrechte-Formular im Titelbild-Upload (gemeinsame
Komponente `press-release-images-manager`, Admin + Customer) war eine
lange flache Feldliste — jetzt fünf nummerierte Schritte mit
Trennlinien: 1 Bild auswählen, 2 Bildinformationen (öffentlich),
3 Herkunft & Lizenz (Pflicht-Badges, 2-Spalten-Grid), 4 Personen &
Rechte Dritter (Radio-Gruppen nebeneinander), 5 Bestätigung. Nach der
Bildauswahl erscheint statt des Mini-Thumbnails eine **große
16:9-Vorschau** im Titelbild-Format (mit Dateiname/Größe und „Anderes
Bild wählen"); die Dropzone weicht der Vorschau, bis das Bild
verworfen wird.
- **Dateien**: `resources/views/livewire/components/press-release-images-manager.blade.php`.
- **Build/Test**: PressReleaseImageLicenseTest 12 passed, Suite 552
passed / 4 skipped, Pint clean.
## 2026-06-12 · Verlinkung & Backlinks (Decision-Update 11.06.) ✅
- **Was**: Systemseitige `rel`-Auszeichnung für Links in PMs umgesetzt:
neue `PressReleaseLinkPolicy`, angewendet in
`PressReleaseHtmlSanitizer::render()` (wirkt rückwirkend auf alle
gespeicherten Inhalte). Externe Links → `sponsored nofollow noopener`
+ `target="_blank"`, portalinterne Links (drei Domains inkl. www,
relative Pfade) → follow, mailto/tel ohne rel/target. Autoren-`rel`
wird immer überschrieben — kein kundenseitiger follow/nofollow-Hebel.
Editor-Hinweis unter der Schreibfläche (Create/Edit). §9-Korrektur im
Preisstruktur-Decision-Update eingearbeitet, Umsetzungsstand im
Verlinkungs-Dokument ergänzt.
- **Dateien**: `app/Services/PressRelease/PressReleaseLinkPolicy.php`
(neu), `PressReleaseHtmlSanitizer.php`, Editor-Views (Create/Edit),
beide Decision-Dokumente.
- **Build/Test**: 6 neue Policy-Tests in `PressReleaseHtmlSanitizerTest`.
- **Offene Fragen**: CTA-Box/Darstellungs-Stufung → Boost-Konzept (9I);
Link-Obergrenze pro PM und Anchor-Text-Soft-Check → Phase 2.
## 2026-06-12 · PM-Vorschau-Umbau + Profil-Feinschliff ✅
- **Was**: (1) PM-Detailseite (Customer) nach Kevins Review umgebaut:
Status-Workflow direkt unter den Header, farblich abgehoben
(Soft-Hintergrund + 3px-Border je Status: Entwurf hub, Überarbeiten
err, In Prüfung warn, neu: Veröffentlicht ok); danach Pressekontakte +
Status & Verlauf, dann erst Titelbild und Inhalt. Metadaten ergänzt:
Portal, Kategorie, Sprache als Kacheln, Themen (Keywords) als Badges,
Backlink — alles im Verlaufs-Panel. (2) **Inhalts-Typografie**: Die
Detailseiten nutzten `prose`-Klassen, aber das Tailwind-Typography-
Plugin ist gar nicht installiert → Text erschien unformatiert (Diff
Editor vs. Vorschau). Neue Klasse `.pr-content` in hub-components.css
bildet die Editor-Typografie nach (Absätze, Listen, H2-H4, Links,
Blockquote); Customer- UND Admin-Detailseite umgestellt, Lesebreite
max 760px. (3) Profil: Pflicht-Badges an Pflichtfeldern, Fokus-Fix
nach dem Speichern (blur statt Autofokus auf „Anzeigename"),
Submit-Modal-Bestätigungen von rohen Checkboxen auf Flux-Switches
(Alpine-State via change-Event, Button-Freischaltung unverändert).
- **Dateien**: `customer/press-releases/show.blade.php` (Umbau),
`admin/press-releases/show.blade.php` (.pr-content),
`resources/css/shared/hub-components.css` (.pr-content),
`customer/profile.blade.php`, `components/press-release-submit-modal.blade.php`.
- **Build/Test**: Suite 546 passed / 4 skipped, Pint clean, npm run build ok.
- **Offene Fragen**: Web-Detailseiten (`web/release-detail` u. a.) nutzen
ebenfalls wirkungslose `prose`-Klassen — beim Web-Relaunch auf
`.pr-content` (oder Typography-Plugin) umstellen.
## 2026-06-12 · User-Panel-Restarbeiten (Kevins Liste) ✅
- **Was**: Alle Punkte aus `docs/user-admin/User-Panel-Restarbeiten.md`
(Status-Tabelle dort): (1) PM-Anlage ohne Firma zeigt eine Meldung mit
„Firma anlegen"-Button statt des leeren Editors. (2) Profil-Seite neu
gegliedert (Persönliche Daten / Konto / Rechnungsadresse / Einstellungen);
Rechnungsadresse vervollständigt um Anrede, Vorname, Nachname,
Firmenname (Migration `billing_addresses` + Snapshot-Spalte `company`
in `invoice_billing_addresses`, von MAN-Lauf und STR-Spiegelung
durchgereicht); „Persönliche Daten übernehmen"-Button gegen die
Doppel-Eingabe. (3) Doppelte Validierungsmeldung behoben: fehlende
Pflichtfelder melden einzeln unter dem Feld. (4) USt-ID-Validierung:
Formatprüfung sofort (hartes Speicher-Gate) + eVatR-Online-Bestätigung
(BZSt-REST-API, neuer `VatIdValidationService`, ENV `BILLING_OWN_VAT_ID`,
6h-Cache nur für definitive Ergebnisse, Ausfälle degradieren sanft).
(5) Rechnungsadresse ist Pflicht für jeden Checkout (Redirect aufs
Profil mit Hinweis); USt-ID wird als Stripe-Tax-ID übergeben.
(6) Firmenübersicht zeigt Logos auch für Legacy-Firmen (zentrale
logoUrl-Auflösung statt verkürzter Fast-Variante); „Letzte PM" mit
Jahreszahl. (7) Checkboxen → Flux-Switches (Boilerplate-Override,
Footer-Code); Token-Abilities bleiben Checkbox-Gruppe. Befund zu den
Profil-Schaltern: `show_stats`/`disable_footer_code` werden noch
nirgends ausgewertet (greifen mit Web-Relaunch) — steht jetzt in den
Beschreibungen.
- **Dateien**: `customer/profile.blade.php` (Neufassung),
`customer/press-releases/create.blade.php` (Guard),
`customer/press-kits/index.blade.php` (Logo/Datum),
`app/Services/Billing/VatIdValidationService.php` (neu) +
`VatIdCheckStatus`-Enum, `VatResolver` (isPlausibleVatId public),
`StripeCheckoutService` (Tax-ID-Sync), `CheckoutController` (Guard),
`User::hasCompleteBillingAddress()`, `BillingAddress::isComplete()`,
Migration, `config/billing.php` (`own_vat_id`).
- **Build/Test**: Suite 546 passed / 4 skipped, Pint clean; 14 neue Tests
(VatId-Service, PM-Guard, Profil-Validierung, Checkout-Guard).
- **Offene Fragen**: `BILLING_OWN_VAT_ID` in .env setzen (eigene USt-ID),
sonst bleibt die Online-Prüfung aus; Entscheidung zu den beiden
Profil-Schaltern (behalten vs. bis Relaunch ausblenden).
## 2026-06-12 · Responsive-Härtung (Block 3, Punkt 1) ✅
- **Was**: Systemische Responsive-Fehler behoben (Screenshots in
`dev/frontend/responsive/`): (1) Das starre Inline-Grid
`style="grid-template-columns:1fr auto"` der Seiten-Header (48 Seiten)
konnte nie umbrechen — die Aktions-Spalte quetschte die Titel-Spalte
(Buchstaben-Umbrüche im Firmennamen, Buttons liefen aus der Box).
Ersetzt durch die neue CSS-Klasse `.page-header` (hub-components.css):
unterhalb lg stapeln Titel und Aktionen, Aktions-Zeilen bekommen
flex-wrap. (2) Firmenkontext-Leiste im App-Layout stapelt jetzt erst
ab lg nebeneinander (vorher sm → Überlappung von „Firmenkontext"/
„Aktive Firma" zwischen 6401024px). (3) Stat-Cards: Label/Meta-Zeile
mit flex-wrap (keine Kollision mehr auf schmalen Karten), `.stat-num`
mit `overflow-wrap:anywhere` + 26px unter 480px (Text-Werte wie
„Businessportal24" sprengen die Karte nicht mehr). (4) KPI-Grids, die
schon ab sm auf 4 Spalten gingen (Customer-Dashboard, User-Show,
PM-Index), erst ab xl vierspaltig.
- **Dateien**: `resources/css/shared/hub-components.css` (.page-header,
.stat-num), `resources/views/components/portal/stat-card.blade.php`,
`resources/views/components/layouts/app.blade.php`, 48 Seiten-Views
(Header-Klasse per sed), 3 KPI-Grids.
- **Build/Test**: `npm run build` ok (beide Bundles), Suite 532 passed /
4 skipped.
- **Offene Fragen**: Weitere Stellen aus Kevins Klick-Durchgang folgen
(Liste Block 3).
## 2026-06-12 · Admin-Zahlungsmodul (P8-Rest) · Zahlungen + Tarif-Verwaltung ✅
- **Was**: Den Phase-8-Platzhalter `/admin/payments` durch das echte
Zahlungsmodul ersetzt: KPI-Reihe (aktive Abos, MRR netto, Umsatz
30 Tage brutto, offene Einzel-PMs), Tabellen für Stripe-Abos (mit
Tarif-Auflösung über die Price-IDs), Einmalkäufe (Typ/Status/PM-Link)
und den lokalen Rechnungsausgang (STR-/MAN-Badge), User-Suche über
alle drei Bereiche. Neu: `/admin/payments/plans` — Tarif-Verwaltung
mit Edit-Modal (Name, Netto-Preise, PM-Kontingent, Tageslimit,
aktiv/inaktiv, Sortierung) und **Sofort-Sync nach Stripe** über den
neuen `StripePlanSyncService`: Preisänderung legt ein neues
Price-Objekt an und deaktiviert das alte (Stripe-Preise sind
unveränderlich), Namensänderung aktualisiert das Produkt, unverknüpfte
Tarife werden komplett angelegt. Bestandsabos behalten ihren Preis
(Hinweis in UI und Speichermeldung). Buchungs-Seite zieht die Preise
ohnehin live aus `plans` → Änderungen wirken sofort überall.
Sidebar: eigener Eintrag „Tarife & Pakete" unter Billing.
- **Dateien**: `resources/views/livewire/admin/payments/index.blade.php`
(Neufassung), `resources/views/livewire/admin/payments/plans.blade.php`
(neu), `app/Services/Billing/StripePlanSyncService.php` (neu),
`routes/admin.php`, Sidebar.
- **Build/Test**: Suite 532 passed / 4 skipped, Pint clean; 13 neue Tests
(`AdminPlansPageTest`, `AdminPaymentsPageTest`), Stripe im Test gemockt.
- **Offene Fragen**: Refund-Workflow aus dem Admin (vorerst über das
Stripe-Dashboard); Einzel-PM-Preis bleibt Config/ENV-basiert.
- **Nächster Schritt**: User-Panel-Restarbeiten (Kevin sammelt Liste),
Login/Registrierungs-Flow durchtesten, 9G Tageslimit.
## 2026-06-12 · Phase 9F · Tarif-Seite + Checkout-UI ✅
- **Was**: „Buchungen & Add-ons" vom Credit-Konzept-Mock auf echte Daten
umgestellt: 4-Tier-Raster aus `plans` (Alpine Monat/Jahr-Toggle,
„2 Monate gratis"), Checkout-Buttons auf die 9E-Routen, Einzel-PM als
separater No-Abo-Block, Aktueller-Tarif-Panel (Abo / Bestandstarif
unbegrenzt / offene Einzelkäufe / leer) mit Kontingent-Kachel,
„Abo verwalten" → Stripe Billing Portal (neue Route
`me.checkout.billing-portal`), aktive Buchungen + Verlauf real.
Credit-Pakete/Marktplatz/Platzierungen entfernt (→ 9I bzw. Phase 2).
Stripe Tax im Dashboard aktiviert („SaaS business use", exklusiv).
**Feinschliff nach Review (Kevin)**: Aktueller-Tarif-Card nur bei
vorhandener Buchung (kein irreführendes „Unbegrenzt" vor dem Launch;
Kontingent-Kachel nur als echte Zahl), Tarif-Cards plakativer
(Icon je Tarif, größerer Preis, Trennlinie, mehr Abstand zum Button),
„Prüfung und Veröffentlichung inklusive" ohne „KI",
„Aktive Buchungen"-Panel entfernt (Info steht im Tarif-Panel),
Verlauf als eigene, klar abgegrenzte Sektion.
- **Dateien**: `resources/views/livewire/customer/bookings.blade.php`
(Neufassung), `app/Http/Controllers/CheckoutController.php` +
`app/Services/Billing/StripeCheckoutService.php` (Billing Portal),
`routes/customer.php`.
- **Build/Test**: Suite 519 passed / 4 skipped, Pint clean; 9 neue Tests
in `BookingsPageTest`, `PanelConsolidationTest` auf neue Seite angepasst.
- **Offene Fragen**: Stripe Tax + Produkt-Sync vor Relaunch im Live-Mode
wiederholen.
- **Nächster Schritt**: 9G Tageslimit (`plans.daily_limit` beim
Veröffentlichen), dann 9H Einzel-PM-Abo-Brücke, 9I Launch-Credits.
## 2026-06-12 · Phase 9E · Stripe-Anbindung komplett ✅
- **Was**: Produkt-Sync nach Stripe (Tarife + Einzel-PM, Netto-Preise,
Test-Mode), Webhook-Verarbeitung (STR-Spiegelung + Einmalkauf-Erfüllung;
Endpoint `pressekonto.com/stripe/webhook` registriert, Secret gesetzt),
Checkout-Flows als Backend (`me.checkout.subscription`,
`me.checkout.single-pm`; Stripe Tax via `Cashier::calculateTaxes()`),
Slot-Logik vom Stub auf Plan-Kontingent umgestellt: Abo → Tarif-Quote,
danach Einmalkauf-Verbrauch (consumed + PM-Verknüpfung),
**Grandfathered = unbegrenzt** (Entscheidung 12.06.2026, Bestandsschutz);
Stub-Spalte `users.press_release_quota` entfernt.
- **Dateien**: `app/Http/Controllers/CheckoutController.php`,
`app/Services/Billing/StripeCheckoutService.php`,
`app/Listeners/ProcessStripeWebhook.php`,
`app/Console/Commands/SyncStripePlans.php`, `app/Models/User.php`,
`app/Services/PressRelease/PressReleaseService.php`,
`routes/customer.php`, `config/billing.php`, Buchungs-Seite (Rückmeldung),
Submit-Modal/Views (Kontingent-Anzeige).
- **Build/Test**: Suite 510 passed / 4 skipped, Pint clean; Stripe-Sync
live gegen Test-Mode gelaufen (Einzel-PM: `STRIPE_PRICE_SINGLE_PM` in .env).
- **Offene Fragen**: Stripe Tax im Dashboard aktivieren (Ursprungsadresse),
sonst schlägt der Checkout fehl; Live-Mode-Sync vor Relaunch.
- **Nächster Schritt**: 9F Tarif-Seite/Buchungs-UI an die Checkout-Routen
anbinden (Mock ablösen), danach 9G Tageslimit.
## 2026-06-12 · Phase 9D · Tarif-Datenmodell, Rechnungskreise & USt ✅
Zentrale Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`.
Plan: `docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md` (9D ✅, 9E in Arbeit).
**Tarif-Datenmodell**
- Laravel Cashier ^16.5 (freigegeben), `User` ist Billable,
Cashier-Migrationen published + ausgeführt.
- `plans` (4 Tiers, Netto-Preise, Kontingente, Tageslimits, Seeder),
`single_purchases` (Einzel-PM, Extra-PM, Boost, PDF-Nachweis).
- `hasActiveBooking()` prüft hybrid: Cashier-Abo bezahlter
Einmalkauf aktive Legacy-Vereinbarung.
**Hybride Rechnungskreise (Entscheidung 12.06.)**
- `InvoiceNumberGenerator`: atomare fortlaufende Nummern, STR- (Stripe)
/ MAN- (manuell); Alt-Archiv `legacy_invoices` bleibt unverändert.
- MAN-Fälligkeitslauf `billing:generate-manual-invoices` (täglich
04:30): Periodenende → Rechnung mit Adress-Snapshot → Periode weiter.
**Legacy-Migration (P6.6, Runbook entsperrt)**
- `legacy:grandfather-subscriptions`: aktive jährliche Vereinbarungen
aus dem Rechnungsarchiv (22 im Test-Snapshot, 4 sofort fällig) als
`grandfathered` nach `user_payment_options` — Replay-fähig für den
Lauf kurz vor Relaunch.
**USt (Einwand 12.06.: alle neuen Preise netto; Legacy war brutto)**
- `VatResolver`: DE immer Steuer, EU nur mit USt-ID befreit (Reverse
Charge + Pflichthinweis `invoices.tax_note`), Drittland befreit.
- `vat_id` an Rechnungsadresse + Rechnungs-Snapshot; Netto-Ableitung
der Legacy-Beträge (199 € brutto → 167,23 € netto + 31,77 € USt —
Brutto bleibt für DE-Bestandskunden identisch).
- Offen: VIES-Validierung, PDF-Layout, Steuerberater-Abnahme.
**Verifikation**: Suite 490 passed / 4 skipped (39 neue Billing-Tests
über 4 Commits). Pint clean. Dry-Runs gegen Echtdaten validiert.
---
## 2026-06-12 · Phase 9 · Veröffentlichungs-Flow Block 1 (9A9C) ✅
Plan-Doc: `docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md`. Grundlage:
`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`
(+ Entscheidung 12.06.: Gelb geht direkt live).
Vorab Block 0: Repo aufgeräumt (3 Artefakt-Dateien im Root entfernt),
der unkommittierte Stand vom 29.05.11.06. in drei Commits gesichert
(Vite/Multi-Domain-Infra, User-Panel/KI-Pipeline, Doku-Sync).
**9A — Gelb-Routing Direkt-Live**
- `routeByClassification()`: Gelb durchläuft denselben Auto-Publish-Pfad
wie Grün (`autoPublishApproved()`); nur Rot wird abgelehnt.
- Scheduler publiziert fällige gelbe + grüne PMs; unklassifizierte
bleiben als Fallback in der manuellen Queue.
**9B — Slot-Verbrauch bei Veröffentlichung**
- Increment aus `submitForReview()` entfernt; `publish()` und
`changeStatusFromAdmin()` zählen idempotent beim ersten
`published`-Übergang (Prüfung über Status-Logs). Rot kostet nichts.
- Submit-Guard: Einreichen erfordert freien Slot
(`QuotaExceededException`, API 422).
**9C — Submit-Gate + Funnel-Fix**
- `User::hasActiveBooking()`-Stub hinter `config/billing.php`
(`enforce_booking`, Default aus) — Tarif-Modul ersetzt nur den Rumpf.
- Einreichungs-Modal zeigt ohne Buchung einen Buchungs-Hinweis mit
CTA zur Buchungs-Seite; Server-Guard (`BookingRequiredException`),
API antwortet 402.
- **Befund + Fix**: Customer-Create legte PMs bei „Zur Prüfung senden"
direkt mit Status `review` an — vorbei an Blacklist, Quota, KI und
Status-Log. Jetzt: immer Draft anlegen, dann `submitForReview()`.
**Verifikation**: Suite 451 passed / 4 skipped (9 neue Tests:
Quota-Semantik, Gelb-Routing, Gate via Service/API/Modal). Pint clean.
Nächster Schritt: Review-Stopp, dann Block 2 (9D9J: Tarif-Datenmodell,
Stripe/Cashier — Dependency-Freigabe nötig, Tarif-UI, Tageslimit,
Einzel-PM, Launch-Credits).
---
## 2026-05-29 · Phase 8 · User-Panel-Konsolidierung abgeschlossen (8F8K) ✅
Abschluss von Phase 8. Die erste Hälfte (8A8E: Show-Page-Lücken,
Listen-Indikatoren, Pressekontakt-Warnung, Firmen-Liste auf Mockup-Niveau)
war bereits im Commit „Optimierung der User und Admin Panels" enthalten,
aber undokumentiert. Heute der zweite Block plus Doku-Sync.
Roadmap-Doc: `20-PHASE-8-USER-PANEL.md`. Plan: `docs/PHASE-8-USER-PANEL-PLAN.md`.
**8D — Doku-Sync**: `docs/user-admin/checkliste-user-backend.md`,
`docs/STATUS-ABGLEICH-USER-PANEL.md` und `Admin-User.md` auf den
echten IST-Stand gezogen (8A8E waren als „offen" markiert, sind
aber umgesetzt).
**8F — SVG-Titelbild-Platzhalter**
- 9 Varianten (3 Muster × 3 Hub-Farben) in
`public/images/press-release-placeholders/`.
- `App\Enums\PressReleasePlaceholder` (Default, Seed-deterministisch).
- `<x-portal.press-release-placeholder>` + Picker-Modal
(`components.press-release-placeholder-picker`, dispatcht
`placeholder-selected`).
**8G — Titelbild-Schema + Cover-Resolver**
- Migration `placeholder_variant` auf `press_releases` (nullable).
- Model-`creating`-Hook setzt deterministisch eine Variante.
- `App\Services\PressRelease\PressReleaseCoverImage`
(`coverUrl`/`coverIsPlaceholder`).
- Hero-Bild in Customer-/Admin-Show; Vorschau + Picker in Create/Edit.
**8H — Bild-Upload mit Lizenz-Pflichtfeldern**
- Migration: `author`, `license_type`, `license_url`,
`persons_consent`, `rights_confirmed_at` auf `press_release_images`.
- `App\Enums\ImageLicenseType` (CC/kommerziell erzwingen Lizenz-URL).
- Image-Manager: Urheber (Pflicht), Lizenztyp (Pflicht), Lizenz-URL
(bedingt), Personen-Einwilligung, Rechte-Bestätigung (Pflicht).
- Abweichung: Upload-Control bleibt `flux:input type=file` statt
`flux:file-upload` (Stabilität). Lizenzerfassung vollständig.
**8J — Quota-Stub (vor 8I, damit Modal darauf aufsetzt)**
- Migration: `users.press_release_quota` (3),
`..._used_this_month` (0).
- `User::pressReleaseQuotaRemaining()`,
Decrement in `PressReleaseService::submitForReview()`.
- Command `press-releases:reset-monthly-quota` + Scheduler
(`monthlyOn(1, '00:05')`).
**8I — Veröffentlichungs-Modal (Customer-Show)**
- „Zur Prüfung einreichen" öffnet jetzt ein FluxUI-Modal statt
`wire:confirm`: Rechtshinweis (Platzhalter, anwaltlich zu prüfen),
Kontingent-Badge, 3 Bestätigungs-Checkboxen (Submit via Alpine
disabled bis alle gesetzt) → ruft das bestehende `submitForReview()`.
**8K — Abschluss**
- Neue Tests: `PressReleasePlaceholderTest` (8),
`PressReleaseImageLicenseTest` (3), `PressReleaseQuotaTest` (3),
`PressReleasePublishModalPhase8iTest` (2).
- Suite: **375 passed, 4 skipped**. Pint clean.
`npm run build:portal` clean.
---
## 2026-05-29 · Wartung · Test-Regression-Fix + Phase-7-Doku nachgezogen
Review der Gesamt-Umsetzung. Zwei Befunde behoben:
### Fix — `ProfileUpdateTest > profile page is displayed`
Seit dem Customer-Portal-Umbau ist `/settings/profile` ein
**Redirect** auf `/admin/me/profile` (route `me.profile`), die
Profil-Pflege liegt jetzt in der Volt-Komponente
`customer.profile`. Der Starter-Kit-Test machte aber weiterhin
`GET /settings/profile``assertOk()` und lief deshalb auf 302
statt 200 (undokumentierte Regression aus dem Commit
„Optimierung der User und Admin Panels", 2026-05-22).
Test umgestellt auf `assertRedirect('/admin/me/profile')`. Das
Rendern der Zielseite ist bereits durch
`CustomerProfileSecurityTest` (Volt-Komponententest) abgedeckt,
also keine Doppelung. Die übrigen 4 Tests der Datei nutzen
weiterhin `Volt::test('settings.profile')` / `delete-user-form`
(Komponenten existieren und sind funktional).
### Doku-Sync
Phase 7 war im Code vollständig umgesetzt (siehe Eintrag unten),
aber im Log nicht erfasst und in den Status-Tabellen
widersprüchlich (`19-PHASE-7` „✅", `03-WEITERE-PHASEN` „🟡 in
Planung", `README` noch auf Stand Phase 2). Nachgezogen:
- Phase-7-Eintrag in diesem Log ergänzt.
- `README.md` Status-Tabelle auf Phase 07 aktualisiert.
- `03-WEITERE-PHASEN.md` Phase 7 von „🟡 In Planung" auf
„✅ abgeschlossen" + Gesamt-Status-Tabelle ergänzt.
**Validierung**:
- `php artisan test --compact` → 359 passed, 3 skipped,
1 failed (weiterhin nur der pre-existing `ApiDocumentationTest`,
fehlende `docs/api/v1.yml`)
- `vendor/bin/pint tests/Feature/Settings/ProfileUpdateTest.php`
→ fixed (EOF-Blankline)
- `npm run build:portal` → grün (436.51 KB CSS / 58.95 KB gzip)
---
## 2026-05-22 · Phase 7 · Press-Release-Form-Refactor ✅ (retroaktiv dokumentiert)
> Großes Modul-Refactor außerhalb der ursprünglichen hub-flux-
> Roadmap (06). Vorlage:
> `dev/frontend/tailwind_v3/User Neue Mitteilung presseportale.html`.
> Plan-Doc: `19-PHASE-7-PRESS-RELEASE-FORM.md`.
> Dieser Eintrag wurde am 2026-05-29 nachgetragen — die Arbeit
> selbst entstand am 21./22.05. (Commit „Optimierung der User und
> Admin Panels").
**7A — Migrations + Models**
- `add_phase7_fields_to_press_releases` (subtitle,
boilerplate_override, scheduled_at, embargo_at — alle nullable)
- `create_press_release_attachments_table` (analog
press_release_images, mit sort_order, soft-deletes)
- `add_boilerplate_to_companies` (companies.boilerplate)
- Models + Factory + Relationen + Casts.
**7B — Editor + Sanitizer**
- `composer require mews/purifier ^3.4` (approved)
- `App\Services\PressRelease\PressReleaseHtmlSanitizer`
(Allowlist p/br/h2/h3/strong/em/u/ul/ol/li/blockquote/a)
- `<flux:textarea>``<flux:editor>` mit reduzierter Toolbar.
- Test: `PressReleaseHtmlSanitizerTest`.
**7C/7D — Customer Create + Edit Form (UI)**
- 2-Spalter mit sticky Settings-Sidebar (Status & Absenden,
Portal read-only-Badge, Pressekontakt-Single-Select,
Themen-Tags, Veröffentlichung, SEO).
- Linke Spalte: Firma-Selector, Titel/Untertitel mit
Counter-Pillen (`.pr-meter`), `flux:editor`, Medien,
Anhänge, Boilerplate-Box mit Override-Toggle.
- Hub-Form-Bausteine in `hub-components.css` ergänzt
(`.pr-form-label`, `.pr-meter`, `.pr-bald-badge`,
`.pr-ai-hint`, `.pr-check-row`, `.pr-boiler`, `.pr-tag-chip`,
`.pr-pub-opt` …).
- Live-Re-Validation (`updated()` re-validiert Felder mit
bestehendem Error) + Sammel-Toast bei Validierungsfehler.
- Neues JS-Asset `portal-form-hooks` (Build).
- Tests: `CustomerPressReleaseCreatePhase7Test` (8),
`CustomerPressReleaseEditPhase7Test` (9).
**7E — Anhänge-Manager**
- `App\Services\PressRelease\PressReleaseAttachmentStorage`
- Komponente
`livewire/components/press-release-attachments-manager.blade.php`
(upload/remove/reorder, PDF/DOCX/XLSX/PPTX, Tile-Layout).
**7F — Scheduling + Embargo**
- UI: Radio „Geplanter Termin" + `datetime-local`,
Embargo-Switch + Date-Picker.
- Validation: scheduled_at min. 5 Min Zukunft, embargo_at
Zukunft (nur wenn Toggle aktiv).
- `PressReleaseService::publish()``resolvePublishedAt()`
(published_at > scheduled_at > embargo_at-Verschiebung > now).
- Command `press-releases:publish-scheduled`
(`App\Console\Commands\PublishScheduledPressReleases`,
`--dry-run`, `--limit=N`) + Scheduler-Eintrag in
`routes/console.php` (`everyFiveMinutes`, `withoutOverlapping`,
`runInBackground`).
- Tests: `PressReleaseSchedulingTest` (11),
`CustomerPressReleaseSchedulingFormTest` (5).
**Effekt auf die Suite**: von dokumentierten ~231 auf ~360 Tests
gewachsen. Admin-Create/Edit ziehen das Layout vorerst NUR
optisch mit; Scheduling/Embargo-UI bleibt Customer-seitig
(laut Plan-Doc Out-of-Scope für Admin in Phase 7).
---
## 2026-05-20 · Phase 6 · Auth-Cleanup ✅
Mit Phase 6 ist die hub-flux-Roadmap (Phase 06) **vollständig

View file

@ -11,11 +11,16 @@
|-------|--------------|--------|
| 0 | Design-Tokens vereinheitlichen | **✅ abgeschlossen** (2026-05-19) |
| 1 | Portal-Shell (Sidebar, Layout, Brand-Mark) | **✅ abgeschlossen** (2026-05-19) |
| 2 | Customer-Dashboard auf Mockup-Stil (inkl. Topbar) | 🟡 wartet auf Freigabe |
| 3 | Admin-Dashboard konsistent | ⚪ später |
| 4 | Listen-/Detail-Pages | ⚪ iterativ |
| 5 | Dark Mode konsistent | ⚪ später |
| 6 | Auth-Konsolidierung (optional) | ⚪ optional |
| 2 | Customer-Dashboard auf Mockup-Stil | **✅ abgeschlossen** (in P1, verfeinert in 4J) |
| 3 | Admin-Dashboard konsistent | **✅ abgeschlossen** (in P1, verfeinert in 4J) |
| 4 | Listen-/Detail-Pages (4A4J) | **✅ abgeschlossen** (2026-05-20) |
| 5 | Dark Mode konsistent | **✅ abgeschlossen** (2026-05-20) |
| 6 | Auth-Cleanup | **✅ abgeschlossen** (2026-05-20) |
| 7 | Press-Release-Form-Refactor (7A7F) | **✅ abgeschlossen** (2026-05-22) |
Die hub-flux-Roadmap (Phase 06) ist vollständig abgeschlossen; Phase 7
(PM-Form-Refactor) als Folge-Initiative ebenfalls. Detail-Plan für Phase 7:
[`19-PHASE-7-PRESS-RELEASE-FORM.md`](./19-PHASE-7-PRESS-RELEASE-FORM.md).
→ Tagesaktueller Fortschritt: [`PROGRESS.md`](./PROGRESS.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Some files were not shown because too many files have changed in this diff Show more