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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-17 15:02:31 +00:00
parent 344aac0740
commit 77a4476fd0
4 changed files with 482 additions and 2 deletions

View file

@ -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
</article>
@endif
{{-- ============== CREDIT-WALLET & ADD-ONS ============== --}}
<section class="space-y-4">
<div>
<span class="section-eyebrow">{{ __('Credit-Wallet & Add-ons') }}</span>
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
{{ __('Guthaben, Extra-PM und Prüfungen') }}
</h2>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[640px] m-0">
{{ __('1 Credit = 1 €. Mit Guthaben kaufen Sie Extra-Pressemitteilungen, Boosts und Veröffentlichungsnachweise — größere Pakete enthalten Bonus-Credits.') }}
</p>
</div>
{{-- Guthaben + Aufladen --}}
<article class="panel">
<div class="p-5 grid gap-5 md:grid-cols-[0.9fr_1.6fr] md:items-center">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-[8px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.wallet class="size-6" />
</div>
<div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Ihr Guthaben') }}</div>
<div class="text-[28px] font-bold tracking-[-0.7px] leading-none text-[color:var(--color-ink)]">
{{ $creditBalance }} <span class="text-[14px] font-normal text-[color:var(--color-ink-3)]">{{ __('Credits') }}</span>
</div>
</div>
</div>
<div class="space-y-2">
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Guthaben aufladen') }}</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
@foreach ($creditPacks as $pack)
@php($bonus = $pack['credits'] - intdiv($pack['price_cents'], 100))
<a href="{{ route('me.checkout.credit-topup', ['pack' => $pack['key']]) }}"
class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-subtle)] p-3 text-center transition-colors hover:border-[color:var(--color-hub)] hover:bg-[color:var(--color-hub-soft)]"
wire:key="pack-{{ $pack['key'] }}">
<div class="text-[16px] font-bold text-[color:var(--color-ink)]">{{ $pack['credits'] }} {{ __('Credits') }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $this->formatEuro($pack['price_cents']) }} {{ __('netto') }}</div>
@if ($bonus > 0)
<span class="badge ok mt-1 inline-block">+{{ $bonus }} {{ __('Bonus') }}</span>
@endif
</a>
@endforeach
</div>
</div>
</div>
</article>
{{-- Mini-Checkout-Hinweise (Extra-PM) --}}
@if ($walletNotice)
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<div class="flex-1">{{ $walletNotice }}</div>
</div>
@endif
@if ($walletError)
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-bg-subtle)] border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-2)]">
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
<div class="flex-1">{{ $walletError }}</div>
</div>
@endif
<div class="grid gap-4 md:grid-cols-2">
{{-- Extra-PM --}}
<article class="panel">
<div class="p-5 space-y-4">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.document-plus class="size-5" />
</div>
<div class="min-w-0">
<h3 class="text-[15px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Extra-Pressemitteilung') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Eine weitere PM, wenn Ihr Monatskontingent voll ist — ohne Tarifwechsel. Preis je nach Tarif.') }}
</p>
</div>
</div>
<div class="flex items-center justify-between gap-3 border-t border-[color:var(--color-bg-rule)] pt-4">
<div>
<span class="text-[22px] font-bold text-[color:var(--color-ink)]">{{ $extraPmPrice }}</span>
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</span>
</div>
<flux:button size="sm" variant="primary" wire:click="buyExtraPm" wire:loading.attr="disabled">
{{ __('Aus Guthaben buchen') }}
</flux:button>
</div>
</div>
</article>
{{-- Prüfkontingent --}}
<article class="panel">
<div class="p-5 space-y-4">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.shield-check class="size-5" />
</div>
<div class="min-w-0">
<h3 class="text-[15px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Prüfkontingent') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Inklusive Vorab-Prüfungen pro Monat. Darüber hinaus zieht jede Prüfung 1 Credit.') }}
</p>
</div>
</div>
<div class="border-t border-[color:var(--color-bg-rule)] pt-4 space-y-1.5">
<div class="flex items-baseline justify-between">
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Frei diesen Monat') }}</span>
<span class="text-[16px] font-semibold text-[color:var(--color-ink)]">{{ $reviewFreeRemaining }} / {{ $reviewFreeQuota }}</span>
</div>
<div class="flex items-baseline justify-between">
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Heute genutzt') }}</span>
<span class="text-[13px] text-[color:var(--color-ink-2)]">{{ $reviewUsedToday }} / {{ $reviewDailyLimit }} {{ __('Tageslimit') }}</span>
</div>
</div>
</div>
</article>
</div>
{{-- Wallet-Verlauf --}}
@if ($recentTransactions->isNotEmpty())
<article class="panel">
<div class="p-5">
<div class="text-[12px] text-[color:var(--color-ink-3)] mb-3">{{ __('Letzte Wallet-Buchungen') }}</div>
<div class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($recentTransactions as $tx)
<div class="py-2.5 first:pt-0 last:pb-0 flex items-center justify-between gap-4"
wire:key="tx-{{ $tx->id }}">
<div class="min-w-0">
<div class="text-[13px] text-[color:var(--color-ink)] truncate">{{ $tx->description ?? $tx->type->label() }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $tx->created_at?->format('d.m.Y H:i') }}</div>
</div>
<div @class([
'text-[14px] font-semibold flex-shrink-0',
'text-[color:var(--color-gain-deep)]' => $tx->amount_credits > 0,
'text-[color:var(--color-ink-2)]' => $tx->amount_credits < 0,
])>
{{ $tx->amount_credits > 0 ? '+' : '' }}{{ $tx->amount_credits }}
</div>
</div>
@endforeach
</div>
</div>
</article>
@endif
</section>
{{-- ============== TARIF-RASTER ============== --}}
<section class="space-y-4" x-data="{ interval: '{{ $currentInterval ?? 'monthly' }}' }">
<div class="flex items-end justify-between gap-4 flex-wrap">

