Phase 9E (Abschluss): Checkout-Flows und Plan-Kontingent statt Quota-Stub

- Checkout-Backend: me.checkout.subscription (Tarif-Abo monatlich/jährlich)
  und me.checkout.single-pm (Einzel-PM 19 € netto, pending-Kauf mit
  Webhook-Erfüllung); StripeCheckoutService als mockbarer Stripe-Wrapper;
  Stripe Tax via Cashier::calculateTaxes() (Netto-Preise, USt-ID-Abfrage)
- Slot-Logik: Kontingent aus dem Tarif (plans.press_release_quota) plus
  bezahlte Einmalkäufe; Verbrauch bei Veröffentlichung zuerst aus dem
  Plan-Zähler, danach Einlösung des ältesten Einmalkaufs (consumed +
  PM-Verknüpfung); Grandfathered = unbegrenzt (Entscheidung 12.06.2026,
  Bestandsschutz); Stub-Spalte users.press_release_quota entfernt
- billing:sync-stripe-plans legt zusätzlich das Einzel-PM-Produkt an
  (STRIPE_PRICE_SINGLE_PM); Test-Mode-Sync gelaufen
- Buchungs-Seite: Rückmeldung nach Checkout (erfolg/abbruch/Guard-Hinweis)
- Tests: PressReleaseQuotaTest auf Plan-Semantik neu geschrieben,
  CheckoutFlowTest (8 Tests), Modal-/API-Tests angepasst; Suite 510 passed
- Doku: Billing-und-Rechnungskreise (Kontingent-Tabelle, Checkout-Routen,
  Webhook-Events, Stripe-CLI-Hinweis), PHASE-9-Plan 9E , Checkliste,
  STATUS-ABGLEICH, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 12:10:32 +00:00
parent 38fab64e10
commit c8dc99c3c8
24 changed files with 775 additions and 100 deletions

View file

