diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 47de4c35..1346e6e7 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -6,6 +6,7 @@ use App\Enums\InvoiceStatus; use App\Enums\PressReleaseStatus; use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseType; +use App\Mail\PressReleaseDepublished; use App\Models\CreditTopup; use App\Models\Invoice; use App\Models\InvoiceBillingAddress; @@ -15,6 +16,7 @@ use App\Services\Billing\CreditTopupService; use App\Services\Billing\InvoiceNumberGenerator; use App\Services\PressRelease\PressReleaseService; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\WebhookReceived; use Laravel\Cashier\Subscription; @@ -268,7 +270,18 @@ class ProcessStripeWebhook } app(PressReleaseService::class)->depublish($pressRelease); - $purchase->update(['consumed_at' => now()]); + $purchase->update([ + 'status' => SinglePurchaseStatus::Consumed->value, + 'consumed_at' => now(), + ]); + + // Bestätigung an den Käufer (nicht zwingend der PM-Autor — der + // Kauf kann von einem Firmen-Kollegen mit Zugriff stammen). + $buyer = $purchase->user; + + if ($buyer && $buyer->email) { + Mail::to($buyer->email)->queue(new PressReleaseDepublished($buyer, $pressRelease)); + } Log::info('Pressemitteilung depubliziert.', [ 'single_purchase_id' => $purchase->id, diff --git a/app/Mail/PressReleaseDepublished.php b/app/Mail/PressReleaseDepublished.php new file mode 100644 index 00000000..5a8d33e9 --- /dev/null +++ b/app/Mail/PressReleaseDepublished.php @@ -0,0 +1,44 @@ +showUrl = $user->canAccessAdmin() + ? route('admin.press-releases.show', $pressRelease->id) + : route('me.press-releases.show', $pressRelease->id); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Ihre Pressemitteilung wurde depubliziert: '.$this->pressRelease->title, + ); + } + + public function content(): Content + { + return new Content(view: 'emails.press-release.depublished'); + } +} diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index 45c5e522..021ac41b 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -341,7 +341,7 @@ class StripeCheckoutService $this->syncCustomerFromBillingAddress($user); return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [ - ...$this->sessionOptions($user), + ...$this->oneTimeSessionOptions($user), 'metadata' => ['single_purchase_id' => (string) $purchase->id], ]); } @@ -358,7 +358,7 @@ class StripeCheckoutService return $user->checkout( [$this->adHocLineItem($purchase->price_cents, 'Depublikation einer Pressemitteilung')], [ - ...$this->sessionOptions($user), + ...$this->oneTimeSessionOptions($user), 'metadata' => ['single_purchase_id' => (string) $purchase->id], ], ); @@ -376,9 +376,27 @@ class StripeCheckoutService return $user->checkout( [$this->adHocLineItem($topup->price_cents, "Credit-Guthaben ({$topup->credits} Credits)")], [ - ...$this->sessionOptions($user), + ...$this->oneTimeSessionOptions($user), 'metadata' => ['credit_topup_id' => (string) $topup->id], ], ); } + + /** + * Session-Optionen für Einmalkäufe (Payment-Mode: Einzel-PM, Depublikation, + * Credit-Topup). Anders als im Subscription-Mode erzeugt Stripe hier ohne + * `invoice_creation` keine Rechnung — und damit auch kein + * `invoice.payment_succeeded`, das die STR-Spiegelung anstoßen könnte. + * Im Subscription-Mode ist der Parameter unzulässig, daher nicht in + * sessionOptions() selbst. + * + * @return array + */ + private function oneTimeSessionOptions(User $user): array + { + return [ + ...$this->sessionOptions($user), + 'invoice_creation' => ['enabled' => true], + ]; + } } diff --git a/resources/views/emails/press-release/depublished.blade.php b/resources/views/emails/press-release/depublished.blade.php new file mode 100644 index 00000000..38e97d0b --- /dev/null +++ b/resources/views/emails/press-release/depublished.blade.php @@ -0,0 +1,21 @@ + +

Hallo {{ $user->name }},

+ +

die von Ihnen beauftragte Depublikation wurde durchgeführt. Die folgende Pressemitteilung ist ab sofort nicht mehr öffentlich abrufbar:

+ +

{{ $pressRelease->title }}

+ + Pressemitteilung im Konto ansehen + +
+ @if ($pressRelease->depublished_at) +
Depubliziert am
+
{{ $pressRelease->depublished_at->format('d.m.Y H:i') }} Uhr
+ @endif +
+ +
+

Unter der bisherigen Adresse erscheint nur noch der Hinweis, dass die Pressemitteilung auf Wunsch des Absenders entfernt wurde — Inhalte sind dort nicht mehr sichtbar. Die Rechnung zu diesem Auftrag finden Sie in Ihrem Konto unter Rechnungen.

+

Bei Fragen erreichen Sie uns über den Hilfe-Bereich auf pressekonto.

+
+
diff --git a/tests/Feature/Billing/DepublicationTest.php b/tests/Feature/Billing/DepublicationTest.php index e1da428a..8b7d1739 100644 --- a/tests/Feature/Billing/DepublicationTest.php +++ b/tests/Feature/Billing/DepublicationTest.php @@ -5,6 +5,7 @@ use App\Enums\PressReleaseStatus; use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseType; use App\Listeners\ProcessStripeWebhook; +use App\Mail\PressReleaseDepublished; use App\Models\BillingAddress; use App\Models\BookingConfirmation; use App\Models\PressRelease; @@ -13,6 +14,7 @@ use App\Models\User; use App\Services\Billing\StripeCheckoutService; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Mail; use Laravel\Cashier\Checkout; use Laravel\Cashier\Events\WebhookReceived; use Livewire\Volt\Volt as LivewireVolt; @@ -193,6 +195,7 @@ test('the detail page hides the depublication offer without a price and after a test('the paid webhook depublishes the release and the public page shows a tombstone', function () { /** @var TestCase $this */ + Mail::fake(); $user = depublicationCustomer(); $release = publishedReleaseFor($user); @@ -220,10 +223,18 @@ test('the paid webhook depublishes the release and the public page shows a tombs $release->refresh(); expect($release->status)->toBe(PressReleaseStatus::Archived); expect($release->depublished_at)->not->toBeNull(); - expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Paid); + // Eingelöst = Status Consumed — nur so erscheint der Kauf unter + // „Eingelöste Käufe" auf der Buchungs-Seite. + expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); expect($purchase->fresh()->consumed_at)->not->toBeNull(); expect($release->statusLogs()->count())->toBeGreaterThan(0); + // Der Käufer erhält genau eine Bestätigung (Replay löst keine zweite aus). + Mail::assertQueued(PressReleaseDepublished::class, function (PressReleaseDepublished $mail) use ($user, $release) { + return $mail->hasTo($user->email) && $mail->pressRelease->is($release); + }); + Mail::assertQueuedCount(1); + // Öffentliche URL: Tombstone mit 410 Gone statt 404, ohne Inhalte. $this->get('https://businessportal24.test/de/press-release/'.$release->slug) ->assertStatus(410) diff --git a/tests/Feature/Billing/StripeCustomerHooksTest.php b/tests/Feature/Billing/StripeCustomerHooksTest.php index edfee175..b2f07fd2 100644 --- a/tests/Feature/Billing/StripeCustomerHooksTest.php +++ b/tests/Feature/Billing/StripeCustomerHooksTest.php @@ -71,13 +71,29 @@ test('checkout session options collect the address in stripe when no billing add ->and($options['customer_update'])->toBe(['address' => 'auto', 'name' => 'auto']); }); +test('one-time checkouts enable stripe invoice creation, subscription checkouts do not', function () { + $user = User::factory()->create(); + BillingAddress::factory()->create(['user_id' => $user->id]); + $user = $user->fresh(); + + // Payment-Mode: ohne invoice_creation erzeugt Stripe keine Rechnung — + // und damit gäbe es kein invoice.payment_succeeded für die STR-Spiegelung. + $oneTime = invokeSessionOptions($user, 'oneTimeSessionOptions'); + expect($oneTime['invoice_creation'])->toBe(['enabled' => true]) + ->and($oneTime['automatic_tax'])->toBe(['enabled' => true]); + + // Subscription-Mode lehnt invoice_creation ab — dort erzeugt Stripe + // die Rechnungen ohnehin selbst. + expect(invokeSessionOptions($user))->not->toHaveKey('invoice_creation'); +}); + /** * @return array */ -function invokeSessionOptions(User $user): array +function invokeSessionOptions(User $user, string $method = 'sessionOptions'): array { $service = app(StripeCheckoutService::class); - $method = new ReflectionMethod($service, 'sessionOptions'); + $method = new ReflectionMethod($service, $method); return $method->invoke($service, $user); } diff --git a/tests/Feature/Mail/EmailBrandingTest.php b/tests/Feature/Mail/EmailBrandingTest.php index 9b99d8b8..4902addf 100644 --- a/tests/Feature/Mail/EmailBrandingTest.php +++ b/tests/Feature/Mail/EmailBrandingTest.php @@ -1,8 +1,10 @@ toContain('businessportal24') ->toContain('ist der gemeinsame Publisher') ->toContain('#1A2540') - ->toContain('#B07A3A'); + ->toContain('#B07A3A') + // Hub ist verlinkt (Wortmarke im Header + Brand-Familien-Zeile). + ->toContain('href="'.config('domains.domain_portal_url').'"') + // Rechtliche Links im Footer (alle auf dem Hub). + ->toContain(config('domains.domain_portal_url').'/de/agb') + ->toContain(config('domains.domain_portal_url').'/de/impressum') + ->toContain(config('domains.domain_portal_url').'/de/datenschutz') + // Keine direkte Service-Mailadresse in der Mail. + ->not->toContain(config('mail.from.address')); } test('the magic login link mail uses the hub ci layout', function () { @@ -53,6 +65,42 @@ test('the press release published and rejected mails use the hub ci layout', fun assertHubEmailBranding((new PressReleaseRejected($user, $pressRelease, 'Bitte Quellen ergänzen.'))->render()); }); +test('the press release depublished mail uses the hub ci layout and explains the tombstone', function () { + $pressRelease = PressRelease::factory()->published()->create(['depublished_at' => now()]); + $user = $pressRelease->user; + + $html = (new PressReleaseDepublished($user, $pressRelease))->render(); + + assertHubEmailBranding($html); + expect($html) + ->toContain($pressRelease->title) + ->toContain('Depubliziert am') + ->toContain('auf Wunsch des Absenders entfernt'); +}); + +test('the published mail names the portal with a link to the public detail page', function () { + $pressRelease = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(); + + $html = (new PressReleasePublished($pressRelease->user, $pressRelease))->render(); + + expect($html) + ->toContain('Veröffentlicht auf') + ->toContain('Presseecho') + ->toContain(config('domains.domain_presseecho_url').'/de/press-release/'.$pressRelease->slug); +}); + +test('the published mail links both portals for a cross-portal press release', function () { + $pressRelease = PressRelease::factory()->published()->forPortal(Portal::Both)->create(); + + $html = (new PressReleasePublished($pressRelease->user, $pressRelease))->render(); + + expect($html) + ->toContain('Presseecho') + ->toContain('Businessportal24') + ->toContain(config('domains.domain_presseecho_url').'/de/press-release/'.$pressRelease->slug) + ->toContain(config('domains.domain_businessportal_url').'/de/press-release/'.$pressRelease->slug); +}); + test('the termination mails use the hub ci layout', function () { $terminationRequest = TerminationRequest::factory()->create();