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:
parent
38fab64e10
commit
c8dc99c3c8
24 changed files with 775 additions and 100 deletions
|
|
@ -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([
|
||||
|
|
|
|||
69
app/Http/Controllers/CheckoutController.php
Normal file
69
app/Http/Controllers/CheckoutController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
51
app/Services/Billing/StripeCheckoutService.php
Normal file
51
app/Services/Billing/StripeCheckoutService.php
Normal 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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue