Boost-Slot-Grenzen (5/3) + Kategorie-Boost zum halben Satz

Entscheidung 13.07.: verkauft wird nur, was sichtbar ist — max. 5
gleichzeitige Boosts je Startseite, 3 je Branchenseite (= die sichtbaren
Plaetze; Hero-Slider damit vertagt). Neue Stufe Kategorie-Boost (nur
Branchenseite, 6/10/18 Credits = 50 % aufgerundet); der Startseiten-Boost
(12/20/35) erscheint weiter auf Start- UND Branchenseite.

- boosts.placement (home/category, Bestand = home) + BoostPlacement-Enum;
  Preise/Slots in config/credits.php (boost_category, boost_slots)
- BoostService::slotAvailability() je Ausgabe (Portal x Sprache, Kategorie
  zusaetzlich je Rubrik); ausgebucht -> soldOut-Exception mit fruehestem
  freien Termin; Verlaengerungen halten ihren Slot, Stufen verlaengern
  unabhaengig; Kategorie-Boost invalidiert nur Kategorie-Caches
- Startseite zeigt nur Home-Boosts; Branchenseite: Kategorie-Boosts mit
  Vorrang (ihre 3 Slots sind die Garantie), Home-Boosts fuellen den Rest
- Add-on-Panel der PM-Seite: zwei Boost-Angebote, bei vollem Inventar
  "Derzeit ausgebucht — frei ab" statt Kauf-Buttons (serverseitig gegated);
  Buchungsseiten-Preisliste um die Kategorie-Staffel ergaenzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-13 12:28:03 +00:00
parent b1be14e1b3
commit 4853d48b83
19 changed files with 702 additions and 98 deletions

View file

