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->info("{$plan->slug}: {$productId} · monatlich {$monthlyId} · jährlich {$yearlyId}");
} }
$this->syncSinglePmPrice($stripe, $dryRun);
return self::SUCCESS; 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 private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
{ {
$price = $stripe->prices->create([ $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_portal',
'legacy_id', 'legacy_id',
'password', 'password',
'press_release_quota',
'press_release_quota_used_this_month', 'press_release_quota_used_this_month',
]; ];
@ -77,21 +76,87 @@ class User extends Authenticatable
'last_seen_at' => 'datetime', 'last_seen_at' => 'datetime',
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'press_release_quota' => 'integer',
'press_release_quota_used_this_month' => 'integer', 'press_release_quota_used_this_month' => 'integer',
]; ];
} }
/** /**
* Verbleibendes PM-Kontingent in diesem Monat. * Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
* * gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.
* Temporärer Stub bis zum echten Tarif-/Credit-Modul. Die Schnittstelle
* (`pressReleaseQuotaRemaining()`) bleibt stabil, damit das
* Veröffentlichungs-Modal nicht neu gebaut werden muss.
*/ */
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\DB;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
use Livewire\Livewire; use Livewire\Livewire;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
@ -51,6 +52,12 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https'); 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); AdminPreset::observe(AdminPerformanceCacheObserver::class);
Category::observe(AdminPerformanceCacheObserver::class); Category::observe(AdminPerformanceCacheObserver::class);
CategoryTranslation::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\PressReleaseClassification;
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Jobs\ClassifyPressRelease; use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease; use App\Jobs\ScorePressRelease;
use App\Mail\PressReleasePublished; use App\Mail\PressReleasePublished;
@ -43,8 +44,11 @@ class PressReleaseService
// Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht // Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht
// (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn // (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn
// noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne // noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne
// verfügbares Kontingent automatisch veröffentlicht. // verfügbares Kontingent automatisch veröffentlicht. Null bedeutet
if ($user && $user->pressReleaseQuotaRemaining() <= 0) { // unbegrenzt (Bestandsschutz bzw. Gate noch nicht scharf).
$quotaRemaining = $user?->pressReleaseQuotaRemaining();
if ($user && $quotaRemaining !== null && $quotaRemaining <= 0) {
throw new QuotaExceededException; throw new QuotaExceededException;
} }
@ -184,6 +188,11 @@ class PressReleaseService
* PMs kosten nichts). Erneutes Publizieren etwa nach Archivierung * PMs kosten nichts). Erneutes Publizieren etwa nach Archivierung
* zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem * zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem
* Schreiben des neuen Status-Logs aufgerufen werden. * 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 private function consumePublishSlot(PressRelease $pressRelease): void
{ {
@ -196,7 +205,29 @@ class PressReleaseService
return; 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,
]);
} }
/** /**

View file

@ -34,6 +34,21 @@ return [
// Zahlungsziel für Rechnungen des manuellen Kreises (Tage). // Zahlungsziel für Rechnungen des manuellen Kreises (Tage).
'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14), 'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14),
/*
|--------------------------------------------------------------------------
| Einzel-Pressemitteilung (Pay-per-Release)
|--------------------------------------------------------------------------
|
| Netto-Preis laut Decision-Update. Die Stripe-Price-ID wird einmalig
| von `billing:sync-stripe-plans` angelegt und hier per ENV verdrahtet
| ohne sie ist der Einzel-PM-Checkout deaktiviert.
|
*/
'single_pm_price_cents' => 1900,
'single_pm_stripe_price_id' => env('STRIPE_PRICE_SINGLE_PM'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| USt-Behandlung (Entscheidung 12.06.2026) | USt-Behandlung (Entscheidung 12.06.2026)

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Entfernt die Stub-Spalte `press_release_quota` (Phase 9E): Das Limit kommt
* jetzt aus dem Tarif (`plans.press_release_quota`) bzw. aus Einmalkäufen.
* Der Verbrauchszähler `press_release_quota_used_this_month` bleibt bestehen.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('press_release_quota');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedInteger('press_release_quota')->default(3)->after('legacy_id');
});
}
};

View file

@ -5,6 +5,31 @@
--- ---
## 2026-06-12 · Phase 9E · Stripe-Anbindung komplett ✅
- **Was**: Produkt-Sync nach Stripe (Tarife + Einzel-PM, Netto-Preise,
Test-Mode), Webhook-Verarbeitung (STR-Spiegelung + Einmalkauf-Erfüllung;
Endpoint `pressekonto.com/stripe/webhook` registriert, Secret gesetzt),
Checkout-Flows als Backend (`me.checkout.subscription`,
`me.checkout.single-pm`; Stripe Tax via `Cashier::calculateTaxes()`),
Slot-Logik vom Stub auf Plan-Kontingent umgestellt: Abo → Tarif-Quote,
danach Einmalkauf-Verbrauch (consumed + PM-Verknüpfung),
**Grandfathered = unbegrenzt** (Entscheidung 12.06.2026, Bestandsschutz);
Stub-Spalte `users.press_release_quota` entfernt.
- **Dateien**: `app/Http/Controllers/CheckoutController.php`,
`app/Services/Billing/StripeCheckoutService.php`,
`app/Listeners/ProcessStripeWebhook.php`,
`app/Console/Commands/SyncStripePlans.php`, `app/Models/User.php`,
`app/Services/PressRelease/PressReleaseService.php`,
`routes/customer.php`, `config/billing.php`, Buchungs-Seite (Rückmeldung),
Submit-Modal/Views (Kontingent-Anzeige).
- **Build/Test**: Suite 510 passed / 4 skipped, Pint clean; Stripe-Sync
live gegen Test-Mode gelaufen (Einzel-PM: `STRIPE_PRICE_SINGLE_PM` in .env).
- **Offene Fragen**: Stripe Tax im Dashboard aktivieren (Ursprungsadresse),
sonst schlägt der Checkout fehl; Live-Mode-Sync vor Relaunch.
- **Nächster Schritt**: 9F Tarif-Seite/Buchungs-UI an die Checkout-Routen
anbinden (Mock ablösen), danach 9G Tageslimit.
## 2026-06-12 · Phase 9D · Tarif-Datenmodell, Rechnungskreise & USt ✅ ## 2026-06-12 · Phase 9D · Tarif-Datenmodell, Rechnungskreise & USt ✅
Zentrale Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`. Zentrale Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`.

View file

@ -41,7 +41,7 @@ Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken:
| **9C** ✅ | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) + Fix: Create-Form lief am Funnel vorbei | M | gering | | **9C** ✅ | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) + Fix: Create-Form lief am Funnel vorbei | M | gering |
| — | **Review-Stopp mit User** | | | | — | **Review-Stopp mit User** | | |
| **9D** ✅ | Tarif-Datenmodell: Pläne, Einzelkäufe, Cashier, Rechnungskreise STR-/MAN-, MAN-Fälligkeitslauf (Stub-Ablösung folgt mit 9E) | L | hoch (Datenmodell) | | **9D** ✅ | Tarif-Datenmodell: Pläne, Einzelkäufe, Cashier, Rechnungskreise STR-/MAN-, MAN-Fälligkeitslauf (Stub-Ablösung folgt mit 9E) | L | hoch (Datenmodell) |
| **9E** 🔄 | Stripe-Anbindung — Backbone ✅ (Produkt-Sync nach Stripe, Webhook-Listener mit STR-Spiegelung + Einmalkauf-Erfüllung); offen: Checkout-Flows, Webhook-Endpoint registrieren, Slot-Logik auf Plan-Kontingent | L | mittel | | **9E** ✅ | Stripe-Anbindung: Produkt-Sync (Tarife + Einzel-PM), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung, Endpoint registriert), Checkout-Flows (Backend), Slot-Logik auf Plan-Kontingent (Grandfathered = unbegrenzt), Stripe Tax | L | mittel |
| **9F** | Tarif-Seite + Checkout-UI (Raster, Einzel-PM-Block, „2 Monate gratis", Enterprise-Hinweis) | M | gering | | **9F** | Tarif-Seite + Checkout-UI (Raster, Einzel-PM-Block, „2 Monate gratis", Enterprise-Hinweis) | M | gering |
| **9G** | Tageslimit je Tier (Business 2 / Pro 3 / Agency 5; gilt auch für Extra-PMs) | S | gering | | **9G** | Tageslimit je Tier (Business 2 / Pro 3 / Agency 5; gilt auch für Extra-PMs) | S | gering |
| **9H** | Einzel-PM-Kauf (19 €) + Einzel→Abo-Brücke (Anrechnung 30 Tage) | M | mittel | | **9H** | Einzel-PM-Kauf (19 €) + Einzel→Abo-Brücke (Anrechnung 30 Tage) | M | mittel |
@ -180,20 +180,26 @@ ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand):
`user_payment_options` (Replay-fähig — die Kern-Migration läuft kurz `user_payment_options` (Replay-fähig — die Kern-Migration läuft kurz
vor dem Relaunch erneut). Details: vor dem Relaunch erneut). Details:
`dev/migration 2026/05-DATABASE-MERGE.md` §5.6 + `MIGRATION-STEPS.md`. `dev/migration 2026/05-DATABASE-MERGE.md` §5.6 + `MIGRATION-STEPS.md`.
- **Noch offen in 9D** (folgt mit 9E, braucht Checkout/Webhooks): - ~~Noch offen in 9D~~ → mit 9E erledigt: Slot-Logik auf Plan-Kontingent
Slot-Logik von `users.press_release_quota`-Stub auf Plan-Kontingent + umgestellt, Stub-Spalte entfernt.
Periodenzähler umstellen und Stub-Spalten entfernen.
### 9E · Stripe (Laravel Cashier) ### 9E · Stripe (Laravel Cashier) ✅ (12.06.2026)
- Checkout für Abo (monatlich/jährlich) und Einmalzahlung (Einzel-PM, Credits); - ✅ Produkt-Sync: `billing:sync-stripe-plans` legt Tarife (Monats-/Jahres-
Stripe-Produkte/Preise anlegen und IDs in `plans` pflegen. preise) und das Einzel-PM-Produkt als Netto-Preise in Stripe an
- Webhooks (Subscription-Status, Zahlungsausfall, `invoice.paid`) + Spiegelung (Test-Mode gelaufen; IDs in `plans` bzw. `STRIPE_PRICE_SINGLE_PM`).
der Stripe-Rechnungen in `invoices` mit STR-Nummer aus dem - ✅ Webhooks: `ProcessStripeWebhook` spiegelt bezahlte Stripe-Rechnungen
`InvoiceNumberGenerator`. mit STR-Nummer in `invoices` und erfüllt Einmalkäufe; Endpoint
- Slot-Logik auf Plan-Kontingent umstellen (siehe 9D-Rest), Stub ablösen. `https://pressekonto.com/stripe/webhook` registriert, Secret gesetzt.
- Benötigt `STRIPE_KEY`/`STRIPE_SECRET`/`STRIPE_WEBHOOK_SECRET` in `.env` - ✅ Checkout-Flows (Backend): `me.checkout.subscription` +
(aktuell nicht gesetzt). `me.checkout.single-pm` (CheckoutController → StripeCheckoutService);
Stripe Tax via `Cashier::calculateTaxes()` (Netto-Preise). UI-Anbindung
der Buttons folgt in 9F.
- ✅ Slot-Logik: Plan-Kontingent + Einmalkauf-Verbrauch statt Stub;
**Grandfathered = unbegrenzt** (Entscheidung 12.06.2026, Bestandsschutz).
Details: `docs/user-admin/Billing-und-Rechnungskreise.md` §2.
- Offen → §7 der Billing-Doku: Stripe Tax im Dashboard aktivieren,
Live-Mode-Sync vor Relaunch.
### 9F · Tarif-Seite + Checkout-UI ### 9F · Tarif-Seite + Checkout-UI

View file

@ -128,7 +128,7 @@ Zentrale Billing-Referenz: [`user-admin/Billing-und-Rechnungskreise.md`](./user-
| Rechnungen mit Legacy-Archiv | umgesetzt | ✅ | | Rechnungen mit Legacy-Archiv | umgesetzt | ✅ |
| Hybride Rechnungskreise STR-/MAN- (Decision 12.06.) | umgesetzt (Phase 9D) — Nummern-Generator, MAN-Fälligkeitslauf, Grandfather-Migration, USt-Logik (`VatResolver`) | ✅ | | Hybride Rechnungskreise STR-/MAN- (Decision 12.06.) | umgesetzt (Phase 9D) — Nummern-Generator, MAN-Fälligkeitslauf, Grandfather-Migration, USt-Logik (`VatResolver`) | ✅ |
| Tarif-Datenmodell + Cashier | umgesetzt (Phase 9D) — `plans`, `single_purchases`, `User` ist Billable | ✅ | | Tarif-Datenmodell + Cashier | umgesetzt (Phase 9D) — `plans`, `single_purchases`, `User` ist Billable | ✅ |
| Stripe-Checkout/Webhooks + STR-Spiegelung | **in Arbeit** (Phase 9E) | 📝 | | Stripe-Checkout/Webhooks + STR-Spiegelung | umgesetzt (Phase 9E) — Produkt-Sync, Webhook-Verarbeitung, Checkout-Backend, Plan-Kontingent | ✅ (UI → 9F) |
| Buchungen & Add-ons (UI) | nur Stub | 📝 (mit 9F Tarif-Seite) | | Buchungen & Add-ons (UI) | nur Stub | 📝 (mit 9F Tarif-Seite) |
| Zahlungsmethoden firmenscharf | **fehlt** | 📝 (Phase 2) | | Zahlungsmethoden firmenscharf | **fehlt** | 📝 (Phase 2) |
@ -212,17 +212,17 @@ im öffentlichen Web-Frontend.
|---|---| |---|---|
| Tarif-Raster Starter/Business/Pro/Agency (29/49/99/199 €, 3/10/25/60 PMs) | **nicht im Datenmodell** | | Tarif-Raster Starter/Business/Pro/Agency (29/49/99/199 €, 3/10/25/60 PMs) | **nicht im Datenmodell** |
| Einzel-PM 19 € (No-Abo-Block) + Einzel→Abo-Brücke | **fehlt** | | Einzel-PM 19 € (No-Abo-Block) + Einzel→Abo-Brücke | **fehlt** |
| Zahlung/Checkout (Stripe) | **fehlt** | | Zahlung/Checkout (Stripe) | **Backend umgesetzt** (Phase 9E) — Checkout-Routen, Webhooks, STR-Spiegelung; UI folgt mit 9F |
| Slot-Verbrauch **bei Veröffentlichung** (Rot = kein Slot) | **umgesetzt** (Phase 9B) — zählt idempotent beim ersten `published`-Übergang; Einreichen erfordert freien Slot, verbraucht aber keinen | | Slot-Verbrauch **bei Veröffentlichung** (Rot = kein Slot) | **umgesetzt** (Phase 9B) — zählt idempotent beim ersten `published`-Übergang; Einreichen erfordert freien Slot, verbraucht aber keinen |
| Submit-Gate: „Zur Prüfung einreichen" gegated hinter Buchung | **vorbereitet** (Phase 9C) — `User::hasActiveBooking()`-Stub hinter `billing.enforce_booking` (Default aus), Modal-Hinweis + Server-Guard + API 402; echte Buchungs-Prüfung kommt mit 9D/9E | | Submit-Gate: „Zur Prüfung einreichen" gegated hinter Buchung | **vorbereitet** (Phase 9C) — `User::hasActiveBooking()`-Stub hinter `billing.enforce_booking` (Default aus), Modal-Hinweis + Server-Guard + API 402; echte Buchungs-Prüfung seit 9D/9E (Abo Einmalkauf Legacy-Vereinbarung) |
| Tageslimit (Business 2 / Pro 3 / Agency 5) | **fehlt** | | Tageslimit (Business 2 / Pro 3 / Agency 5) | **fehlt** |
| Launch-Credits: Extra-PM, Boost (nur grün), Veröffentlichungsnachweis-PDF | **fehlt** | | Launch-Credits: Extra-PM, Boost (nur grün), Veröffentlichungsnachweis-PDF | **fehlt** |
| Jahrespreis als „2 Monate gratis" | Kommunikations-Regel, greift mit Tarif-UI | | Jahrespreis als „2 Monate gratis" | Kommunikations-Regel, greift mit Tarif-UI |
| `user_payment_options`-Tabelle | **vorhanden** (Pivot zu Companies da, aber kein aktiver Flow) | | `user_payment_options`-Tabelle | **vorhanden** (Pivot zu Companies da, aber kein aktiver Flow) |
**Bewertung**: 📝 — der **Launch-Block** und damit das größte ungebaute Feature. **Bewertung**: 📝 — der **Launch-Block** und damit das größte ungebaute Feature.
Vorhandene Anschlusspunkte: Quota-Stub (`users.press_release_quota`, Vorhandene Anschlusspunkte: Plan-Kontingent (`pressReleaseQuotaRemaining()`,
`pressReleaseQuotaRemaining()`), Veröffentlichungs-Modal (zeigt Kontingent), null = unbegrenzt/Bestandsschutz), Veröffentlichungs-Modal (zeigt Kontingent),
KI-Klassifikation (liefert das Rot/Gelb/Grün für den Slot-Verbrauch). KI-Klassifikation (liefert das Rot/Gelb/Grün für den Slot-Verbrauch).
Bewusst **nicht** zum Launch: Re-Check-Loop, Vorab-Prüfung, Prüfzähler Bewusst **nicht** zum Launch: Re-Check-Loop, Vorab-Prüfung, Prüfzähler
(alles Phase 2, siehe Decision-Update §7). (alles Phase 2, siehe Decision-Update §7).
@ -267,7 +267,7 @@ Bewusst **nicht** zum Launch: Re-Check-Loop, Vorab-Prüfung, Prüfzähler
| `press_release_attachments`-Tabelle + Model | Migration `2026_05_20_143424_*` | UI auskommentiert, Tabelle bleibt → Doku-Anker für spätere Reaktivierung | | `press_release_attachments`-Tabelle + Model | Migration `2026_05_20_143424_*` | UI auskommentiert, Tabelle bleibt → Doku-Anker für spätere Reaktivierung |
| Background-Job für scheduled publishing | `app/Console/Commands/PublishScheduledPressReleases.php`, alle 5 Min via Scheduler; publiziert seit der KI-Anbindung nur noch **grün klassifizierte** fällige PMs | Im Konzept als „automatische Veröffentlichung zum geplanten Termin" hinzufügen | | Background-Job für scheduled publishing | `app/Console/Commands/PublishScheduledPressReleases.php`, alle 5 Min via Scheduler; publiziert seit der KI-Anbindung nur noch **grün klassifizierte** fällige PMs | Im Konzept als „automatische Veröffentlichung zum geplanten Termin" hinzufügen |
| Zeitzonen-Handling für geplante Termine | `PressRelease::DISPLAY_TIMEZONE` (Europe/Berlin), `scheduledAtLocal()`/`embargoAtLocal()`; Eingabe Berlin, Speicherung UTC | dokumentiert in `Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`; `published_at`/`created_at` weiterhin UTC-Anzeige (Folgeschritt) | | Zeitzonen-Handling für geplante Termine | `PressRelease::DISPLAY_TIMEZONE` (Europe/Berlin), `scheduledAtLocal()`/`embargoAtLocal()`; Eingabe Berlin, Speicherung UTC | dokumentiert in `Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`; `published_at`/`created_at` weiterhin UTC-Anzeige (Folgeschritt) |
| Monatlicher Quota-Reset | `press-releases:reset-monthly-quota` (Scheduler, 1. des Monats) | Stub — wird vom Tarif-Modul (Decision-Update) abgelöst | | Monatlicher Quota-Reset | `press-releases:reset-monthly-quota` (Scheduler, 1. des Monats) | Setzt den Plan-Kontingent-Zähler zurück (seit 9E) |
| FluxUI Toast für UX-Feedback | `Flux::toast()` durchgehend in Customer-Forms | Konzept-übergreifend, kein Konzept-Update nötig | | FluxUI Toast für UX-Feedback | `Flux::toast()` durchgehend in Customer-Forms | Konzept-übergreifend, kein Konzept-Update nötig |
| Smooth-Scroll zu Validation-Errors | `resources/js/portal-form-hooks.js` | UX-Detail, keine Konzept-Doku | | Smooth-Scroll zu Validation-Errors | `resources/js/portal-form-hooks.js` | UX-Detail, keine Konzept-Doku |
| Pre-Submit-Check-Liste in PM-Forms | computed `presubmitChecks` | Im Konzept als „Pre-Submit-Check senkt Support-Aufwand" ergänzen | | Pre-Submit-Check-Liste in PM-Forms | computed `presubmitChecks` | Im Konzept als „Pre-Submit-Check senkt Support-Aufwand" ergänzen |

View file

@ -1,7 +1,8 @@
# Billing & Rechnungskreise (hybrides Modell) # Billing & Rechnungskreise (hybrides Modell)
Stand: 12.06.2026 — Datenmodell, MAN-Kreis und USt-Behandlung umgesetzt Stand: 12.06.2026 — Datenmodell, MAN-Kreis, USt-Behandlung (Phase 9D) sowie
(Phase 9D); Stripe-Checkout/Webhooks in Arbeit (Phase 9E). Stripe-Sync, Webhook-Verarbeitung, Checkout-Flows und Plan-Kontingent
(Phase 9E) umgesetzt. Es fehlt die Checkout-UI (Phase 9F).
Dieses Dokument ist die zentrale Referenz für das Abrechnungssystem: Dieses Dokument ist die zentrale Referenz für das Abrechnungssystem:
Rechnungskreise, Tarif-Datenmodell, Steuerlogik, Befehle und Konfiguration. Rechnungskreise, Tarif-Datenmodell, Steuerlogik, Befehle und Konfiguration.
@ -44,6 +45,35 @@ eingelöster Einzel-/Extra-PM-Kauf **oder** eine aktive/grandfathered
Legacy-Vereinbarung. Bestandskunden behalten damit nach Gate-Aktivierung Legacy-Vereinbarung. Bestandskunden behalten damit nach Gate-Aktivierung
volle Einreichungsrechte. volle Einreichungsrechte.
**PM-Kontingent** (`User::pressReleaseQuotaRemaining()`, null = unbegrenzt):
| Wer | Kontingent |
|---|---|
| Launch-Schalter `billing.enforce_booking` aus | unbegrenzt (Vor-Launch-Zustand) |
| Bestandskunde (aktive/grandfathered Legacy-Vereinbarung) | **unbegrenzt** — Entscheidung 12.06.2026: Bestandsschutz gilt, das Alt-Produkt sah unbegrenzte PMs vor; eine Migration auf neue Tarife kommt ggf. später |
| Abonnent | Monatskontingent des Tarifs (`plans.press_release_quota`) plus offene Einmalkäufe |
| Nur Einmalkäufe | Anzahl bezahlter, noch nicht eingelöster Einzel-/Extra-PM-Käufe |
**Slot-Verbrauch** (erst bei Veröffentlichung, Decision-Update §3.2; Ablehnung
kostet nichts; Re-Publish nach Archivierung zählt nicht doppelt): zuerst das
Plan-Monatskontingent (Zähler `users.press_release_quota_used_this_month`,
monatlicher Reset), danach wird der älteste bezahlte Einmalkauf eingelöst
(`single_purchases.status → consumed`, verknüpft mit der PM). Die frühere
Stub-Spalte `users.press_release_quota` ist entfernt.
**Checkout-Einstiege** (Phase 9E; UI-Anbindung folgt in 9F):
| Route | Zweck |
|---|---|
| `me.checkout.subscription` (`/admin/me/checkout/abo/{slug}/{monthly\|yearly}`) | Stripe-Checkout für ein Tarif-Abo |
| `me.checkout.single-pm` (`/admin/me/checkout/einzel-pm`) | Stripe-Checkout Einzel-PM (legt `single_purchases`-Eintrag `pending` an; Webhook setzt `paid`) |
Erfolg/Abbruch landen auf der Buchungs-Seite (`?checkout=erfolg|abbruch`).
Die Steuer ergänzt **Stripe Tax** automatisch (`Cashier::calculateTaxes()`
im AppServiceProvider, Netto-Preise mit `tax_behavior: exclusive`) — nach
denselben Regeln wie der VatResolver im MAN-Kreis, inkl. USt-ID-Abfrage im
Checkout.
--- ---
## 3. MAN-Kreis: Fälligkeitslauf für Legacy-Zahlungen ## 3. MAN-Kreis: Fälligkeitslauf für Legacy-Zahlungen
@ -102,8 +132,9 @@ Rechnungsadresse bestimmt:
| Befehl | Zweck | Scheduler | | Befehl | Zweck | Scheduler |
|---|---|---| |---|---|---|
| `billing:generate-manual-invoices` | MAN-Fälligkeitslauf (Abschnitt 3) | täglich 04:30 | | `billing:generate-manual-invoices` | MAN-Fälligkeitslauf (Abschnitt 3) | täglich 04:30 |
| `billing:sync-stripe-plans` | Tarife + Einzel-PM als Netto-Produkte/Preise nach Stripe synchronisieren (idempotent; `--dry-run`) | manuell |
| `legacy:grandfather-subscriptions` | Aktive Legacy-Abos aus dem Archiv migrieren | manuell (Migrations-Runbook) | | `legacy:grandfather-subscriptions` | Aktive Legacy-Abos aus dem Archiv migrieren | manuell (Migrations-Runbook) |
| `press-releases:reset-monthly-quota` | Quota-Stub-Reset (entfällt mit Plan-Kontingent, 9E) | monatlich, 1. um 00:05 | | `press-releases:reset-monthly-quota` | Monatlicher Reset des Plan-Kontingent-Zählers (`press_release_quota_used_this_month`) | monatlich, 1. um 00:05 |
--- ---
@ -119,36 +150,50 @@ Rechnungsadresse bestimmt:
| `vat_rate` | `BILLING_VAT_RATE` | `0.19` | USt-Satz für steuerpflichtige Fälle | | `vat_rate` | `BILLING_VAT_RATE` | `0.19` | USt-Satz für steuerpflichtige Fälle |
| `eu_country_codes` | — | EU-27 ohne DE | Basis der Drittland-/EU-Unterscheidung | | `eu_country_codes` | — | EU-27 ohne DE | Basis der Drittland-/EU-Unterscheidung |
Stripe/Cashier (`config/cashier.php`): Stripe/Cashier:
| ENV | Bedeutung | | ENV | Bedeutung |
|---|---| |---|---|
| `STRIPE_KEY` / `STRIPE_SECRET` | Publishable/Secret Key (Test-Keys gesetzt) | | `STRIPE_KEY` / `STRIPE_SECRET` | Publishable/Secret Key (Test-Keys gesetzt) |
| `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints — wird beim Einrichten des Endpoints gesetzt (9E) | | `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints (gesetzt; Endpoint `https://pressekonto.com/stripe/webhook` im Dashboard registriert, 12.06.2026) |
| `CASHIER_CURRENCY` | Default `usd` → für uns `eur` setzen (9E) | | `STRIPE_PRICE_SINGLE_PM` | Stripe-Price-ID der Einzel-PM (legt `billing:sync-stripe-plans` an; ohne sie ist der Einzel-PM-Checkout deaktiviert) |
| `CASHIER_CURRENCY` / `CASHIER_CURRENCY_LOCALE` | `eur` / `de_DE` (gesetzt) |
**Benötigte Webhook-Events** am Stripe-Endpoint: `invoice.payment_succeeded`
(STR-Spiegelung), `checkout.session.completed` (Einmalkauf-Erfüllung) sowie
die Cashier-Standardevents `customer.subscription.created/updated/deleted`,
`customer.updated`, `customer.deleted` (Abo-Zustand).
**Lokal testen** (der registrierte Endpoint zeigt auf die Live-Domain und
läuft bis zum Relaunch ins Leere — das ist unkritisch, Stripe versucht die
Zustellung nur erneut): Stripe CLI verwenden —
`stripe listen --forward-to pressekonto.test/stripe/webhook` und das von der
CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`.
--- ---
## 7. Offene Punkte (Stand 12.06.2026, nach 9E-Backbone) ## 7. Offene Punkte (Stand 12.06.2026, nach Phase 9E)
0. **9E-Backbone erledigt**: Tarife liegen als Netto-Produkte/Preise in 0. **9E erledigt**: Stripe-Sync (Tarife + Einzel-PM als Netto-Produkte,
Stripe (Test-Mode, `billing:sync-stripe-plans`, IDs in `plans`); Test-Mode), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung,
Webhook-Listener `ProcessStripeWebhook` spiegelt bezahlte Endpoint + Secret eingerichtet), Checkout-Flows (Abo + Einzel-PM,
Stripe-Rechnungen in den STR-Kreis (fortlaufende Nummer, Routen siehe Abschnitt 2), Slot-Logik auf Plan-Kontingent umgestellt
Adress-Snapshot aus dem Stripe-Payload, idempotent) und erfüllt (Grandfathered = unbegrenzt, Entscheidung 12.06.2026), Stub-Spalte
Einmalkäufe (`checkout.session.completed` entfernt, Stripe Tax aktiviert (`Cashier::calculateTaxes()`).
`single_purchases.status = paid`). Cashier-Route `POST /stripe/webhook` 1. **Phase 9F**: Tarif-Seite/Buchungs-UI an die Checkout-Routen anbinden
ist aktiv. (die Buchungs-Seite ist noch Konzept-Mock mit deaktivierten Buttons);
1. **Phase 9E (Rest)**: Checkout-Flows (Abo + Einmalkauf) inkl. echte Tarif-/Buchungsdaten statt Platzhalter anzeigen.
Buchungs-Seite, Webhook-Endpoint im Stripe-Dashboard registrieren + 2. **Stripe Tax im Dashboard aktivieren** (Ursprungsadresse/Registrierung
`STRIPE_WEBHOOK_SECRET` setzen, Slot-Logik von hinterlegen) — ohne das schlägt der Checkout mit automatischer Steuer
`users.press_release_quota`-Stub auf Plan-Kontingent umstellen fehl. Im Test-Mode prüfen, dann im Live-Mode wiederholen; dort auch
(fachlich zu klären: Kontingent-Semantik für Grandfathered — `billing:sync-stripe-plans` erneut ausführen (Live-Produkt-IDs).
Legacy-Produkt war „unbegrenzte PMs pro Pressemappe"). 3. **VIES-Validierung** der USt-ID (aktuell Formatprüfung; Stripe prüft
2. **VIES-Validierung** der USt-ID (aktuell Formatprüfung). die im Checkout erfasste USt-ID asynchron selbst).
3. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`); 4. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`);
Archiv-PDFs existieren bereits on-demand. Archiv-PDFs existieren bereits on-demand.
4. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung 5. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung
auf neue Tarife am `grandfathered_until` (D-13-Rest). auf neue Tarife am `grandfathered_until` (D-13-Rest); erst dann wird
5. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem das unbegrenzte Bestandskontingent ggf. migriert.
6. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem
ersten produktiven MAN-/STR-Lauf. ersten produktiven MAN-/STR-Lauf.
7. **Tageslimit** (`plans.daily_limit`) durchsetzen — Phase 9G.

View file

@ -128,7 +128,7 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic
- [x] Aktive Legacy-Zahlungen migrieren (12.06.): `legacy:grandfather-subscriptions` leitet aus dem Rechnungsarchiv die aktiven jaehrlichen Vereinbarungen ab (22 im Test-Snapshot) und schreibt sie als `grandfathered` nach `user_payment_options` — Replay-faehig fuer den Lauf kurz vor Relaunch. Doku: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6. - [x] Aktive Legacy-Zahlungen migrieren (12.06.): `legacy:grandfather-subscriptions` leitet aus dem Rechnungsarchiv die aktiven jaehrlichen Vereinbarungen ab (22 im Test-Snapshot) und schreibt sie als `grandfathered` nach `user_payment_options` — Replay-faehig fuer den Lauf kurz vor Relaunch. Doku: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6.
- [x] USt-Behandlung (12.06.): alle neuen Preise netto; `VatResolver` (DE immer Steuer, EU nur mit USt-ID befreit/Reverse Charge, Drittland befreit), `vat_id` an Rechnungsadresse + Rechnungs-Snapshot, `tax_note` auf Rechnungen; Grandfathered rechnen auf Netto-Basis der letzten Legacy-Rechnung (Brutto bleibt fuer DE-Bestandskunden gleich). - [x] USt-Behandlung (12.06.): alle neuen Preise netto; `VatResolver` (DE immer Steuer, EU nur mit USt-ID befreit/Reverse Charge, Drittland befreit), `vat_id` an Rechnungsadresse + Rechnungs-Snapshot, `tax_note` auf Rechnungen; Grandfathered rechnen auf Netto-Basis der letzten Legacy-Rechnung (Brutto bleibt fuer DE-Bestandskunden gleich).
- [ ] VIES-Validierung der USt-ID (aktuell Formatpruefung) — vor Gate-/Checkout-Aktivierung. - [ ] VIES-Validierung der USt-ID (aktuell Formatpruefung) — vor Gate-/Checkout-Aktivierung.
- [ ] Stripe-Checkout + Webhooks (Phase 9E): Produkte/Preise anlegen (netto, Steuer via Stripe Tax oder VatResolver), STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent umstellen, Quota-Stub abloesen. Benoetigt `STRIPE_KEY`/`STRIPE_SECRET` in `.env`. - [x] Stripe-Checkout + Webhooks (Phase 9E, 12.06.): Produkt-Sync nach Stripe (Tarife + Einzel-PM, netto, Stripe Tax), STR-Rechnungsspiegelung + Einmalkauf-Erfuellung per Webhook (Endpoint registriert), Checkout-Flows als Backend (`me.checkout.subscription`/`me.checkout.single-pm`), Slot-Logik auf Plan-Kontingent umgestellt (Grandfathered = unbegrenzt, Bestandsschutz), Quota-Stub-Spalte entfernt. UI-Anbindung folgt in 9F. Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`.
- [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs. - [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs.
- [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €. - [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €.
- [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen). - [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen).
@ -158,6 +158,6 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic
- Phase 1, Phase 7 (PM-Form-Refactor), Phase 8 (User-Panel-Konsolidierung) und die KI-Pruef-Pipeline (Phasen 05) sind abgeschlossen — siehe Plan-Dokus oben. - Phase 1, Phase 7 (PM-Form-Refactor), Phase 8 (User-Panel-Konsolidierung) und die KI-Pruef-Pipeline (Phasen 05) sind abgeschlossen — siehe Plan-Dokus oben.
- Fuer Preise, Kontingente und den Veroeffentlichungs-Flow gilt ausschliesslich das Decision-Update vom 11.06.2026; aeltere Tarif-Tabellen in `Konzept-Update 1` und im Relaunch-Konzept sind ueberschrieben. - Fuer Preise, Kontingente und den Veroeffentlichungs-Flow gilt ausschliesslich das Decision-Update vom 11.06.2026; aeltere Tarif-Tabellen in `Konzept-Update 1` und im Relaunch-Konzept sind ueberschrieben.
- Der Quota-Stub (3 PM/Monat, zaehlt beim Einreichen) bleibt bis zum Tarif-Modul aktiv; die Umstellung auf Slot-Verbrauch bei Veroeffentlichung ist Teil des Launch-Blocks. - Das PM-Kontingent kommt aus dem Tarif (`plans.press_release_quota`) plus Einmalkaeufen; Bestandskunden (Grandfathered) sind unbegrenzt. Solange `billing.enforce_booking` aus ist, gilt kein Kontingent (Launch-Schalter).
- Die KI-Klassifikation laeuft asynchron — in Produktion wird ein Queue-Worker fuer die Queue `classification` benoetigt (Test-Drain: `php artisan classification:work`). - Die KI-Klassifikation laeuft asynchron — in Produktion wird ein Queue-Worker fuer die Queue `classification` benoetigt (Test-Drain: `php artisan classification:work`).
- Anhaenge sind aktuell aus Sicherheitsgruenden deaktiviert, Tabelle und Komponente bleiben aber erhalten und werden in einem separaten Audit-Track reaktiviert. - Anhaenge sind aktuell aus Sicherheitsgruenden deaktiviert, Tabelle und Komponente bleiben aber erhalten und werden in einem separaten Audit-Track reaktiviert.

View file

@ -11,7 +11,9 @@
Wird in Detailansicht, Bearbeiten und Erstellen eingebunden. Der Wird in Detailansicht, Bearbeiten und Erstellen eingebunden. Der
`action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim `action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim
Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`, Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`,
`save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben sind. `save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben
sind null bedeutet unbegrenztes Kontingent (Bestandsschutz bzw.
Launch-Schalter aus) und blendet den Block aus.
Submit-Gate (Decision-Update §5.1): Ohne aktive Buchung zeigt das Modal Submit-Gate (Decision-Update §5.1): Ohne aktive Buchung zeigt das Modal
statt des Prüf-Flows einen Buchungs-Hinweis der Button konvertiert, statt des Prüf-Flows einen Buchungs-Hinweis der Button konvertiert,

View file

@ -9,6 +9,10 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
public function with(): array public function with(): array
{ {
return [ return [
// Rückkehr aus dem Stripe-Checkout (?checkout=erfolg|abbruch)
// bzw. Hinweis aus den Checkout-Guards (Session-Flash).
'checkoutResult' => request()->query('checkout'),
'checkoutNotice' => session('checkout-notice'),
'creditSummary' => [ 'creditSummary' => [
'total' => 17, 'total' => 17,
'bonus' => 12, 'bonus' => 12,
@ -99,6 +103,34 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
}; ?> }; ?>
<div class="space-y-8"> <div class="space-y-8">
{{-- ============== CHECKOUT-RÜCKMELDUNG ============== --}}
@if ($checkoutResult === 'erfolg')
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<div class="flex-1">
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Vielen Dank für Ihre Buchung!') }}</span>
{{ __('Die Zahlung wird von Stripe bestätigt — die Buchung erscheint hier in wenigen Augenblicken. Die Rechnung finden Sie anschließend unter Rechnungen.') }}
</div>
</div>
@elseif ($checkoutResult === 'abbruch')
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-bg-subtle)] border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-2)]">
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
<div class="flex-1">
{{ __('Der Bezahlvorgang wurde abgebrochen. Es wurde nichts gebucht — Sie können den Checkout jederzeit erneut starten.') }}
</div>
</div>
@endif
@if ($checkoutNotice)
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-bg-subtle)] border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-2)]">
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
<div class="flex-1">{{ $checkoutNotice }}</div>
</div>
@endif
{{-- ============== PAGE HEADER ============== --}} {{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;"> <header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0"> <div class="min-w-0">

View file

@ -546,7 +546,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
: Contact::query()->whereRaw('0 = 1')->get(), : Contact::query()->whereRaw('0 = 1')->get(),
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'), 'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
'quotaTotal' => (int) $user->press_release_quota, 'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
]; ];
} }