@ -77,9 +77,53 @@ class SyncStripePlans extends Command
$this->info("{$plan->slug}: {$productId} · monatlich {$monthlyId} · jährlich {$yearlyId}");
}
$this->syncSinglePmPrice($stripe, $dryRun);
return self::SUCCESS;
}
/**
* Legt das Einmal-Produkt „Einzel-Pressemitteilung" an (Netto-Preis aus
* billing.single_pm_price_cents). Die Price-ID landet bewusst in der ENV
* (STRIPE_PRICE_SINGLE_PM) statt in einer Tabelle es gibt genau einen
* solchen Preis, und ohne ENV bleibt der Checkout deaktiviert.
*/
private function syncSinglePmPrice(object $stripe, bool $dryRun): void
{
if (config('billing.single_pm_stripe_price_id')) {
$this->line('einzel-pm: bereits verknüpft (STRIPE_PRICE_SINGLE_PM), übersprungen.');
return;
}
$amount = (int) config('billing.single_pm_price_cents');
if ($dryRun) {
$this->line(sprintf(
'[dry-run] einzel-pm: Einmal-Produkt + Preis %s € (netto) anlegen.',
number_format($amount / 100, 2, ',', '.'),
));
return;
}
$product = $stripe->products->create([
'name' => 'Einzel-Pressemitteilung',
'metadata' => ['purpose' => 'single_pm'],
]);
$price = $stripe->prices->create([
'product' => $product->id,
'currency' => 'eur',
'unit_amount' => $amount,
'tax_behavior' => 'exclusive',
'metadata' => ['purpose' => 'single_pm'],
]);
$this->info("einzel-pm: {$product->id} · {$price->id}");
$this->warn("Bitte in die .env eintragen: STRIPE_PRICE_SINGLE_PM={$price->id}");
}
private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
{
$price = $stripe->prices->create([

View file

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Services\Billing\StripeCheckoutService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Laravel\Cashier\Checkout;
/**
* Einstieg in die Stripe-Checkouts (Phase 9E): Tarif-Abo und Einzel-PM.
*
* Die Routen leiten direkt zur Stripe-Checkout-Seite weiter; Erfolg und
* Abbruch landen auf der Buchungs-Seite (?checkout=erfolg|abbruch). Die
* Erfüllung übernimmt der Webhook (ProcessStripeWebhook): Cashier legt das
* Abo an, `checkout.session.completed` markiert den Einmalkauf als bezahlt.
*/
class CheckoutController extends Controller
{
public function __construct(private readonly StripeCheckoutService $checkout) {}
public function subscription(Request $request, string $planSlug, string $interval): Checkout|RedirectResponse
{
$plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail();
$user = $request->user();
if ($user->subscribed()) {
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.'));
}
$priceId = $interval === 'yearly'
? $plan->stripe_price_id_yearly
: $plan->stripe_price_id_monthly;
if (! $priceId) {
return $this->backToBookings(__('Dieser Tarif ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
}
return $this->checkout->forSubscription($user, $plan, $interval);
}
public function singlePm(Request $request): Checkout|RedirectResponse
{
if (! config('billing.single_pm_stripe_price_id')) {
return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
}
$purchase = SinglePurchase::query()->create([
'user_id' => $request->user()->id,
'type' => SinglePurchaseType::SinglePm->value,
'status' => SinglePurchaseStatus::Pending->value,
'price_cents' => (int) config('billing.single_pm_price_cents'),
'currency' => 'EUR',
]);
return $this->checkout->forSinglePurchase($request->user(), $purchase);
}
private function backToBookings(string $notice): RedirectResponse
{
return redirect()
->route('me.bookings.index')
->with('checkout-notice', $notice);
}
}

View file

@ -45,7 +45,6 @@ class User extends Authenticatable
'legacy_portal',
'legacy_id',
'password',
'press_release_quota',
'press_release_quota_used_this_month',
];
@ -77,21 +76,87 @@ class User extends Authenticatable
'last_seen_at' => 'datetime',
'deleted_at' => 'datetime',
'password' => 'hashed',
'press_release_quota' => 'integer',
'press_release_quota_used_this_month' => 'integer',
];
}
/**
* Verbleibendes PM-Kontingent in diesem Monat.
*
* Temporärer Stub bis zum echten Tarif-/Credit-Modul. Die Schnittstelle
* (`pressReleaseQuotaRemaining()`) bleibt stabil, damit das
* Veröffentlichungs-Modal nicht neu gebaut werden muss.
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
* gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.
*/
public function pressReleaseQuotaRemaining(): int
public function currentPlan(): ?Plan
{
return max(0, (int) $this->press_release_quota - (int) $this->press_release_quota_used_this_month);
$subscription = $this->subscription();
if (! $subscription?->valid()) {
return null;
}
$priceId = $subscription->stripe_price;
if (! $priceId) {
return null;
}
return Plan::query()
->where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_yearly', $priceId)
->first();
}
/**
* Hat dieser User ein unbegrenztes PM-Kontingent?
*
* Entscheidung 12.06.2026: Bestandskunden (aktive/grandfathered
* Legacy-Vereinbarung) behalten ihren Bestandsschutz unverändert
* das Alt-Produkt sah unbegrenzte PMs vor. Solange der Launch-Schalter
* `billing.enforce_booking` aus ist, gilt das Kontingent für niemanden.
*/
public function hasUnlimitedPressReleaseQuota(): bool
{
if (! config('billing.enforce_booking')) {
return true;
}
return $this->userPaymentOptions()
->whereIn('status', [
UserPaymentOptionStatus::Active->value,
UserPaymentOptionStatus::Grandfathered->value,
])
->exists();
}
/**
* Verbleibendes PM-Kontingent: Rest des Plan-Monatskontingents plus
* bezahlte, noch nicht eingelöste Einzel-/Extra-PM-Käufe.
* Null bedeutet unbegrenzt.
*/
public function pressReleaseQuotaRemaining(): ?int
{
if ($this->hasUnlimitedPressReleaseQuota()) {
return null;
}
$planRemaining = max(
0,
($this->currentPlan()?->press_release_quota ?? 0) - (int) $this->press_release_quota_used_this_month,
);
return $planRemaining + $this->singlePurchases()->grantingSubmission()->count();
}
/**
* Gesamtes PM-Kontingent (Plan-Monatskontingent plus offene Einmalkäufe)
* für die Anzeige „verbleibend / gesamt". Null bedeutet unbegrenzt.
*/
public function pressReleaseQuotaTotal(): ?int
{
if ($this->hasUnlimitedPressReleaseQuota()) {
return null;
}
return ($this->currentPlan()?->press_release_quota ?? 0)
+ $this->singlePurchases()->grantingSubmission()->count();
}
/**

View file

@ -22,6 +22,7 @@ use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
@ -51,6 +52,12 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https');
}
// Stripe Tax berechnet die USt im Checkout automatisch nach den
// gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer,
// EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich
// die USt-ID-Abfrage im Stripe Checkout.
Cashier::calculateTaxes();
AdminPreset::observe(AdminPerformanceCacheObserver::class);
Category::observe(AdminPerformanceCacheObserver::class);
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);

View file

@ -0,0 +1,51 @@
<?php
namespace App\Services\Billing;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use Laravel\Cashier\Checkout;
/**
* Dünner Wrapper um die Cashier-Checkout-Erzeugung (Phase 9E).
*
* Hier passiert ausschließlich der Stripe-Aufruf alle Guards (Tarif
* synchronisiert, kein Doppel-Abo, Preis konfiguriert) liegen im
* CheckoutController. So bleibt der Controller ohne Stripe-Anbindung
* testbar, indem dieser Service im Container gemockt wird.
*/
class StripeCheckoutService
{
/**
* Stripe-Checkout für ein Tarif-Abo (monatlich/jährlich). Die Steuer
* ergänzt Stripe Tax automatisch (Cashier::calculateTaxes, Netto-Preise).
*/
public function forSubscription(User $user, Plan $plan, string $interval): Checkout
{
$priceId = $interval === 'yearly'
? $plan->stripe_price_id_yearly
: $plan->stripe_price_id_monthly;
return $user
->newSubscription('default', $priceId)
->checkout([
'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']),
'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']),
]);
}
/**
* Stripe-Checkout für eine Einzel-PM. Die `single_purchase_id` in den
* Session-Metadaten schließt den Kreis: `checkout.session.completed`
* markiert den Kauf über ProcessStripeWebhook als bezahlt.
*/
public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout
{
return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [
'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']),
'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']),
'metadata' => ['single_purchase_id' => (string) $purchase->id],
]);
}
}

