From 69411b4c871f3cbd787166f9d36fa918a274ace5 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 17 Jun 2026 14:28:08 +0000 Subject: [PATCH] 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 --- .../ProofPdfNotAvailableException.php | 17 ++ app/Http/Controllers/ProofPdfController.php | 31 +++ app/Policies/PressReleasePolicy.php | 10 + .../Billing/ExtraPmPurchaseService.php | 64 +++++ app/Services/Billing/ProofPdfService.php | 92 +++++++ .../PressRelease/ProofPdfRenderer.php | 232 ++++++++++++++++++ routes/customer.php | 4 + tests/Feature/ProofPdfAndExtraPmTest.php | 159 ++++++++++++ 8 files changed, 609 insertions(+) create mode 100644 app/Exceptions/ProofPdfNotAvailableException.php create mode 100644 app/Http/Controllers/ProofPdfController.php create mode 100644 app/Services/Billing/ExtraPmPurchaseService.php create mode 100644 app/Services/Billing/ProofPdfService.php create mode 100644 app/Services/PressRelease/ProofPdfRenderer.php create mode 100644 tests/Feature/ProofPdfAndExtraPmTest.php diff --git a/app/Exceptions/ProofPdfNotAvailableException.php b/app/Exceptions/ProofPdfNotAvailableException.php new file mode 100644 index 0000000..cec9b64 --- /dev/null +++ b/app/Exceptions/ProofPdfNotAvailableException.php @@ -0,0 +1,17 @@ +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); + } +} diff --git a/app/Policies/PressReleasePolicy.php b/app/Policies/PressReleasePolicy.php index 09fd259..9c0d2ac 100644 --- a/app/Policies/PressReleasePolicy.php +++ b/app/Policies/PressReleasePolicy.php @@ -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. diff --git a/app/Services/Billing/ExtraPmPurchaseService.php b/app/Services/Billing/ExtraPmPurchaseService.php new file mode 100644 index 0000000..e06225e --- /dev/null +++ b/app/Services/Billing/ExtraPmPurchaseService.php @@ -0,0 +1,64 @@ +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; + }); + } +} diff --git a/app/Services/Billing/ProofPdfService.php b/app/Services/Billing/ProofPdfService.php new file mode 100644 index 0000000..eea00ee --- /dev/null +++ b/app/Services/Billing/ProofPdfService.php @@ -0,0 +1,92 @@ +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); + } +} diff --git a/app/Services/PressRelease/ProofPdfRenderer.php b/app/Services/PressRelease/ProofPdfRenderer.php new file mode 100644 index 0000000..ffaf480 --- /dev/null +++ b/app/Services/PressRelease/ProofPdfRenderer.php @@ -0,0 +1,232 @@ +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 + */ + 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 + */ + 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; + } +} diff --git a/routes/customer.php b/routes/customer.php index fc073df..3e6ce39 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -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'); diff --git a/tests/Feature/ProofPdfAndExtraPmTest.php b/tests/Feature/ProofPdfAndExtraPmTest.php new file mode 100644 index 0000000..80b2bd5 --- /dev/null +++ b/tests/Feature/ProofPdfAndExtraPmTest.php @@ -0,0 +1,159 @@ +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); +});