View file

@ -524,7 +524,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
'coverUrl' => $cover->coverUrl($pressRelease, 'cover'), 'coverUrl' => $cover->coverUrl($pressRelease, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease), 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease),
'quotaTotal' => (int) $user->press_release_quota, 'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
]; ];
} }

View file

@ -104,7 +104,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
'categoryName' => $categoryName, 'categoryName' => $categoryName,
'coverUrl' => $cover->coverUrl($pr, 'cover'), 'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr), 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'quotaTotal' => (int) $user->press_release_quota, 'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
'canEdit' => auth()->user()->can('update', $pr) 'canEdit' => auth()->user()->can('update', $pr)
&& in_array($pr->status->value, ['draft', 'rejected']), && in_array($pr->status->value, ['draft', 'rejected']),

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\LegacyInvoicePdfController; use App\Http\Controllers\LegacyInvoicePdfController;
use App\Http\Middleware\EnsureUserIsCustomer; use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests; use App\Http\Middleware\LogSlowAdminRequests;
@ -32,6 +33,11 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
->where('id', '[0-9]+'); ->where('id', '[0-9]+');
Volt::route('buchungen-add-ons', 'customer.bookings')->name('bookings.index'); Volt::route('buchungen-add-ons', 'customer.bookings')->name('bookings.index');
Route::get('checkout/abo/{planSlug}/{interval}', [CheckoutController::class, 'subscription'])
->whereIn('interval', ['monthly', 'yearly'])
->name('checkout.subscription');
Route::get('checkout/einzel-pm', [CheckoutController::class, 'singlePm'])
->name('checkout.single-pm');
Volt::route('invoices', 'customer.invoices')->name('invoices.index'); Volt::route('invoices', 'customer.invoices')->name('invoices.index');
Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf'); Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf');
Volt::route('tokens', 'customer.tokens')->name('tokens.index'); Volt::route('tokens', 'customer.tokens')->name('tokens.index');

