Depublikation: Kauf als eingelöst markieren, Rechnung & Bestätigungs-Mail

Drei Lücken im bezahlten Depublikations-Flow (T12) geschlossen:

- Webhook setzt den Kauf jetzt auf status=consumed (vorher nur
  consumed_at) — erst damit erscheint er unter „Eingelöste Käufe".
- Einmalkäufe (Depublikation, Einzel-PM, Credit-Topup) aktivieren
  invoice_creation im Stripe-Checkout: Ohne das Flag erzeugt Stripe im
  Payment-Mode keine Rechnung und die STR-Spiegelung lief ins Leere.
  Abo-Checkouts bleiben unverändert (Parameter dort unzulässig).
- Neue Bestätigungs-Mail PressReleaseDepublished an den Käufer
  (nicht zwingend der PM-Autor) nach vollzogener Depublikation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-08 11:25:12 +00:00
parent d57f6b5c66
commit f6882b621a
7 changed files with 180 additions and 9 deletions

View file

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

View file

@ -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<string, mixed>
*/
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);
}