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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ return [
|
|||
// Zahlungsziel für Rechnungen des manuellen Kreises (Tage).
|
||||
'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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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 ✅
|
||||
|
||||
Zentrale Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| — | **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) |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
|
@ -180,20 +180,26 @@ ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand):
|
|||
`user_payment_options` (Replay-fähig — die Kern-Migration läuft kurz
|
||||
vor dem Relaunch erneut). Details:
|
||||
`dev/migration 2026/05-DATABASE-MERGE.md` §5.6 + `MIGRATION-STEPS.md`.
|
||||
- **Noch offen in 9D** (folgt mit 9E, braucht Checkout/Webhooks):
|
||||
Slot-Logik von `users.press_release_quota`-Stub auf Plan-Kontingent +
|
||||
Periodenzähler umstellen und Stub-Spalten entfernen.
|
||||
- ~~Noch offen in 9D~~ → mit 9E erledigt: Slot-Logik auf Plan-Kontingent
|
||||
umgestellt, Stub-Spalte entfernt.
|
||||
|
||||
### 9E · Stripe (Laravel Cashier)
|
||||
### 9E · Stripe (Laravel Cashier) ✅ (12.06.2026)
|
||||
|
||||
- Checkout für Abo (monatlich/jährlich) und Einmalzahlung (Einzel-PM, Credits);
|
||||
Stripe-Produkte/Preise anlegen und IDs in `plans` pflegen.
|
||||
- Webhooks (Subscription-Status, Zahlungsausfall, `invoice.paid`) + Spiegelung
|
||||
der Stripe-Rechnungen in `invoices` mit STR-Nummer aus dem
|
||||
`InvoiceNumberGenerator`.
|
||||
- Slot-Logik auf Plan-Kontingent umstellen (siehe 9D-Rest), Stub ablösen.
|
||||
- Benötigt `STRIPE_KEY`/`STRIPE_SECRET`/`STRIPE_WEBHOOK_SECRET` in `.env`
|
||||
(aktuell nicht gesetzt).
|
||||
- ✅ Produkt-Sync: `billing:sync-stripe-plans` legt Tarife (Monats-/Jahres-
|
||||
preise) und das Einzel-PM-Produkt als Netto-Preise in Stripe an
|
||||
(Test-Mode gelaufen; IDs in `plans` bzw. `STRIPE_PRICE_SINGLE_PM`).
|
||||
- ✅ Webhooks: `ProcessStripeWebhook` spiegelt bezahlte Stripe-Rechnungen
|
||||
mit STR-Nummer in `invoices` und erfüllt Einmalkäufe; Endpoint
|
||||
`https://pressekonto.com/stripe/webhook` registriert, Secret gesetzt.
|
||||
- ✅ Checkout-Flows (Backend): `me.checkout.subscription` +
|
||||
`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
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ Zentrale Billing-Referenz: [`user-admin/Billing-und-Rechnungskreise.md`](./user-
|
|||
| 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`) | ✅ |
|
||||
| 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) |
|
||||
| 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** |
|
||||
| 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 |
|
||||
| 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** |
|
||||
| Launch-Credits: Extra-PM, Boost (nur grün), Veröffentlichungsnachweis-PDF | **fehlt** |
|
||||
| Jahrespreis als „2 Monate gratis" | Kommunikations-Regel, greift mit Tarif-UI |
|
||||
| `user_payment_options`-Tabelle | **vorhanden** (Pivot zu Companies da, aber kein aktiver Flow) |
|
||||
|
||||
**Bewertung**: 📝 — der **Launch-Block** und damit das größte ungebaute Feature.
|
||||
Vorhandene Anschlusspunkte: Quota-Stub (`users.press_release_quota`,
|
||||
`pressReleaseQuotaRemaining()`), Veröffentlichungs-Modal (zeigt Kontingent),
|
||||
Vorhandene Anschlusspunkte: Plan-Kontingent (`pressReleaseQuotaRemaining()`,
|
||||
null = unbegrenzt/Bestandsschutz), Veröffentlichungs-Modal (zeigt Kontingent),
|
||||
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
|
||||
(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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# Billing & Rechnungskreise (hybrides Modell)
|
||||
|
||||
Stand: 12.06.2026 — Datenmodell, MAN-Kreis und USt-Behandlung umgesetzt
|
||||
(Phase 9D); Stripe-Checkout/Webhooks in Arbeit (Phase 9E).
|
||||
Stand: 12.06.2026 — Datenmodell, MAN-Kreis, USt-Behandlung (Phase 9D) sowie
|
||||
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:
|
||||
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
|
||||
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
|
||||
|
|
@ -102,8 +132,9 @@ Rechnungsadresse bestimmt:
|
|||
| Befehl | Zweck | Scheduler |
|
||||
|---|---|---|
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `eu_country_codes` | — | EU-27 ohne DE | Basis der Drittland-/EU-Unterscheidung |
|
||||
|
||||
Stripe/Cashier (`config/cashier.php`):
|
||||
Stripe/Cashier:
|
||||
|
||||
| ENV | Bedeutung |
|
||||
|---|---|
|
||||
| `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) |
|
||||
| `CASHIER_CURRENCY` | Default `usd` → für uns `eur` setzen (9E) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints (gesetzt; Endpoint `https://pressekonto.com/stripe/webhook` im Dashboard registriert, 12.06.2026) |
|
||||
| `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
|
||||
Stripe (Test-Mode, `billing:sync-stripe-plans`, IDs in `plans`);
|
||||
Webhook-Listener `ProcessStripeWebhook` spiegelt bezahlte
|
||||
Stripe-Rechnungen in den STR-Kreis (fortlaufende Nummer,
|
||||
Adress-Snapshot aus dem Stripe-Payload, idempotent) und erfüllt
|
||||
Einmalkäufe (`checkout.session.completed` →
|
||||
`single_purchases.status = paid`). Cashier-Route `POST /stripe/webhook`
|
||||
ist aktiv.
|
||||
1. **Phase 9E (Rest)**: Checkout-Flows (Abo + Einmalkauf) inkl.
|
||||
Buchungs-Seite, Webhook-Endpoint im Stripe-Dashboard registrieren +
|
||||
`STRIPE_WEBHOOK_SECRET` setzen, Slot-Logik von
|
||||
`users.press_release_quota`-Stub auf Plan-Kontingent umstellen
|
||||
(fachlich zu klären: Kontingent-Semantik für Grandfathered —
|
||||
Legacy-Produkt war „unbegrenzte PMs pro Pressemappe").
|
||||
2. **VIES-Validierung** der USt-ID (aktuell Formatprüfung).
|
||||
3. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`);
|
||||
0. **9E erledigt**: Stripe-Sync (Tarife + Einzel-PM als Netto-Produkte,
|
||||
Test-Mode), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung,
|
||||
Endpoint + Secret eingerichtet), Checkout-Flows (Abo + Einzel-PM,
|
||||
Routen siehe Abschnitt 2), Slot-Logik auf Plan-Kontingent umgestellt
|
||||
(Grandfathered = unbegrenzt, Entscheidung 12.06.2026), Stub-Spalte
|
||||
entfernt, Stripe Tax aktiviert (`Cashier::calculateTaxes()`).
|
||||
1. **Phase 9F**: Tarif-Seite/Buchungs-UI an die Checkout-Routen anbinden
|
||||
(die Buchungs-Seite ist noch Konzept-Mock mit deaktivierten Buttons);
|
||||
echte Tarif-/Buchungsdaten statt Platzhalter anzeigen.
|
||||
2. **Stripe Tax im Dashboard aktivieren** (Ursprungsadresse/Registrierung
|
||||
hinterlegen) — ohne das schlägt der Checkout mit automatischer Steuer
|
||||
fehl. Im Test-Mode prüfen, dann im Live-Mode wiederholen; dort auch
|
||||
`billing:sync-stripe-plans` erneut ausführen (Live-Produkt-IDs).
|
||||
3. **VIES-Validierung** der USt-ID (aktuell Formatprüfung; Stripe prüft
|
||||
die im Checkout erfasste USt-ID asynchron selbst).
|
||||
4. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`);
|
||||
Archiv-PDFs existieren bereits on-demand.
|
||||
4. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung
|
||||
auf neue Tarife am `grandfathered_until` (D-13-Rest).
|
||||
5. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem
|
||||
5. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung
|
||||
auf neue Tarife am `grandfathered_until` (D-13-Rest); erst dann wird
|
||||
das unbegrenzte Bestandskontingent ggf. migriert.
|
||||
6. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem
|
||||
ersten produktiven MAN-/STR-Lauf.
|
||||
7. **Tageslimit** (`plans.daily_limit`) durchsetzen — Phase 9G.
|
||||
|
|
|
|||
|
|
@ -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] 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.
|
||||
- [ ] 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.
|
||||
- [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €.
|
||||
- [ ] 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 0–5) 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.
|
||||
- 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`).
|
||||
- Anhaenge sind aktuell aus Sicherheitsgruenden deaktiviert, Tabelle und Komponente bleiben aber erhalten und werden in einem separaten Audit-Track reaktiviert.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
Wird in Detailansicht, Bearbeiten und Erstellen eingebunden. Der
|
||||
`action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim
|
||||
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
|
||||
statt des Prüf-Flows einen Buchungs-Hinweis — der Button konvertiert,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
public function with(): array
|
||||
{
|
||||
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' => [
|
||||
'total' => 17,
|
||||
'bonus' => 12,
|
||||
|
|
@ -99,6 +103,34 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
}; ?>
|
||||
|
||||
<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 ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
<div class="min-w-0">
|
||||
|
|
|
|||
|
|
@ -546,7 +546,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
: Contact::query()->whereRaw('0 = 1')->get(),
|
||||
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
|
||||
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
|
||||
'quotaTotal' => (int) $user->press_release_quota,
|
||||
'quotaTotal' => $user->pressReleaseQuotaTotal(),
|
||||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -524,7 +524,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
|
||||
'coverUrl' => $cover->coverUrl($pressRelease, 'cover'),
|
||||
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease),
|
||||
'quotaTotal' => (int) $user->press_release_quota,
|
||||
'quotaTotal' => $user->pressReleaseQuotaTotal(),
|
||||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
'categoryName' => $categoryName,
|
||||
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
||||
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
||||
'quotaTotal' => (int) $user->press_release_quota,
|
||||
'quotaTotal' => $user->pressReleaseQuotaTotal(),
|
||||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||||
'canEdit' => auth()->user()->can('update', $pr)
|
||||
&& in_array($pr->status->value, ['draft', 'rejected']),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\CheckoutController;
|
||||
use App\Http\Controllers\LegacyInvoicePdfController;
|
||||
use App\Http\Middleware\EnsureUserIsCustomer;
|
||||
use App\Http\Middleware\LogSlowAdminRequests;
|
||||
|
|
@ -32,6 +33,11 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
|
|||
->where('id', '[0-9]+');
|
||||
|
||||
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');
|
||||
Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf');
|
||||
Volt::route('tokens', 'customer.tokens')->name('tokens.index');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Plan;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
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 () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('billing.enforce_booking', true);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'press_release_quota' => 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([
|
||||
'user_id' => $user->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
|
|
|
|||
132
tests/Feature/Billing/CheckoutFlowTest.php
Normal file
132
tests/Feature/Billing/CheckoutFlowTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Plan;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
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 () {
|
||||
/** @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->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);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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 () {
|
||||
/** @var TestCase $this */
|
||||
['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);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@
|
|||
|
||||
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Plan;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
|
|
@ -15,6 +20,9 @@ use Tests\TestCase;
|
|||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$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
|
||||
|
|
@ -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([
|
||||
'press_release_quota' => 3,
|
||||
'press_release_quota_used_this_month' => 1,
|
||||
'press_release_quota_used_this_month' => $used,
|
||||
]);
|
||||
$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->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 () {
|
||||
// Decision-Update §3.2: Der Slot zählt erst bei Veröffentlichung runter.
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'press_release_quota' => 3,
|
||||
'press_release_quota_used_this_month' => 0,
|
||||
]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
$user = quotaTestSubscriber();
|
||||
$pr = quotaTestPressRelease($user);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('publishing consumes exactly one quota slot', function () {
|
||||
$user = User::factory()->create([
|
||||
'press_release_quota' => 3,
|
||||
'press_release_quota_used_this_month' => 0,
|
||||
]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
test('publishing consumes exactly one plan slot', function () {
|
||||
$user = quotaTestSubscriber();
|
||||
$pr = quotaTestPressRelease($user, 'review');
|
||||
|
||||
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 () {
|
||||
$user = User::factory()->create([
|
||||
'press_release_quota' => 3,
|
||||
'press_release_quota_used_this_month' => 0,
|
||||
]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
$user = quotaTestSubscriber();
|
||||
$pr = quotaTestPressRelease($user, 'review');
|
||||
$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 () {
|
||||
$user = User::factory()->create([
|
||||
'press_release_quota' => 3,
|
||||
'press_release_quota_used_this_month' => 0,
|
||||
]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
$user = quotaTestSubscriber();
|
||||
$pr = quotaTestPressRelease($user, 'review');
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 () {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'press_release_quota' => 3,
|
||||
'press_release_quota_used_this_month' => 3,
|
||||
]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
$user = quotaTestSubscriber(planQuota: 3, used: 3);
|
||||
$pr = quotaTestPressRelease($user);
|
||||
|
||||
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 () {
|
||||
/** @var TestCase $this */
|
||||
User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]);
|
||||
$untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Services\CurrentPortalContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue