Compare commits
26 commits
e8c47b7553
...
ad741331ee
| Author | SHA1 | Date | |
|---|---|---|---|
| ad741331ee | |||
| 970b4909fa | |||
| 284d029b29 | |||
| cc7b3c3379 | |||
| 6e0b2b1814 | |||
| a7c30d4ecc | |||
| 25ea91d85b | |||
| 5a8da0c1f4 | |||
| b7145512a7 | |||
| afcca34f91 | |||
| 036a53499f | |||
| bda755fcf8 | |||
| 8f3261d0b4 | |||
| 6a82e2a2a8 | |||
| 23ac8bc7f1 | |||
| c8dc99c3c8 | |||
| 38fab64e10 | |||
| 62e6b7e70f | |||
| 894a9436b0 | |||
| 1cd4d8e33a | |||
| d548f4b235 | |||
| 4419d9ff43 | |||
| 8d8d957884 | |||
| a000238ca8 | |||
| 0efabaf446 | |||
| 4bb9094207 |
274 changed files with 21711 additions and 1789 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
70
app/Console/Commands/GenerateManualInvoices.php
Normal file
70
app/Console/Commands/GenerateManualInvoices.php
Normal 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;
|
||||
}
|
||||
}
|
||||
292
app/Console/Commands/GrandfatherLegacySubscriptions.php
Normal file
292
app/Console/Commands/GrandfatherLegacySubscriptions.php
Normal 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, ',', '.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
36
app/Console/Commands/ResetMonthlyPressReleaseQuota.php
Normal file
36
app/Console/Commands/ResetMonthlyPressReleaseQuota.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
app/Console/Commands/RunClassificationQueue.php
Normal file
35
app/Console/Commands/RunClassificationQueue.php
Normal 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);
|
||||
}
|
||||
}
|
||||
140
app/Console/Commands/SyncStripePlans.php
Normal file
140
app/Console/Commands/SyncStripePlans.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
80
app/Enums/ImageLicenseType.php
Normal file
80
app/Enums/ImageLicenseType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
app/Enums/PressReleaseClassification.php
Normal file
27
app/Enums/PressReleaseClassification.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
51
app/Enums/PressReleaseContentTier.php
Normal file
51
app/Enums/PressReleaseContentTier.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Content-Score-Stufe (Außenkommunikation, Konzept Update 2).
|
||||
*
|
||||
* Der numerische Score (0–100) 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;
|
||||
}
|
||||
}
|
||||
90
app/Enums/PressReleasePlaceholder.php
Normal file
90
app/Enums/PressReleasePlaceholder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
21
app/Enums/SinglePurchaseStatus.php
Normal file
21
app/Enums/SinglePurchaseStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
app/Enums/SinglePurchaseType.php
Normal file
30
app/Enums/SinglePurchaseType.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Enums/VatIdCheckStatus.php
Normal file
19
app/Enums/VatIdCheckStatus.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Enums/VatTreatment.php
Normal file
40
app/Enums/VatTreatment.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
104
app/Http/Controllers/CheckoutController.php
Normal file
104
app/Http/Controllers/CheckoutController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
107
app/Jobs/ClassifyPressRelease.php
Normal file
107
app/Jobs/ClassifyPressRelease.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
app/Jobs/ScorePressRelease.php
Normal file
85
app/Jobs/ScorePressRelease.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
146
app/Listeners/ProcessStripeWebhook.php
Normal file
146
app/Listeners/ProcessStripeWebhook.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class Invoice extends Model
|
|||
'total_cents',
|
||||
'currency',
|
||||
'is_netto',
|
||||
'tax_note',
|
||||
'invoice_date',
|
||||
'due_date',
|
||||
'paid_at',
|
||||
|
|
|
|||
|
|
@ -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
51
app/Models/KiAudit.php
Normal 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
51
app/Models/Plan.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
68
app/Models/SinglePurchase.php
Normal file
68
app/Models/SinglePurchase.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
69
app/Services/Billing/InvoiceNumberGenerator.php
Normal file
69
app/Services/Billing/InvoiceNumberGenerator.php
Normal 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));
|
||||
}
|
||||
}
|
||||
162
app/Services/Billing/ManualInvoiceService.php
Normal file
162
app/Services/Billing/ManualInvoiceService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
120
app/Services/Billing/StripeCheckoutService.php
Normal file
120
app/Services/Billing/StripeCheckoutService.php
Normal 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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
121
app/Services/Billing/StripePlanSyncService.php
Normal file
121
app/Services/Billing/StripePlanSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
148
app/Services/Billing/VatIdValidationService.php
Normal file
148
app/Services/Billing/VatIdValidationService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
67
app/Services/Billing/VatResolver.php
Normal file
67
app/Services/Billing/VatResolver.php
Normal 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 2–13 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
17
app/Services/PressRelease/BookingRequiredException.php
Normal file
17
app/Services/PressRelease/BookingRequiredException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 0–100-Score plus optionalen
|
||||
* Faktor-Breakdown, damit der Job einheitlich persistieren/auditieren kann.
|
||||
*/
|
||||
final class ContentScoreResult
|
||||
{
|
||||
/**
|
||||
* @param int $score 0–100
|
||||
* @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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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 (0–100) 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;
|
||||
}
|
||||
|
|
@ -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 0–100-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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
92
app/Services/PressRelease/PressReleaseCoverImage.php
Normal file
92
app/Services/PressRelease/PressReleaseCoverImage.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
132
app/Services/PressRelease/PressReleaseLinkPolicy.php
Normal file
132
app/Services/PressRelease/PressReleaseLinkPolicy.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
17
app/Services/PressRelease/QuotaExceededException.php
Normal file
17
app/Services/PressRelease/QuotaExceededException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
145
app/Support/DomainAssetContext.php
Normal file
145
app/Support/DomainAssetContext.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
328
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
79
config/billing.php
Normal 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
130
config/cashier.php
Normal 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'),
|
||||
|
||||
];
|
||||
|
|
@ -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'), //
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
71
config/scoring.php
Normal 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 0–100-Score (Standard < 60 ≤ Geprüft < 80 ≤ Hochwertig).
|
||||
| Schwellen werden laut Konzept nach 100–200 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'
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
|
|
|
|||
53
database/factories/KiAuditFactory.php
Normal file
53
database/factories/KiAuditFactory.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
database/factories/PlanFactory.php
Normal file
36
database/factories/PlanFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
45
database/factories/SinglePurchaseFactory.php
Normal file
45
database/factories/SinglePurchaseFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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 (0–100, 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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
39
database/migrations/2026_06_12_100724_create_plans_table.php
Normal file
39
database/migrations/2026_06_12_100724_create_plans_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
36
database/seeders/PlanSeeder.php
Normal file
36
database/seeders/PlanSeeder.php
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (4A–4J) | ✅ **komplett** |
|
||||
| 5 | Dark Mode konsistent | ✅ **abgeschlossen** |
|
||||
| 6 | Auth-Cleanup | ✅ **abgeschlossen** |
|
||||
| 7 | Press-Release-Form-Refactor (7A–7F) | ✅ **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 1–6):
|
||||
Die noch ungenutzten Mockups
|
||||
|
|
|
|||
103
dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md
Normal file
103
dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md
Normal 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 (8F–8J, 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).
|
||||
|
|
@ -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 640–1024px). (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 (9A–9C) ✅
|
||||
|
||||
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 (9D–9J: Tarif-Datenmodell,
|
||||
Stripe/Cashier — Dependency-Freigabe nötig, Tarif-UI, Tageslimit,
|
||||
Einzel-PM, Launch-Credits).
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-29 · Phase 8 · User-Panel-Konsolidierung abgeschlossen (8F–8K) ✅
|
||||
|
||||
Abschluss von Phase 8. Die erste Hälfte (8A–8E: 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 (8A–8E 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 0–7 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 (0–6). 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 0–6) **vollständig
|
||||
|
|
|
|||
|
|
@ -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 (4A–4J) | **✅ abgeschlossen** (2026-05-20) |
|
||||
| 5 | Dark Mode konsistent | **✅ abgeschlossen** (2026-05-20) |
|
||||
| 6 | Auth-Cleanup | **✅ abgeschlossen** (2026-05-20) |
|
||||
| 7 | Press-Release-Form-Refactor (7A–7F) | **✅ abgeschlossen** (2026-05-22) |
|
||||
|
||||
Die hub-flux-Roadmap (Phase 0–6) 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)
|
||||
|
||||
|
|
|
|||
BIN
dev/frontend/responsive/admin:me.png
Normal file
BIN
dev/frontend/responsive/admin:me.png
Normal file
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
Loading…
Add table
Add a link
Reference in a new issue