@ -2,6 +2,7 @@
namespace App\Services\Billing;
use App\Enums\BoostPlacement;
use App\Enums\Tier;
use App\Models\User;
@ -29,11 +30,12 @@ class CreditPricingService
}
/**
* Boost-Preis (Credits) für eine Laufzeit in Tagen.
* Boost-Preis (Credits) für eine Laufzeit in Tagen, je Boost-Stufe
* (Startseiten-Boost bzw. Kategorie-Boost zum halben Satz).
*/
public function boostCredits(int $days): int
public function boostCredits(int $days, BoostPlacement $placement = BoostPlacement::Home): int
{
$table = config('credits.boost', []);
$table = $this->boostTable($placement);
if (! isset($table[$days])) {
throw new \InvalidArgumentException("Unbekannte Boost-Laufzeit: {$days} Tage.");
@ -47,14 +49,22 @@ class CreditPricingService
*
* @return array<int, int>
*/
public function boostOptions(): array
public function boostOptions(BoostPlacement $placement = BoostPlacement::Home): array
{
$table = config('credits.boost', []);
$table = $this->boostTable($placement);
ksort($table);
return $table;
}
/**
* @return array<int, int>
*/
private function boostTable(BoostPlacement $placement): array
{
return config($placement === BoostPlacement::Home ? 'credits.boost' : 'credits.boost_category', []);
}
public function proofPdfCredits(): int
{
return (int) config('credits.proof_pdf');

View file

@ -2,6 +2,7 @@
namespace App\Services\PressRelease;
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
@ -9,16 +10,24 @@ use App\Exceptions\BoostNotAllowedException;
use App\Models\Boost;
use App\Models\PressRelease;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\CreditWalletService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Boost-Kauf laut Decision-Update §2.2. Gate: nur veröffentlichte, grüne PMs.
* Bezahlung über die Credit-Wallet, Preis nach Laufzeit (7/14/30 Tage).
* Mehrfachkauf verlängert: der neue Zeitraum schließt an ein laufendes Ende an.
* Boost-Kauf laut Decision-Update §2.2 + Platzierungs-Entscheidung 13.07.2026.
* Gate: nur veröffentlichte, grüne PMs. Bezahlung über die Credit-Wallet,
* Preis nach Laufzeit (7/14/30 Tage) und Stufe: Startseiten-Boost (Startseite
* + Branchenseite) bzw. Kategorie-Boost (nur Branchenseite, halber Satz).
* Slot-Inventar je Ausgabe (config credits.boost_slots): mehr gleichzeitige
* Boosts als sichtbare Plätze werden nicht verkauft — der Kauf wirft dann
* BoostNotAllowedException::soldOut() mit dem frühesten freien Termin.
* Mehrfachkauf derselben Stufe verlängert: der neue Zeitraum schließt an ein
* laufendes Ende an und braucht keinen freien Slot (die PM hält ihren).
*/
class BoostService
{
@ -36,40 +45,111 @@ class BoostService
&& $pressRelease->classification === PressReleaseClassification::Green;
}
public function isBoosted(PressRelease $pressRelease): bool
public function isBoosted(PressRelease $pressRelease, ?BoostPlacement $placement = null): bool
{
return $pressRelease->boosts()->active()->exists();
return $pressRelease->boosts()
->active()
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
->exists();
}
/**
* Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`)
* oder null, wenn die PM gerade nicht geboostet ist.
* Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`),
* optional je Stufe, oder null, wenn die PM gerade nicht geboostet ist.
*/
public function activeUntil(PressRelease $pressRelease): ?Carbon
public function activeUntil(PressRelease $pressRelease, ?BoostPlacement $placement = null): ?Carbon
{
$endsAt = $pressRelease->boosts()->active()->max('ends_at');
$endsAt = $pressRelease->boosts()
->active()
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
->max('ends_at');
return $endsAt ? Carbon::parse($endsAt) : null;
}
/**
* Bucht einen Boost. Wirft BoostNotAllowedException am Gate und
* Slot-Verfügbarkeit der Stufe für diese PM. Eine Verlängerung ist immer
* verfügbar (die PM hält ihren Slot); sonst muss auf jeder Ausgabe, auf
* der die PM erscheint (Portal × Sprache, Kategorie-Boost zusätzlich je
* Rubrik), ein Slot frei sein. `freeAt` nennt bei „ausgebucht" den
* frühesten Zeitpunkt, zu dem wieder ein Platz frei wird.
*
* @return array{available: bool, freeAt: Carbon|null}
*/
public function slotAvailability(PressRelease $pressRelease, BoostPlacement $placement): array
{
if ($this->isBoosted($pressRelease, $placement)) {
return ['available' => true, 'freeAt' => null];
}
$limit = $this->slotLimit($placement);
$freeAt = null;
foreach ($this->affectedPortals($pressRelease) as $portal) {
// Je Konkurrent zählt seine PM einen Slot, gehalten bis zum
// spätesten Boost-Ende (Verlängerungen schließen lückenlos an).
$heldUntil = Boost::query()
->forPlacement($placement)
->active()
->where('press_release_id', '!=', $pressRelease->id)
->whereHas('pressRelease', function (Builder $query) use ($portal, $pressRelease, $placement): void {
$query
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', [$portal, Portal::Both->value])
->where('language', $pressRelease->language)
->when(
$placement === BoostPlacement::Category,
fn (Builder $query) => $query->where('category_id', $pressRelease->category_id),
);
})
->groupBy('press_release_id')
->selectRaw('press_release_id, MAX(ends_at) AS held_until')
->pluck('held_until');
if ($heldUntil->count() < $limit) {
continue;
}
// Diese Ausgabe ist voll: frei, sobald der zuerst endende
// Konkurrent seinen Slot räumt. Bei mehreren vollen Ausgaben
// zählt die späteste (die PM braucht überall einen Platz).
$portalFreeAt = Carbon::parse($heldUntil->min());
$freeAt = $freeAt === null ? $portalFreeAt : $freeAt->max($portalFreeAt);
}
return ['available' => $freeAt === null, 'freeAt' => $freeAt];
}
public function slotLimit(BoostPlacement $placement): int
{
return (int) config("credits.boost_slots.{$placement->value}");
}
/**
* Bucht einen Boost der angegebenen Stufe. Wirft BoostNotAllowedException
* am Gate (nicht boostbar bzw. Slots ausgebucht) und
* InvalidArgumentException bei unbekannter Laufzeit; die Wallet-Belastung
* wirft InsufficientCreditsException, falls das Guthaben nicht reicht.
*/
public function boost(User $user, PressRelease $pressRelease, int $days): Boost
public function boost(User $user, PressRelease $pressRelease, int $days, BoostPlacement $placement = BoostPlacement::Home): Boost
{
if (! $this->canBoost($pressRelease)) {
throw BoostNotAllowedException::notBoostable();
}
$credits = $this->pricing->boostCredits($days);
$credits = $this->pricing->boostCredits($days, $placement);
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits): Boost {
$startsAt = $this->activeUntil($pressRelease) ?? now();
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits, $placement): Boost {
$availability = $this->slotAvailability($pressRelease, $placement);
if (! $availability['available']) {
throw BoostNotAllowedException::soldOut($placement, $availability['freeAt']);
}
$startsAt = $this->activeUntil($pressRelease, $placement) ?? now();
$boost = $pressRelease->boosts()->create([
'user_id' => $user->id,
'placement' => $placement,
'days' => $days,
'credits_charged' => $credits,
'starts_at' => $startsAt,
@ -79,14 +159,14 @@ class BoostService
$this->wallet->debit(
$user,
$credits,
"Boost {$days} Tage – PM #{$pressRelease->id}",
"{$placement->label()} {$days} Tage – PM #{$pressRelease->id}",
$boost,
);
return $boost;
});
$this->forgetPlacementCaches($pressRelease);
$this->forgetPlacementCaches($pressRelease, $placement);
return $boost;
}
@ -94,13 +174,12 @@ class BoostService
/**
* Gecachte Start-/Branchenseiten der betroffenen Ausgaben verwerfen,
* damit die bezahlte Platzierung sofort sichtbar wird statt erst nach
* Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s).
* Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s). Der
* Kategorie-Boost berührt die Startseite nicht.
*/
private function forgetPlacementCaches(PressRelease $pressRelease): void
private function forgetPlacementCaches(PressRelease $pressRelease, BoostPlacement $placement): void
{
$portals = $pressRelease->portal === Portal::Both
? [Portal::Presseecho->value, Portal::Businessportal24->value]
: [$pressRelease->portal->value];
$portals = $this->affectedPortals($pressRelease);
// Branchenseite der eigenen Rubrik plus Eltern-Rubrik (deren Seite
// schließt Unterkategorien mit ein), je hinterlegter Sprache.
@ -111,7 +190,9 @@ class BoostService
->merge($category?->parent?->translations ?? []);
foreach ($portals as $portal) {
Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language));
if ($placement === BoostPlacement::Home) {
Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language));
}
foreach ($translations as $translation) {
if (filled($translation->slug)) {
@ -120,4 +201,16 @@ class BoostService
}
}
}
/**
* Portale, auf deren Ausgaben die PM erscheint.
*
* @return list<string>
*/
private function affectedPortals(PressRelease $pressRelease): array
{
return $pressRelease->portal === Portal::Both
? [Portal::Presseecho->value, Portal::Businessportal24->value]
: [$pressRelease->portal->value];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services\Web;
use App\Enums\BoostPlacement;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
@ -97,13 +98,27 @@ class CategoryPageData
// Gebuchte Boosts (Decision-Update §2.2): geboostete PMs der Branche
// besetzen Lead- und Top-Plätze vor der Chronologie; die Views
// markieren sie über `boostedIds`.
$boostedReleases = $base()
->boosted()
// markieren sie über `boostedIds`. Kategorie-Boosts haben Vorrang —
// ihr Slot-Inventar (max. 3 je Branchenseite) ist die verkaufte
// Garantie; Startseiten-Boosts erscheinen hier zusätzlich, solange
// Plätze übrig sind (Entscheidung 13.07.2026).
$categoryBoosted = $base()
->boosted(BoostPlacement::Category)
->with($with)
->orderByDesc('published_at')
->limit(3)
->get();
$homeBoostSlots = 3 - $categoryBoosted->count();
$homeBoosted = $homeBoostSlots > 0
? $base()
->boosted(BoostPlacement::Home)
->when($categoryBoosted->isNotEmpty(), fn (Builder $query) => $query->whereNotIn('id', $categoryBoosted->modelKeys()))
->with($with)
->orderByDesc('published_at')
->limit($homeBoostSlots)
->get()
: collect();
$boostedReleases = $categoryBoosted->concat($homeBoosted)->values();
$boostedIds = $boostedReleases->modelKeys();
$leadRelease = $boostedReleases->first()

View file

@ -2,6 +2,7 @@
namespace App\Services\Web;
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
@ -75,8 +76,10 @@ class HomePageData
// Gebuchte Boosts (Decision-Update §2.2): geboostete PMs besetzen die
// Top-Positionen vor der Chronologie — die jüngste als Lead, weitere
// in der Seitenleiste. Die Views markieren sie über `boostedIds`.
// Nur Startseiten-Boosts; der günstigere Kategorie-Boost kauft
// ausschließlich die Branchenseite (Entscheidung 13.07.2026).
$boostedReleases = $publishedQuery()
->boosted()
->boosted(BoostPlacement::Home)
->with($with)
->orderByDesc('published_at')
->limit(5)

View file

@ -26,7 +26,7 @@ class PreisePageData
->values()
->all();
$boost = collect(config('credits.boost', []))
$staffel = static fn (array $table): array => collect($table)
->map(static fn (int $credits, int|string $days): array => [
'days' => (int) $days,
'credits' => $credits,
@ -38,7 +38,9 @@ class PreisePageData
'plans' => Plan::query()->active()->get(),
'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'),
'extraPm' => $extraPm,
'boost' => $boost,
'boost' => $staffel(config('credits.boost', [])),
// Kategorie-Boost (nur Branchenseite, halber Satz — 13.07.2026).
'boostCategory' => $staffel(config('credits.boost_category', [])),
'proofPdf' => (int) config('credits.proof_pdf'),
'creditPacks' => config('credits.packs', []),
// TODO: Lineup/Preise hängen am Bookings-/Magic-Link-Rework — final abstimmen.