Proof-PDF + Extra-PM-Verkauf ueber die Credit-Wallet (Decision-Update 2.1/2.3)

- ProofPdfService: Veroeffentlichungsnachweis 3 Credits pauschal, einmal pro
  PM (Zweitdownload kostenfrei); ProofPdfRenderer erzeugt das PDF on-demand
  aus vorhandenen PM-Daten (kein externer Renderer); GET-Download-Endpoint
  /admin/me/press-releases/{id}/nachweis hinter downloadProof-Policy + Kauf-Gate
- ExtraPmPurchaseService: tier-gestaffelter Nachkauf (19/15/12/10/8) aus der
  Wallet; verbucht als bezahlter SinglePurchase(ExtraPm) und greift damit in
  die bestehende Kontingent-/Slot-Mechanik. InsufficientCreditsException
  liefert das Mini-Checkout-Signal (required/available/shortfall)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-17 14:28:08 +00:00
parent c7a4c8bfd4
commit 69411b4c87
8 changed files with 609 additions and 0 deletions

View file

@ -0,0 +1,17 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Wird geworfen, wenn für eine PM kein Veröffentlichungsnachweis erstellt
* werden kann der Nachweis setzt eine veröffentlichte Meldung voraus (§2.3).
*/
class ProofPdfNotAvailableException extends RuntimeException
{
public static function notPublished(): self
{
return new self('Ein Veröffentlichungsnachweis ist nur für veröffentlichte Pressemitteilungen verfügbar.');
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Models\PressRelease;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\ProofPdfRenderer;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
/**
* Liefert den Veröffentlichungsnachweis-PDF einer PM zum Download. Der Nachweis
* muss zuvor gekauft sein (3 Credits, einmalig pro PM {@see ProofPdfService});
* der Kauf selbst erfolgt im Kundenbereich, nicht über diesen GET-Endpunkt.
*/
class ProofPdfController extends Controller
{
public function __invoke(
string $id,
ProofPdfService $service,
ProofPdfRenderer $renderer,
): Response {
$pressRelease = PressRelease::query()->withoutGlobalScopes()->findOrFail((int) $id);
Gate::authorize('downloadProof', $pressRelease);
abort_unless($service->hasPurchased(auth()->user(), $pressRelease), 403, 'Veröffentlichungsnachweis noch nicht gekauft.');
return $renderer->downloadResponse($pressRelease);
}
}

View file

@ -76,6 +76,16 @@ class PressReleasePolicy
return $user->canAccessAdmin() && $user->can('press-releases:publish');
}
/**
* Veröffentlichungsnachweis darf laden, wer die PM verwalten darf (Autor
* oder Firmenmitglied) bzw. ein Admin. Ob der Nachweis bereits gekauft
* wurde, prüft der ProofPdfService separat.
*/
public function downloadProof(User $user, PressRelease $pressRelease): bool
{
return $user->canAccessAdmin() || $this->canManage($user, $pressRelease);
}
/**
* Zugriff auf eine PM hat der Autor ODER ein Mitglied der zugeordneten
* Firma (Owner/Team-Mitglied). So sehen/bearbeiten Firmenkontakte inkl.

View file

@ -0,0 +1,64 @@
<?php
namespace App\Services\Billing;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\SinglePurchase;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Extra-PM-Nachkauf laut Decision-Update §2.1: die Brücke zwischen
* Tarif-Kontingent und Credit-Wallet. Ist das Monatskontingent voll, kann eine
* einzelne weitere PM über die Wallet nachgekauft werden Preis tier-gestaffelt
* und zur Kaufzeit aus dem aktiven Abo abgeleitet (nicht statisch gespeichert).
*
* Der Kauf wird als bezahlter SinglePurchase (Typ ExtraPm) verbucht und greift
* damit in die bestehende Kontingent-/Slot-Mechanik
* ({@see User::pressReleaseQuotaRemaining()},
* PressReleaseService::consumePublishSlot()).
*/
class ExtraPmPurchaseService
{
public function __construct(
private readonly CreditPricingService $pricing,
private readonly CreditWalletService $wallet,
) {}
/**
* Tier-abgeleiteter Extra-PM-Preis für den User (Credits).
*/
public function priceCredits(User $user): int
{
return $this->pricing->extraPmCreditsFor($user);
}
/**
* Bucht eine Extra-PM aus der Wallet. Wirft InsufficientCreditsException,
* wenn das Guthaben nicht reicht (Signal für den Mini-Checkout).
*/
public function purchase(User $user): SinglePurchase
{
$credits = $this->priceCredits($user);
return DB::transaction(function () use ($user, $credits): SinglePurchase {
$purchase = $user->singlePurchases()->create([
'type' => SinglePurchaseType::ExtraPm,
'status' => SinglePurchaseStatus::Paid,
'price_cents' => $credits * 100,
'currency' => 'eur',
'paid_at' => now(),
]);
$this->wallet->debit(
$user,
$credits,
'Extra-PM ('.$user->currentTier()->label().')',
$purchase,
);
return $purchase;
});
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Services\Billing;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Exceptions\ProofPdfNotAvailableException;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Kauf des Veröffentlichungsnachweis-PDF laut Decision-Update §2.3: 3 Credits
* pauschal pro PM aus der Wallet. Der Kauf wird als bezahlter SinglePurchase
* (Typ ProofPdf) festgehalten ein erneuter Download derselben PM ist danach
* kostenfrei (Impulskauf, keine Doppelbelastung).
*/
class ProofPdfService
{
public function __construct(
private readonly CreditPricingService $pricing,
private readonly CreditWalletService $wallet,
) {}
public function priceCredits(): int
{
return $this->pricing->proofPdfCredits();
}
/**
* Ein Nachweis ist nur für veröffentlichte Meldungen sinnvoll.
*/
public function isAvailable(PressRelease $pressRelease): bool
{
return $pressRelease->status === PressReleaseStatus::Published;
}
public function hasPurchased(User $user, PressRelease $pressRelease): bool
{
return $this->purchaseQuery($user, $pressRelease)->exists();
}
/**
* Kauft (oder liefert den bereits gekauften) Nachweis. Belastet die Wallet
* nur beim Erstkauf. Wirft ProofPdfNotAvailableException, wenn die PM nicht
* veröffentlicht ist, und InsufficientCreditsException ohne Deckung.
*/
public function purchase(User $user, PressRelease $pressRelease): SinglePurchase
{
if (! $this->isAvailable($pressRelease)) {
throw ProofPdfNotAvailableException::notPublished();
}
$existing = $this->purchaseQuery($user, $pressRelease)->first();
if ($existing) {
return $existing;
}
$credits = $this->priceCredits();
return DB::transaction(function () use ($user, $pressRelease, $credits): SinglePurchase {
$purchase = $user->singlePurchases()->create([
'type' => SinglePurchaseType::ProofPdf,
'status' => SinglePurchaseStatus::Paid,
'price_cents' => $credits * 100,
'currency' => 'eur',
'press_release_id' => $pressRelease->id,
'paid_at' => now(),
]);
$this->wallet->debit(
$user,
$credits,
"Veröffentlichungsnachweis PM #{$pressRelease->id}",
$purchase,
);
return $purchase;
});
}
private function purchaseQuery(User $user, PressRelease $pressRelease)
{
return $user->singlePurchases()
->where('type', SinglePurchaseType::ProofPdf->value)
->where('status', SinglePurchaseStatus::Paid->value)
->where('press_release_id', $pressRelease->id);
}
}

View file

@ -0,0 +1,232 @@
<?php
namespace App\Services\PressRelease;
use App\Enums\Portal;
use App\Models\PressRelease;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3
* („PM XY wurde am auf veröffentlicht" inkl. URL, Datum, Vorschau).
* On-demand aus vorhandenen PM-Daten keine KI, kein externer Renderer
* (gleicher schlanker PDF-Ansatz wie der Legacy-Rechnungs-Renderer).
*/
class ProofPdfRenderer
{
private const PAGE_WIDTH = 595;
private const PAGE_HEIGHT = 842;
private const LEFT = 70;
private const RIGHT = 525;
public function downloadResponse(PressRelease $pressRelease): Response
{
$pdf = $this->render($pressRelease);
return response()->streamDownload(
static function () use ($pdf): void {
echo $pdf;
},
$this->filename($pressRelease),
[
'Content-Type' => 'application/pdf',
'Cache-Control' => 'private, max-age=0, must-revalidate',
],
);
}
public function filename(PressRelease $pressRelease): string
{
return 'Veroeffentlichungsnachweis-PM-'.$pressRelease->id.'.pdf';
}
public function render(PressRelease $pressRelease): string
{
$content = '';
$portalLabel = $pressRelease->portal instanceof Portal
? $pressRelease->portal->label()
: (string) $pressRelease->portal;
$publishedAt = $pressRelease->published_at?->copy()->setTimezone(PressRelease::DISPLAY_TIMEZONE);
$dateLabel = $publishedAt?->format('d.m.Y') ?? 'n/a';
$timeLabel = $publishedAt?->format('H:i') ?? null;
$content .= $this->text(self::LEFT, 790, 'Veröffentlichungsnachweis', 22, 'F2');
$content .= $this->line(self::LEFT, 778, self::RIGHT, 778, 0.7);
$content .= $this->text(self::LEFT, 760, $portalLabel, 11, 'F2');
$y = 712.0;
$headline = $timeLabel
? "Diese Pressemitteilung wurde am {$dateLabel} um {$timeLabel} Uhr auf {$portalLabel} veröffentlicht."
: "Diese Pressemitteilung wurde am {$dateLabel} auf {$portalLabel} veröffentlicht.";
$y = $this->wrappedText($content, self::LEFT, $y, $headline, 11, self::RIGHT - self::LEFT, 16);
$y -= 24;
$content .= $this->text(self::LEFT, $y, 'Titel', 9, 'F2');
$y = $this->wrappedText($content, self::LEFT, $y - 15, (string) $pressRelease->title, 13, self::RIGHT - self::LEFT, 17, 'F2');
$y -= 18;
foreach ($this->metaRows($pressRelease, $portalLabel, $dateLabel) as [$label, $value]) {
$content .= $this->text(self::LEFT, $y, $label, 9, 'F2');
$this->wrappedText($content, self::LEFT + 110, $y, $value, 9, self::RIGHT - self::LEFT - 110, 12);
$y -= 20;
}
$y -= 10;
$content .= $this->text(self::LEFT, $y, 'Vorschau', 9, 'F2');
$this->wrappedText($content, self::LEFT, $y - 15, $this->previewText($pressRelease), 9, self::RIGHT - self::LEFT, 12);
$content .= $this->line(self::LEFT, 70, self::RIGHT, 70, 0.5);
$content .= $this->text(self::LEFT, 56, 'Erstellt über pressekonto maschinell erzeugter Nachweis.', 8);
return $this->buildPdf($content);
}
/**
* @return list<array{0: string, 1: string}>
*/
private function metaRows(PressRelease $pressRelease, string $portalLabel, string $dateLabel): array
{
return array_values(array_filter([
['Portal', $portalLabel],
['Veröffentlicht', $dateLabel],
['URL', $this->publicUrl($pressRelease)],
$pressRelease->category?->name ? ['Kategorie', (string) $pressRelease->category->name] : null,
]));
}
private function publicUrl(PressRelease $pressRelease): string
{
$base = match ($pressRelease->portal) {
Portal::Presseecho => config('domains.domain_presseecho_url'),
Portal::Businessportal24 => config('domains.domain_businessportal_url'),
default => config('app.url'),
};
return rtrim((string) $base, '/').'/'.ltrim((string) $pressRelease->slug, '/');
}
private function previewText(PressRelease $pressRelease): string
{
$plain = trim(html_entity_decode(strip_tags((string) $pressRelease->text)));
return Str::limit($plain, 600);
}
private function buildPdf(string $content): string
{
$objects = [
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n",
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
"6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
];
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $object) {
$offsets[] = strlen($pdf);
$pdf .= $object;
}
$xrefOffset = strlen($pdf);
$pdf .= "xref\n0 ".(count($objects) + 1)."\n";
$pdf .= "0000000000 65535 f \n";
foreach (array_slice($offsets, 1) as $offset) {
$pdf .= sprintf("%010d 00000 n \n", $offset);
}
$pdf .= "trailer\n<< /Size ".(count($objects) + 1)." /Root 1 0 R >>\n";
$pdf .= "startxref\n{$xrefOffset}\n%%EOF\n";
return $pdf;
}
private function escapePdfText(string $text): string
{
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
}
private function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1'): string
{
return sprintf(
"BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n",
$font,
$size,
$x,
$y,
$this->escapePdfText($text),
);
}
private function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string
{
return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2);
}
private function wrappedText(
string &$content,
float $x,
float $y,
string $text,
int $size,
float $width,
float $lineHeight = 12,
string $font = 'F1',
): float {
foreach ($this->wrap($text, $width, $size) as $line) {
if ($line !== '') {
$content .= $this->text($x, $y, $line, $size, $font);
}
$y -= $lineHeight;
}
return $y;
}
/**
* @return list<string>
*/
private function wrap(string $text, float $width, int $size): array
{
$lines = [];
$maxCharacters = max(8, (int) floor($width / ($size * 0.5)));
foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $text)) as $paragraph) {
if (trim($paragraph) === '') {
$lines[] = '';
continue;
}
$line = '';
foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) {
$candidate = trim($line.' '.$word);
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
$lines[] = $line;
$line = $word;
continue;
}
$line = $candidate;
}
if ($line !== '') {
$lines[] = $line;
}
}
return $lines;
}
}