View file

@ -4,6 +4,7 @@ namespace App\Services\PressRelease;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Mail\PressReleasePublished;
@ -43,8 +44,11 @@ class PressReleaseService
// Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht
// (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn
// noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne
// verfügbares Kontingent automatisch veröffentlicht.
if ($user && $user->pressReleaseQuotaRemaining() <= 0) {
// verfügbares Kontingent automatisch veröffentlicht. Null bedeutet
// unbegrenzt (Bestandsschutz bzw. Gate noch nicht scharf).
$quotaRemaining = $user?->pressReleaseQuotaRemaining();
if ($user && $quotaRemaining !== null && $quotaRemaining <= 0) {
throw new QuotaExceededException;
}
@ -184,6 +188,11 @@ class PressReleaseService
* PMs kosten nichts). Erneutes Publizieren etwa nach Archivierung
* zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem
* Schreiben des neuen Status-Logs aufgerufen werden.
*
* Verbrauchsreihenfolge: zuerst das Plan-Monatskontingent (Zähler
* `press_release_quota_used_this_month`), danach der älteste bezahlte
* Einzel-/Extra-PM-Kauf (wird mit der PM verknüpft und eingelöst).
* Unbegrenzte User (Bestandsschutz) verbrauchen nichts.
*/
private function consumePublishSlot(PressRelease $pressRelease): void
{
@ -196,7 +205,29 @@ class PressReleaseService
return;
}
$pressRelease->user?->increment('press_release_quota_used_this_month');
$user = $pressRelease->user;
if (! $user || $user->hasUnlimitedPressReleaseQuota()) {
return;
}
$plan = $user->currentPlan();
if ($plan && (int) $user->press_release_quota_used_this_month < $plan->press_release_quota) {
$user->increment('press_release_quota_used_this_month');
return;
}
$user->singlePurchases()
->grantingSubmission()
->oldest('paid_at')
->first()
?->update([
'status' => SinglePurchaseStatus::Consumed->value,
'consumed_at' => now(),
'press_release_id' => $pressRelease->id,
]);
}
/**