View file

@ -1,10 +1,15 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Exceptions\BoostNotAllowedException;
use App\Exceptions\InsufficientCreditsException;
use App\Models\PressRelease;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\BoostService;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
@ -83,6 +88,60 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
Flux::toast(text: __('Vorschau-Link wurde erzeugt.'), variant: 'success');
}
/**
* Boost (Platzierung) aus der Wallet buchen nur veröffentlichte, grüne
* PMs sind boostbar (Gate im BoostService).
*/
public function buyBoost(int $days): void
{
$pr = $this->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
</article>
@endif
{{-- ============== ADD-ONS (Boost + Veröffentlichungsnachweis) ============== --}}
@if ($pr->status === PressReleaseStatus::Published)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Add-ons') }}</span>
<span class="badge">{{ __(':n Credits Guthaben', ['n' => $creditBalance]) }}</span>
</div>
<div class="p-5 grid gap-5 md:grid-cols-2">
{{-- Boost --}}
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.rocket-launch class="size-[18px]" />
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Boost — Platzierung') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Hervorhebung auf Start- und Branchenseite.') }}
</p>
</div>
</div>
@if ($isBoosted)
<div class="rounded-[6px] border border-[color:var(--color-ok)]/30 bg-[color:var(--color-ok-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-gain-deep)]">
{{ __('Aktiv bis :date', ['date' => $boostedUntil?->copy()->setTimezone(\App\Models\PressRelease::DISPLAY_TIMEZONE)->format('d.m.Y H:i')]) }}
</div>
@endif
@if ($canBoost)
<div class="flex flex-wrap gap-2">
@foreach ($boostOptions as $days => $credits)
<flux:button size="sm" variant="filled"
wire:click="buyBoost({{ $days }})" wire:loading.attr="disabled">
{{ __(':days Tage · :credits Credits', ['days' => $days, 'credits' => $credits]) }}
</flux:button>
@endforeach
</div>
@if ($isBoosted)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">{{ __('Ein weiterer Kauf verlängert die Laufzeit.') }}</p>
@endif
@else
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Nur grün geprüfte Pressemitteilungen sind boostbar.') }}
</p>
@endif
</div>
{{-- Veröffentlichungsnachweis --}}
<div class="space-y-3 md:border-l md:border-[color:var(--color-bg-rule)] md:pl-5">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.document-check class="size-[18px]" />
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Veröffentlichungsnachweis') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('PDF mit Datum, Portal und URL — :credits Credits.', ['credits' => $proofPrice]) }}
</p>
</div>
</div>
@if ($proofPurchased)
<flux:button size="sm" variant="primary" icon="arrow-down-tray"
href="{{ route('me.press-releases.proof', $pr->id) }}">
{{ __('Nachweis herunterladen') }}
</flux:button>
@else
<flux:button size="sm" variant="primary" wire:click="buyProof" wire:loading.attr="disabled">
{{ __('Für :credits Credits kaufen', ['credits' => $proofPrice]) }}
</flux:button>
@endif
</div>
</div>
</article>
@endif
@if ($pr->status === PressReleaseStatus::Review)
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px; background:var(--color-warn-soft);">
<div class="panel-head">

View file

@ -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);
});

View file

@ -0,0 +1,100 @@
<?php
use App\Enums\PressReleaseClassification;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Billing\CreditWalletService;
use App\Services\Billing\ProofPdfService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->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();
});