From c7a4c8bfd46c8c4217bc92b5f3f5bba3d3005e94 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 17 Jun 2026 14:22:12 +0000 Subject: [PATCH] Boost-Geschaeftslogik (Decision-Update 2.2) Bezahlte Platzierung gruener, veroeffentlichter PMs ueber die Credit-Wallet: - boosts Tabelle (Zeitraum starts_at/ends_at, days, credits_charged) - BoostService: Gate (nur Published + Green), Preis nach Laufzeit (7/14/30 -> 12/20/35 Credits), Mehrfachkauf verlaengert vom laufenden Ende, Wallet-Belastung referenziert den Boost im Ledger - PressRelease::boosts()/isBoosted()/scopeBoosted() als Basis fuer die Featured-Platzierung (Frontend-Anbindung bleibt der Web-Strecke ueberlassen) Co-Authored-By: Claude Opus 4.8 --- app/Exceptions/BoostNotAllowedException.php | 17 ++++ app/Models/Boost.php | 57 +++++++++++ app/Models/PressRelease.php | 23 +++++ app/Services/PressRelease/BoostService.php | 87 ++++++++++++++++ database/factories/BoostFactory.php | 37 +++++++ .../2026_06_17_142008_create_boosts_table.php | 36 +++++++ tests/Feature/BoostServiceTest.php | 98 +++++++++++++++++++ 7 files changed, 355 insertions(+) create mode 100644 app/Exceptions/BoostNotAllowedException.php create mode 100644 app/Models/Boost.php create mode 100644 app/Services/PressRelease/BoostService.php create mode 100644 database/factories/BoostFactory.php create mode 100644 database/migrations/2026_06_17_142008_create_boosts_table.php create mode 100644 tests/Feature/BoostServiceTest.php diff --git a/app/Exceptions/BoostNotAllowedException.php b/app/Exceptions/BoostNotAllowedException.php new file mode 100644 index 0000000..9774931 --- /dev/null +++ b/app/Exceptions/BoostNotAllowedException.php @@ -0,0 +1,17 @@ + */ + use HasFactory; + + protected $fillable = [ + 'press_release_id', + 'user_id', + 'days', + 'credits_charged', + 'starts_at', + 'ends_at', + ]; + + protected function casts(): array + { + return [ + 'days' => 'integer', + 'credits_charged' => 'integer', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } + + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Boosts, deren Platzierung gerade aktiv ist (läuft noch). + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('ends_at', '>', now()); + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 3f4e5ad..f4d0e8d 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -11,6 +11,7 @@ use App\Models\Concerns\HasUniqueSlug; use App\Scopes\PortalScope; use App\Services\PressRelease\PressReleaseHtmlSanitizer; use Database\Factories\PressReleaseFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -167,6 +168,28 @@ class PressRelease extends Model return $this->hasMany(KiAudit::class)->orderByDesc('created_at'); } + public function boosts(): HasMany + { + return $this->hasMany(Boost::class); + } + + /** + * Aktuell geboostet (mindestens ein laufender Boost-Zeitraum)? + */ + public function isBoosted(): bool + { + return $this->boosts()->active()->exists(); + } + + /** + * Beschränkt auf PMs mit aktuell laufendem Boost — Basis für die + * Featured-Platzierung auf Start-/Branchenseite. + */ + public function scopeBoosted(Builder $query): Builder + { + return $query->whereHas('boosts', fn (Builder $q) => $q->active()); + } + /** * Display-ready text. Returns sanitized HTML for Phase-7+ PMs and *

/
-wrapped legacy plain text for older imports. diff --git a/app/Services/PressRelease/BoostService.php b/app/Services/PressRelease/BoostService.php new file mode 100644 index 0000000..a2d4d4f --- /dev/null +++ b/app/Services/PressRelease/BoostService.php @@ -0,0 +1,87 @@ +status === PressReleaseStatus::Published + && $pressRelease->classification === PressReleaseClassification::Green; + } + + public function isBoosted(PressRelease $pressRelease): bool + { + return $pressRelease->boosts()->active()->exists(); + } + + /** + * Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`) + * oder null, wenn die PM gerade nicht geboostet ist. + */ + public function activeUntil(PressRelease $pressRelease): ?Carbon + { + $endsAt = $pressRelease->boosts()->active()->max('ends_at'); + + return $endsAt ? Carbon::parse($endsAt) : null; + } + + /** + * Bucht einen Boost. Wirft BoostNotAllowedException am Gate 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 + { + if (! $this->canBoost($pressRelease)) { + throw BoostNotAllowedException::notBoostable(); + } + + $credits = $this->pricing->boostCredits($days); + + return DB::transaction(function () use ($user, $pressRelease, $days, $credits): Boost { + $startsAt = $this->activeUntil($pressRelease) ?? now(); + + $boost = $pressRelease->boosts()->create([ + 'user_id' => $user->id, + 'days' => $days, + 'credits_charged' => $credits, + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->copy()->addDays($days), + ]); + + $this->wallet->debit( + $user, + $credits, + "Boost {$days} Tage – PM #{$pressRelease->id}", + $boost, + ); + + return $boost; + }); + } +} diff --git a/database/factories/BoostFactory.php b/database/factories/BoostFactory.php new file mode 100644 index 0000000..19c6b8b --- /dev/null +++ b/database/factories/BoostFactory.php @@ -0,0 +1,37 @@ + + */ +class BoostFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'press_release_id' => PressRelease::factory(), + 'user_id' => User::factory(), + 'days' => 7, + 'credits_charged' => 12, + 'starts_at' => now(), + 'ends_at' => now()->addDays(7), + ]; + } + + public function expired(): static + { + return $this->state(fn (): array => [ + 'starts_at' => now()->subDays(14), + 'ends_at' => now()->subDay(), + ]); + } +} diff --git a/database/migrations/2026_06_17_142008_create_boosts_table.php b/database/migrations/2026_06_17_142008_create_boosts_table.php new file mode 100644 index 0000000..f533393 --- /dev/null +++ b/database/migrations/2026_06_17_142008_create_boosts_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('press_release_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedSmallInteger('days'); + $table->integer('credits_charged'); + $table->timestamp('starts_at'); + $table->timestamp('ends_at'); + $table->timestamps(); + + $table->index(['press_release_id', 'ends_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('boosts'); + } +}; diff --git a/tests/Feature/BoostServiceTest.php b/tests/Feature/BoostServiceTest.php new file mode 100644 index 0000000..8aa1cef --- /dev/null +++ b/tests/Feature/BoostServiceTest.php @@ -0,0 +1,98 @@ +service = app(BoostService::class); + $this->wallet = app(CreditWalletService::class); +}); + +function boostableRelease(User $user): PressRelease +{ + return PressRelease::factory()->published()->create([ + 'user_id' => $user->id, + 'classification' => PressReleaseClassification::Green->value, + ]); +} + +test('only published green releases are boostable', function () { + $user = User::factory()->create(); + + expect($this->service->canBoost(boostableRelease($user)))->toBeTrue(); + + $yellow = PressRelease::factory()->published()->create([ + 'classification' => PressReleaseClassification::Yellow->value, + ]); + expect($this->service->canBoost($yellow))->toBeFalse(); + + $draftGreen = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Draft->value, + 'classification' => PressReleaseClassification::Green->value, + ]); + expect($this->service->canBoost($draftGreen))->toBeFalse(); +}); + +test('booking a boost debits the wallet and activates the placement', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 20); + $pr = boostableRelease($user); + + $boost = $this->service->boost($user, $pr, 14); + + expect($boost->days)->toBe(14); + expect($boost->credits_charged)->toBe(20); + expect($this->wallet->balance($user))->toBe(0); + expect($this->service->isBoosted($pr))->toBeTrue(); + expect($pr->fresh()->isBoosted())->toBeTrue(); +}); + +test('boosting a non-green release is rejected at the gate', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 50); + $red = PressRelease::factory()->published()->create([ + 'classification' => PressReleaseClassification::Red->value, + ]); + + expect(fn () => $this->service->boost($user, $red, 7)) + ->toThrow(BoostNotAllowedException::class); + expect($this->wallet->balance($user))->toBe(50); +}); + +test('an unaffordable boost throws and creates no boost record', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 5); + $pr = boostableRelease($user); + + expect(fn () => $this->service->boost($user, $pr, 7)) + ->toThrow(InsufficientCreditsException::class); + expect($pr->boosts()->count())->toBe(0); + expect($this->wallet->balance($user))->toBe(5); +}); + +test('an unknown duration is rejected', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 50); + $pr = boostableRelease($user); + + expect(fn () => $this->service->boost($user, $pr, 99)) + ->toThrow(InvalidArgumentException::class); +}); + +test('a second boost extends from the running end instead of overlapping', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 100); + $pr = boostableRelease($user); + + $first = $this->service->boost($user, $pr, 7); + $second = $this->service->boost($user, $pr, 14); + + expect($second->starts_at->timestamp)->toBe($first->ends_at->timestamp); + expect($second->ends_at->timestamp)->toBe($first->ends_at->copy()->addDays(14)->timestamp); +});