View file

@ -3,6 +3,7 @@
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Models\Category; use App\Models\Category;
use App\Models\Company; use App\Models\Company;
use App\Models\Plan;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog; use App\Models\PressReleaseStatusLog;
use App\Models\User; use App\Models\User;
@ -79,10 +80,16 @@ test('api submit responds 402 when the booking gate is enforced', function () {
test('api submit responds 422 when the monthly quota is exhausted', function () { test('api submit responds 422 when the monthly quota is exhausted', function () {
/** @var TestCase $this */ /** @var TestCase $this */
config()->set('billing.enforce_booking', true);
$user = User::factory()->create([ $user = User::factory()->create([
'press_release_quota' => 3,
'press_release_quota_used_this_month' => 3, 'press_release_quota_used_this_month' => 3,
]); ]);
$plan = Plan::factory()->create([
'press_release_quota' => 3,
'stripe_price_id_monthly' => 'price_test_m_submit',
]);
subscribeUserToPlan($user, $plan);
$pressRelease = PressRelease::factory()->create([ $pressRelease = PressRelease::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value, 'status' => PressReleaseStatus::Draft->value,

View file

@ -0,0 +1,132 @@
<?php
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Services\Billing\StripeCheckoutService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\RedirectResponse;
use Laravel\Cashier\Checkout;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function checkoutTestCustomer(): User
{
$user = User::factory()->create();
$user->assignRole('customer');
return $user;
}
/**
* Stripe-Checkout-Stub: Der Controller gibt das Checkout-Objekt zurück,
* der Router ruft toResponse() wir leiten auf eine Fake-Stripe-URL um.
*/
function fakeStripeCheckout(): Checkout
{
$checkout = Mockery::mock(Checkout::class);
$checkout->shouldReceive('toResponse')
->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test'));
return $checkout;
}
test('guests are redirected to the login', function () {
/** @var TestCase $this */
$plan = Plan::factory()->create();
$this->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly']))
->assertRedirect();
});
test('an unknown plan or invalid interval responds 404', function () {
/** @var TestCase $this */
$this->actingAs(checkoutTestCustomer());
$this->get('/admin/me/checkout/abo/gibts-nicht/monthly')->assertNotFound();
$plan = Plan::factory()->create();
$this->get("/admin/me/checkout/abo/{$plan->slug}/weekly")->assertNotFound();
});
test('a plan without a synced stripe price redirects back with a notice', function () {
/** @var TestCase $this */
$plan = Plan::factory()->create(['stripe_price_id_monthly' => null]);
$this->actingAs(checkoutTestCustomer())
->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly']))
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
});
test('an already subscribed user is redirected instead of double-booking', function () {
/** @var TestCase $this */
$user = checkoutTestCustomer();
$plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_1']);
subscribeUserToPlan($user, $plan);
$this->actingAs($user)
->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly']))
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
});
test('the subscription checkout hands plan and interval to stripe', function () {
/** @var TestCase $this */
$user = checkoutTestCustomer();
$plan = Plan::factory()->create(['stripe_price_id_yearly' => 'price_test_y_1']);
$this->mock(StripeCheckoutService::class, function ($mock) use ($plan) {
$mock->shouldReceive('forSubscription')
->once()
->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($plan) && $interval === 'yearly')
->andReturn(fakeStripeCheckout());
});
$this->actingAs($user)
->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'yearly']))
->assertRedirect('https://checkout.stripe.com/c/pay/test');
});
test('the single pm checkout is disabled without a configured stripe price', function () {
/** @var TestCase $this */
config()->set('billing.single_pm_stripe_price_id', null);
$this->actingAs(checkoutTestCustomer())
->get(route('me.checkout.single-pm'))
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
expect(SinglePurchase::count())->toBe(0);
});
test('the single pm checkout creates a pending purchase and hands it to stripe', function () {
/** @var TestCase $this */
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
config()->set('billing.single_pm_price_cents', 1900);
$user = checkoutTestCustomer();
$this->mock(StripeCheckoutService::class, function ($mock) use ($user) {
$mock->shouldReceive('forSinglePurchase')
->once()
->withArgs(fn (User $u, SinglePurchase $p) => $u->is($user) && $p->exists)
->andReturn(fakeStripeCheckout());
});
$this->actingAs($user)
->get(route('me.checkout.single-pm'))
->assertRedirect('https://checkout.stripe.com/c/pay/test');
$purchase = SinglePurchase::sole();
expect($purchase->user_id)->toBe($user->id);
expect($purchase->type)->toBe(SinglePurchaseType::SinglePm);
expect($purchase->status)->toBe(SinglePurchaseStatus::Pending);
expect($purchase->price_cents)->toBe(1900);
});

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Models\Plan;
use Database\Seeders\RolesAndPermissionsSeeder; use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Volt\Volt as LivewireVolt; use Livewire\Volt\Volt as LivewireVolt;
@ -17,8 +18,17 @@ beforeEach(function (): void {
test('customer show renders the publish confirmation modal with legal note and quota', function () { test('customer show renders the publish confirmation modal with legal note and quota', function () {
/** @var TestCase $this */ /** @var TestCase $this */
// Das Kontingent erscheint nur mit scharfem Launch-Schalter und Tarif —
// unbegrenzte User (Schalter aus, Bestandsschutz) sehen den Block nicht.
config()->set('billing.enforce_booking', true);
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a();
$customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 1]); $customer->update(['press_release_quota_used_this_month' => 1]);
$plan = Plan::factory()->create([
'press_release_quota' => 3,
'stripe_price_id_monthly' => 'price_test_m_modal',
]);
subscribeUserToPlan($customer, $plan);
$this->actingAs($customer); $this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
@ -28,10 +38,21 @@ test('customer show renders the publish confirmation modal with legal note and q
->assertSee('2 / 3'); ->assertSee('2 / 3');
}); });
test('the quota block is hidden for users with an unlimited quota', function () {
/** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a();
$this->actingAs($customer);
// Launch-Schalter aus → Kontingent unbegrenzt → kein Quota-Block.
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Pressemitteilung zur Prüfung einreichen')
->assertDontSee('PM-Kontingent diesen Monat');
});
test('submitting from the show modal moves the draft into review without consuming quota', function () { test('submitting from the show modal moves the draft into review without consuming quota', function () {
/** @var TestCase $this */ /** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a();
$customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 0]); $customer->update(['press_release_quota_used_this_month' => 0]);
$this->actingAs($customer); $this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])

View file

@ -2,10 +2,15 @@
use App\Console\Commands\ResetMonthlyPressReleaseQuota; use App\Console\Commands\ResetMonthlyPressReleaseQuota;
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Category; use App\Models\Category;
use App\Models\Company; use App\Models\Company;
use App\Models\Plan;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User; use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException; use App\Services\PressRelease\QuotaExceededException;
use Database\Seeders\RolesAndPermissionsSeeder; use Database\Seeders\RolesAndPermissionsSeeder;
@ -15,6 +20,9 @@ use Tests\TestCase;
beforeEach(function (): void { beforeEach(function (): void {
/** @var TestCase $this */ /** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class); $this->seed(RolesAndPermissionsSeeder::class);
// Das Kontingent greift erst mit dem Launch-Schalter (sonst unbegrenzt).
config()->set('billing.enforce_booking', true);
}); });
function quotaTestPressRelease(User $user, string $status = 'draft'): PressRelease function quotaTestPressRelease(User $user, string $status = 'draft'): PressRelease
@ -31,25 +39,72 @@ function quotaTestPressRelease(User $user, string $status = 'draft'): PressRelea
]); ]);
} }
test('remaining quota reflects the used counter', function () { function quotaTestSubscriber(int $planQuota = 3, int $used = 0): User
{
$user = User::factory()->create([ $user = User::factory()->create([
'press_release_quota' => 3, 'press_release_quota_used_this_month' => $used,
'press_release_quota_used_this_month' => 1, ]);
$user->assignRole('customer');
$plan = Plan::factory()->create([
'press_release_quota' => $planQuota,
'stripe_price_id_monthly' => 'price_test_m_'.fake()->unique()->randomNumber(6),
]); ]);
subscribeUserToPlan($user, $plan);
return $user;
}
test('without the launch switch the quota is unlimited', function () {
config()->set('billing.enforce_booking', false);
$user = User::factory()->create();
expect($user->pressReleaseQuotaRemaining())->toBeNull();
expect($user->pressReleaseQuotaTotal())->toBeNull();
});
test('a subscriber inherits the monthly quota of the plan', function () {
$user = quotaTestSubscriber(planQuota: 3, used: 1);
expect($user->pressReleaseQuotaRemaining())->toBe(2); expect($user->pressReleaseQuotaRemaining())->toBe(2);
expect($user->pressReleaseQuotaTotal())->toBe(3);
});
test('paid single purchases extend the quota', function () {
$user = quotaTestSubscriber(planQuota: 3, used: 3);
SinglePurchase::factory()->paid()->count(2)->create(['user_id' => $user->id]);
SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]);
expect($user->pressReleaseQuotaRemaining())->toBe(2);
expect($user->pressReleaseQuotaTotal())->toBe(5);
});
test('a grandfathered legacy user has an unlimited quota', function () {
// Entscheidung 12.06.2026: Bestandsschutz — das Alt-Produkt sah
// unbegrenzte PMs vor, am Kontingent wird nichts umgestellt.
$user = User::factory()->create();
$user->assignRole('customer');
UserPaymentOption::factory()->create([
'user_id' => $user->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
]);
expect($user->pressReleaseQuotaRemaining())->toBeNull();
$pr = quotaTestPressRelease($user, 'review');
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
expect($user->fresh()->press_release_quota_used_this_month)->toBe(0);
}); });
test('submitting a press release does not consume a quota slot', function () { test('submitting a press release does not consume a quota slot', function () {
// Decision-Update §3.2: Der Slot zählt erst bei Veröffentlichung runter. // Decision-Update §3.2: Der Slot zählt erst bei Veröffentlichung runter.
Queue::fake(); Queue::fake();
$user = User::factory()->create([ $user = quotaTestSubscriber();
'press_release_quota' => 3,
'press_release_quota_used_this_month' => 0,
]);
$user->assignRole('customer');
$pr = quotaTestPressRelease($user); $pr = quotaTestPressRelease($user);
app(PressReleaseService::class)->submitForReview($pr); app(PressReleaseService::class)->submitForReview($pr);
@ -58,13 +113,8 @@ test('submitting a press release does not consume a quota slot', function () {
expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); expect($user->fresh()->press_release_quota_used_this_month)->toBe(0);
}); });
test('publishing consumes exactly one quota slot', function () { test('publishing consumes exactly one plan slot', function () {
$user = User::factory()->create([ $user = quotaTestSubscriber();
'press_release_quota' => 3,
'press_release_quota_used_this_month' => 0,
]);
$user->assignRole('customer');
$pr = quotaTestPressRelease($user, 'review'); $pr = quotaTestPressRelease($user, 'review');
app(PressReleaseService::class)->publish($pr); app(PressReleaseService::class)->publish($pr);
@ -74,12 +124,7 @@ test('publishing consumes exactly one quota slot', function () {
}); });
test('re-publishing after archive does not consume a second slot', function () { test('re-publishing after archive does not consume a second slot', function () {
$user = User::factory()->create([ $user = quotaTestSubscriber();
'press_release_quota' => 3,
'press_release_quota_used_this_month' => 0,
]);
$user->assignRole('customer');
$pr = quotaTestPressRelease($user, 'review'); $pr = quotaTestPressRelease($user, 'review');
$service = app(PressReleaseService::class); $service = app(PressReleaseService::class);
@ -91,12 +136,7 @@ test('re-publishing after archive does not consume a second slot', function () {
}); });
test('a rejected press release does not consume a quota slot', function () { test('a rejected press release does not consume a quota slot', function () {
$user = User::factory()->create([ $user = quotaTestSubscriber();
'press_release_quota' => 3,
'press_release_quota_used_this_month' => 0,
]);
$user->assignRole('customer');
$pr = quotaTestPressRelease($user, 'review'); $pr = quotaTestPressRelease($user, 'review');
app(PressReleaseService::class)->reject($pr, 'Unzulässiger Inhalt.', 'ki'); app(PressReleaseService::class)->reject($pr, 'Unzulässiger Inhalt.', 'ki');
@ -105,15 +145,44 @@ test('a rejected press release does not consume a quota slot', function () {
expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); expect($user->fresh()->press_release_quota_used_this_month)->toBe(0);
}); });
test('publishing past the plan quota consumes the oldest paid purchase', function () {
$user = quotaTestSubscriber(planQuota: 1, used: 1);
$older = SinglePurchase::factory()->paid()->create([
'user_id' => $user->id,
'paid_at' => now()->subDays(2),
]);
$newer = SinglePurchase::factory()->paid()->create([
'user_id' => $user->id,
'paid_at' => now()->subDay(),
]);
$pr = quotaTestPressRelease($user, 'review');
app(PressReleaseService::class)->publish($pr);
expect($user->fresh()->press_release_quota_used_this_month)->toBe(1);
expect($older->fresh()->status)->toBe(SinglePurchaseStatus::Consumed);
expect($older->fresh()->press_release_id)->toBe($pr->id);
expect($newer->fresh()->status)->toBe(SinglePurchaseStatus::Paid);
});
test('a purchase-only user consumes the purchase on publish', function () {
$user = User::factory()->create();
$user->assignRole('customer');
$purchase = SinglePurchase::factory()->paid()->create(['user_id' => $user->id]);
expect($user->pressReleaseQuotaRemaining())->toBe(1);
$pr = quotaTestPressRelease($user, 'review');
app(PressReleaseService::class)->publish($pr);
expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed);
expect($purchase->fresh()->consumed_at)->not->toBeNull();
});
test('submitting with an exhausted quota is blocked', function () { test('submitting with an exhausted quota is blocked', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->create([ $user = quotaTestSubscriber(planQuota: 3, used: 3);
'press_release_quota' => 3,
'press_release_quota_used_this_month' => 3,
]);
$user->assignRole('customer');
$pr = quotaTestPressRelease($user); $pr = quotaTestPressRelease($user);
expect(fn () => app(PressReleaseService::class)->submitForReview($pr)) expect(fn () => app(PressReleaseService::class)->submitForReview($pr))
@ -123,6 +192,7 @@ test('submitting with an exhausted quota is blocked', function () {
}); });
test('monthly reset command zeroes the used counter', function () { test('monthly reset command zeroes the used counter', function () {
/** @var TestCase $this */
User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]); User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]);
$untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]); $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]);

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Models\Plan;
use App\Models\User;
use App\Services\CurrentPortalContext; use App\Services\CurrentPortalContext;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -54,3 +56,21 @@ function something()
{ {
// .. // ..
} }
/**
* Legt offline eine aktive Cashier-Abo-Zeile für den User an (kein
* Stripe-Aufruf) und verknüpft sie über die Preis-ID mit dem Tarif
* `User::currentPlan()` löst darüber auf.
*/
function subscribeUserToPlan(User $user, Plan $plan, string $interval = 'monthly'): void
{
$priceId = $interval === 'yearly' ? $plan->stripe_price_id_yearly : $plan->stripe_price_id_monthly;
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test_'.fake()->unique()->randomNumber(6),
'stripe_status' => 'active',
'stripe_price' => $priceId,
'quantity' => 1,
]);
}