View file

@ -2,6 +2,7 @@
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\LegacyInvoicePdfController;
use App\Http\Controllers\ProofPdfController;
use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests;
use Illuminate\Support\Facades\Route;
@ -24,6 +25,9 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
Volt::route('press-releases/create', 'customer.press-releases.create')->name('press-releases.create');
Volt::route('press-releases/{id}', 'customer.press-releases.show')->name('press-releases.show');
Volt::route('press-releases/{id}/edit', 'customer.press-releases.edit')->name('press-releases.edit');
Route::get('press-releases/{id}/nachweis', ProofPdfController::class)
->where('id', '[0-9]+')
->name('press-releases.proof');
Volt::route('firmen', 'customer.press-kits.index')->name('press-kits.index');
Volt::route('firmen/anlegen', 'customer.press-kits.create')->name('press-kits.create');

View file

@ -0,0 +1,159 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Exceptions\InsufficientCreditsException;
use App\Exceptions\ProofPdfNotAvailableException;
use App\Models\Plan;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Billing\CreditWalletService;
use App\Services\Billing\ExtraPmPurchaseService;
use App\Services\Billing\ProofPdfService;
use App\Services\PressRelease\ProofPdfRenderer;
use Database\Seeders\RolesAndPermissionsSeeder;
beforeEach(function (): void {
$this->seed(RolesAndPermissionsSeeder::class);
$this->wallet = app(CreditWalletService::class);
$this->proof = app(ProofPdfService::class);
$this->extraPm = app(ExtraPmPurchaseService::class);
});
function customerWithRole(): User
{
$user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]);
$user->assignRole('customer');
return $user;
}
// ---------------------------------------------------------------- Proof-PDF
test('buying a proof debits three credits and records a paid purchase', function () {
$user = customerWithRole();
$this->wallet->credit($user, 10);
$pr = PressRelease::factory()->published()->create(['user_id' => $user->id]);
$purchase = $this->proof->purchase($user, $pr);
expect($purchase->type)->toBe(SinglePurchaseType::ProofPdf);
expect($purchase->status)->toBe(SinglePurchaseStatus::Paid);
expect($this->wallet->balance($user))->toBe(7);
expect($this->proof->hasPurchased($user, $pr))->toBeTrue();
});
test('re-buying the same proof does not charge again', function () {
$user = customerWithRole();
$this->wallet->credit($user, 10);
$pr = PressRelease::factory()->published()->create(['user_id' => $user->id]);
$first = $this->proof->purchase($user, $pr);
$second = $this->proof->purchase($user, $pr);
expect($second->id)->toBe($first->id);
expect($this->wallet->balance($user))->toBe(7);
expect($user->singlePurchases()->where('type', SinglePurchaseType::ProofPdf->value)->count())->toBe(1);
});
test('a proof for an unpublished release is rejected', function () {
$user = customerWithRole();
$this->wallet->credit($user, 10);
$draft = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]);
expect(fn () => $this->proof->purchase($user, $draft))
->toThrow(ProofPdfNotAvailableException::class);
expect($this->wallet->balance($user))->toBe(10);
});
test('a proof without enough credits throws and records nothing', function () {
$user = customerWithRole();
$this->wallet->credit($user, 2);
$pr = PressRelease::factory()->published()->create(['user_id' => $user->id]);
expect(fn () => $this->proof->purchase($user, $pr))
->toThrow(InsufficientCreditsException::class);
expect($user->singlePurchases()->count())->toBe(0);
});
test('the rendered proof is a non-trivial pdf document', function () {
$pr = PressRelease::factory()->published()->create(['title' => 'Testmeldung', 'text' => str_repeat('Inhalt. ', 50)]);
$pdf = app(ProofPdfRenderer::class)->render($pr);
expect($pdf)->toStartWith('%PDF-1.4');
expect($pdf)->toContain('%%EOF');
expect(strlen($pdf))->toBeGreaterThan(800);
});
test('the proof download requires a prior purchase', function () {
$user = customerWithRole();
$pr = PressRelease::factory()->published()->create(['user_id' => $user->id]);
// Ohne Kauf: 403.
$this->actingAs($user)
->get(route('me.press-releases.proof', $pr->id))
->assertForbidden();
// Nach Kauf: PDF-Download.
$this->wallet->credit($user, 5);
$this->proof->purchase($user, $pr);
$this->actingAs($user)
->get(route('me.press-releases.proof', $pr->id))
->assertOk()
->assertHeader('content-type', 'application/pdf');
});
// ---------------------------------------------------------------- Extra-PM
test('an Einzel user pays the full extra-pm rate from the wallet', function () {
$user = customerWithRole();
$this->wallet->credit($user, 25);
$purchase = $this->extraPm->purchase($user);
expect($purchase->type)->toBe(SinglePurchaseType::ExtraPm);
expect($purchase->status)->toBe(SinglePurchaseStatus::Paid);
expect($purchase->price_cents)->toBe(1900);
expect($this->wallet->balance($user))->toBe(6);
});
test('an extra-pm purchase extends the press release quota', function () {
config()->set('billing.enforce_booking', true);
$user = customerWithRole();
$plan = Plan::factory()->create([
'slug' => 'business',
'press_release_quota' => 3,
'stripe_price_id_monthly' => 'price_biz_'.fake()->unique()->randomNumber(6),
]);
subscribeUserToPlan($user, $plan);
$user->update(['press_release_quota_used_this_month' => 3]); // Kontingent voll
$this->wallet->credit($user, 20);
expect($user->pressReleaseQuotaRemaining())->toBe(0);
// Business-Tier zahlt 12 Credits für die Extra-PM.
$this->extraPm->purchase($user);
expect($this->wallet->balance($user))->toBe(8);
expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(1);
});
test('an extra-pm without enough credits throws the mini-checkout signal', function () {
$user = customerWithRole();
$this->wallet->credit($user, 8);
try {
$this->extraPm->purchase($user);
$this->fail('Expected InsufficientCreditsException.');
} catch (InsufficientCreditsException $e) {
expect($e->required)->toBe(19);
expect($e->available)->toBe(8);
expect($e->shortfall())->toBe(11);
}
expect($user->singlePurchases()->count())->toBe(0);
});