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