From 77a4476fd00b97bda54f627037365d93f9fc2150 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 17 Jun 2026 15:02:31 +0000 Subject: [PATCH] UI: Credit-Wallet-Hub + Per-PM Add-ons im Backend-Pressekonto Wallet-Oekonomie im Customer-Bereich sichtbar gemacht: - Buchungen & Add-ons: Guthaben-Anzeige, Credit-Paket-Topup (Bonus-Staffel), Extra-PM-Kauf aus der Wallet mit kontextuellem Mini-Checkout-Hinweis, Pruefkontingent-Status (frei/Monat, heute/Tageslimit), Wallet-Verlauf - PM-Detailseite: Add-ons-Panel fuer veroeffentlichte PMs -- Boost buchen (7/14/30 Tage, Gate nur gruen, Verlaengerung), Veroeffentlichungsnachweis kaufen + Download. Aktionen ueber BoostService/ProofPdfService Co-Authored-By: Claude Opus 4.8 --- .../livewire/customer/bookings.blade.php | 193 +++++++++++++++++- .../customer/press-releases/show.blade.php | 145 +++++++++++++ tests/Feature/Billing/BookingsPageTest.php | 46 +++++ tests/Feature/PressReleaseShowAddonsTest.php | 100 +++++++++ 4 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/PressReleaseShowAddonsTest.php diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 0f0b0a7..46612f6 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -3,8 +3,14 @@ use App\Enums\Portal; use App\Enums\SinglePurchaseStatus; use App\Enums\UserPaymentOptionStatus; +use App\Exceptions\InsufficientCreditsException; use App\Models\Plan; use App\Models\UserPaymentOption; +use App\Services\Billing\CreditPricingService; +use App\Services\Billing\CreditTopupService; +use App\Services\Billing\CreditWalletService; +use App\Services\Billing\ExtraPmPurchaseService; +use App\Services\PressRelease\ReviewCheckService; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Volt\Component; @@ -12,15 +18,41 @@ use Livewire\Volt\Component; /** * Buchungen & Add-ons (Phase 9F): Tarif-Raster mit Stripe-Checkout, * Einzel-PM als separater No-Abo-Block, Bestandstarife (MAN-Kreis) und - * echte Buchungsdaten. Launch-Credits (Extra-PM, Boost, Nachweis-PDF) - * folgen mit Phase 9I, das Credit-Wallet mit Phase 2. + * echte Buchungsdaten. Die Credit-Wallet (Topup, Extra-PM, Prüfkontingent) + * bildet den Add-on-Block laut Decision-Update (Rev. 4) ab. */ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component { + /** Erfolgs-/Fehlerhinweis des Wallet-Blocks (Extra-PM-Kauf). */ + public ?string $walletNotice = null; + + public ?string $walletError = null; + public function formatEuro(int $cents): string { return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.') . ' €'; } + /** + * Extra-PM aus der Wallet nachkaufen (tier-gestaffelter Preis). Reicht das + * Guthaben nicht, zeigt der kontextuelle Mini-Checkout den Fehlbetrag. + */ + public function buyExtraPm(ExtraPmPurchaseService $service): void + { + $this->walletNotice = null; + $this->walletError = null; + + try { + $service->purchase(auth()->user()); + $this->walletNotice = __('Extra-Pressemitteilung gebucht — Ihr PM-Kontingent wurde um eine Veröffentlichung erhöht.'); + } catch (InsufficientCreditsException $e) { + $this->walletError = __('Die Extra-PM kostet :need Credits, Ihr Guthaben beträgt :have. Bitte laden Sie mindestens :short Credits nach.', [ + 'need' => $e->required, + 'have' => $e->available, + 'short' => $e->shortfall(), + ]); + } + } + public function planIcon(Plan $plan): string { return match ($plan->slug) { @@ -91,6 +123,16 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte 'quotaTotal' => $user->pressReleaseQuotaTotal(), 'singlePmPrice' => $this->formatEuro((int) config('billing.single_pm_price_cents')), 'singlePmAvailable' => (bool) config('billing.single_pm_stripe_price_id'), + + // Credit-Wallet & Add-ons (Decision-Update Rev. 4). + 'creditBalance' => $user->creditBalance(), + 'creditPacks' => app(CreditTopupService::class)->packs(), + 'extraPmPrice' => app(CreditPricingService::class)->extraPmCreditsFor($user), + 'reviewFreeRemaining' => app(ReviewCheckService::class)->freeRemaining($user), + 'reviewFreeQuota' => app(CreditPricingService::class)->reviewFreeQuota($user->currentTier()), + 'reviewUsedToday' => app(ReviewCheckService::class)->usedToday($user), + 'reviewDailyLimit' => app(CreditPricingService::class)->reviewDailyLimit(), + 'recentTransactions' => $user->creditTransactions()->latest()->limit(6)->get(), ]; } }; ?> @@ -308,6 +350,153 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @endif + {{-- ============== CREDIT-WALLET & ADD-ONS ============== --}} +
+
+ {{ __('Credit-Wallet & Add-ons') }} +

+ {{ __('Guthaben, Extra-PM und Prüfungen') }} +

+

+ {{ __('1 Credit = 1 €. Mit Guthaben kaufen Sie Extra-Pressemitteilungen, Boosts und Veröffentlichungsnachweise — größere Pakete enthalten Bonus-Credits.') }} +

+
+ + {{-- Guthaben + Aufladen --}} + + + {{-- Mini-Checkout-Hinweise (Extra-PM) --}} + @if ($walletNotice) +
+ +
{{ $walletNotice }}
+
+ @endif + @if ($walletError) +
+ +
{{ $walletError }}
+
+ @endif + +
+ {{-- Extra-PM --}} +
+
+
+
+ +
+
+

{{ __('Extra-Pressemitteilung') }}

+

+ {{ __('Eine weitere PM, wenn Ihr Monatskontingent voll ist — ohne Tarifwechsel. Preis je nach Tarif.') }} +

+
+
+
+
+ {{ $extraPmPrice }} + {{ __('Credits') }} +
+ + {{ __('Aus Guthaben buchen') }} + +
+
+
+ + {{-- Prüfkontingent --}} +
+
+
+
+ +
+
+

{{ __('Prüfkontingent') }}

+

+ {{ __('Inklusive Vorab-Prüfungen pro Monat. Darüber hinaus zieht jede Prüfung 1 Credit.') }} +

+
+
+
+
+ {{ __('Frei diesen Monat') }} + {{ $reviewFreeRemaining }} / {{ $reviewFreeQuota }} +
+
+ {{ __('Heute genutzt') }} + {{ $reviewUsedToday }} / {{ $reviewDailyLimit }} {{ __('Tageslimit') }} +
+
+
+
+
+ + {{-- Wallet-Verlauf --}} + @if ($recentTransactions->isNotEmpty()) +
+
+
{{ __('Letzte Wallet-Buchungen') }}
+
+ @foreach ($recentTransactions as $tx) +
+
+
{{ $tx->description ?? $tx->type->label() }}
+
{{ $tx->created_at?->format('d.m.Y H:i') }}
+
+
$tx->amount_credits > 0, + 'text-[color:var(--color-ink-2)]' => $tx->amount_credits < 0, + ])> + {{ $tx->amount_credits > 0 ? '+' : '' }}{{ $tx->amount_credits }} +
+
+ @endforeach +
+
+
+ @endif +
+ {{-- ============== TARIF-RASTER ============== --}}
diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index b324a0e..1a74a34 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -1,10 +1,15 @@ getMyPR(); + $this->authorize('view', $pr); + + try { + app(BoostService::class)->boost(auth()->user(), $pr, $days); + Flux::toast( + heading: __('Boost gebucht'), + text: __('Ihre Pressemitteilung wird jetzt auf Start- und Branchenseite hervorgehoben.'), + variant: 'success', + ); + } catch (InsufficientCreditsException $e) { + Flux::toast( + heading: __('Nicht genügend Credits'), + text: __('Der Boost kostet :need Credits, Ihr Guthaben beträgt :have. Laden Sie unter „Buchungen & Add-ons" auf.', ['need' => $e->required, 'have' => $e->available]), + variant: 'warning', + duration: 8000, + ); + } catch (BoostNotAllowedException $e) { + Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000); + } + } + + /** + * Veröffentlichungsnachweis-PDF kaufen (3 Credits, einmalig je PM). Danach + * steht der Download über die Nachweis-Route bereit. + */ + public function buyProof(): void + { + $pr = $this->getMyPR(); + $this->authorize('downloadProof', $pr); + + try { + app(ProofPdfService::class)->purchase(auth()->user(), $pr); + Flux::toast( + heading: __('Nachweis gekauft'), + text: __('Der Veröffentlichungsnachweis steht jetzt zum Download bereit.'), + variant: 'success', + ); + } catch (InsufficientCreditsException $e) { + Flux::toast( + heading: __('Nicht genügend Credits'), + text: __('Der Nachweis kostet :need Credits, Ihr Guthaben beträgt :have.', ['need' => $e->required, 'have' => $e->available]), + variant: 'warning', + duration: 8000, + ); + } + } + public function with(): array { $pr = $this->getMyPR(); @@ -101,8 +160,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $company = $pr->company?->loadCount(['pressReleases', 'contacts']); + $boostService = app(BoostService::class); + $proofService = app(ProofPdfService::class); + return [ 'pr' => $pr, + 'creditBalance' => $user->creditBalance(), + 'canBoost' => $boostService->canBoost($pr), + 'isBoosted' => $boostService->isBoosted($pr), + 'boostedUntil' => $boostService->activeUntil($pr), + 'boostOptions' => app(CreditPricingService::class)->boostOptions(), + 'proofPrice' => $proofService->priceCredits(), + 'proofPurchased' => $proofService->hasPurchased($user, $pr), 'companyLogoUrl' => $company?->logoUrl(), 'companyInitials' => $this->companyInitials($company?->name), 'companyMetaLine' => $this->companyMetaLine($company), @@ -349,6 +418,82 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @endif + {{-- ============== ADD-ONS (Boost + Veröffentlichungsnachweis) ============== --}} + @if ($pr->status === PressReleaseStatus::Published) +
+
+ {{ __('Add-ons') }} + {{ __(':n Credits Guthaben', ['n' => $creditBalance]) }} +
+
+ {{-- Boost --}} +
+
+
+ +
+
+

{{ __('Boost — Platzierung') }}

+

+ {{ __('Hervorhebung auf Start- und Branchenseite.') }} +

+
+
+ + @if ($isBoosted) +
+ {{ __('Aktiv bis :date', ['date' => $boostedUntil?->copy()->setTimezone(\App\Models\PressRelease::DISPLAY_TIMEZONE)->format('d.m.Y H:i')]) }} +
+ @endif + + @if ($canBoost) +
+ @foreach ($boostOptions as $days => $credits) + + {{ __(':days Tage · :credits Credits', ['days' => $days, 'credits' => $credits]) }} + + @endforeach +
+ @if ($isBoosted) +

{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}

+ @endif + @else +

+ {{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }} +

+ @endif +
+ + {{-- Veröffentlichungsnachweis --}} +
+
+
+ +
+
+

{{ __('Veröffentlichungsnachweis') }}

+

+ {{ __('PDF mit Datum, Portal und URL — :credits Credits.', ['credits' => $proofPrice]) }} +

+
+
+ + @if ($proofPurchased) + + {{ __('Nachweis herunterladen') }} + + @else + + {{ __('Für :credits Credits kaufen', ['credits' => $proofPrice]) }} + + @endif +
+
+
+ @endif + @if ($pr->status === PressReleaseStatus::Review)
diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php index f241a74..3c19a60 100644 --- a/tests/Feature/Billing/BookingsPageTest.php +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -5,6 +5,7 @@ use App\Models\Plan; use App\Models\SinglePurchase; use App\Models\User; use App\Models\UserPaymentOption; +use App\Services\Billing\CreditWalletService; use App\Services\Billing\StripeCheckoutService; use Database\Seeders\RolesAndPermissionsSeeder; use Livewire\Volt\Volt as LivewireVolt; @@ -162,3 +163,48 @@ test('the checkout success banner is shown after returning from stripe', functio ->assertOk() ->assertSee('Vielen Dank für Ihre Buchung!'); }); + +test('the wallet block shows the balance and the credit pack topup links', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + app(CreditWalletService::class)->credit($user, 30); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('Credit-Wallet & Add-ons') + ->assertSee('30') + ->assertSee('27 Credits') // Bonus-Paket + ->assertSee(route('me.checkout.credit-topup', ['pack' => 'p25']), false) + ->assertSee('Prüfkontingent') + ->assertSee('4 / 4'); // Einzel-Tier: 4 Freiprüfungen +}); + +test('buying an extra pm from the wallet succeeds with enough credits', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + app(CreditWalletService::class)->credit($user, 25); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->call('buyExtraPm') + ->assertSee('Extra-Pressemitteilung gebucht'); + + expect($user->fresh()->creditBalance())->toBe(6); // 25 - 19 (Einzel-Tier) + expect($user->singlePurchases()->grantingSubmission()->count())->toBe(1); +}); + +test('buying an extra pm without enough credits shows the mini checkout hint', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + app(CreditWalletService::class)->credit($user, 8); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->call('buyExtraPm') + ->assertSee('Bitte laden Sie mindestens 11 Credits nach'); + + expect($user->singlePurchases()->count())->toBe(0); +}); diff --git a/tests/Feature/PressReleaseShowAddonsTest.php b/tests/Feature/PressReleaseShowAddonsTest.php new file mode 100644 index 0000000..4bb5713 --- /dev/null +++ b/tests/Feature/PressReleaseShowAddonsTest.php @@ -0,0 +1,100 @@ +seed(RolesAndPermissionsSeeder::class); + $this->wallet = app(CreditWalletService::class); +}); + +/** + * @return array{customer: User, pr: PressRelease} + */ +function publishedGreenPrForOwner(): array +{ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $company = Company::factory()->presseecho()->create(); + $customer->companies()->attach($company->id, ['role' => 'owner']); + + $pr = PressRelease::factory()->published()->create([ + 'user_id' => $customer->id, + 'company_id' => $company->id, + 'category_id' => Category::factory()->create()->id, + 'portal' => $company->portal->value, + 'classification' => PressReleaseClassification::Green->value, + ]); + + return compact('customer', 'pr'); +} + +test('a published green release shows the boost and proof add-ons', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); + $this->wallet->credit($customer, 40); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('Add-ons') + ->assertSee('Boost — Platzierung') + ->assertSee('7 Tage · 12 Credits') + ->assertSee('Veröffentlichungsnachweis') + ->assertSee('Für 3 Credits kaufen'); +}); + +test('booking a boost from the detail page debits the wallet and activates it', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); + $this->wallet->credit($customer, 40); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->call('buyBoost', 14); + + expect($customer->fresh()->creditBalance())->toBe(20); // 40 - 20 (14 Tage) + expect($pr->fresh()->isBoosted())->toBeTrue(); +}); + +test('buying a proof from the detail page unlocks the download', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); + $this->wallet->credit($customer, 10); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->call('buyProof') + ->assertSee('Nachweis herunterladen'); + + expect(app(ProofPdfService::class)->hasPurchased($customer->fresh(), $pr))->toBeTrue(); + expect($customer->fresh()->creditBalance())->toBe(7); +}); + +test('a non-green release cannot be boosted from the detail page', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = publishedGreenPrForOwner(); + $pr->update(['classification' => PressReleaseClassification::Yellow->value]); + $this->wallet->credit($customer, 40); + + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('Nur grün geprüfte Pressemitteilungen sind boostbar') + ->call('buyBoost', 7); + + expect($customer->fresh()->creditBalance())->toBe(40); // nichts abgebucht + expect($pr->fresh()->isBoosted())->toBeFalse(); +});