From d548f4b23516d97d047657fc1793db4c35727e9c Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 12 Jun 2026 10:15:46 +0000 Subject: [PATCH] Phase 9D: Tarif-Datenmodell, Cashier und hybride Rechnungskreise STR-/MAN- Tarif-Datenmodell (Decision-Update): - plans: Starter/Business/Pro/Agency mit Monats-/Jahrespreis (Jahres = 10 x Monat), PM-Kontingent, Tageslimit, Stripe-IDs; idempotenter Seeder - single_purchases: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit Status-Lifecycle und Stripe-Checkout-Referenzen - laravel/cashier ^16.5 installiert (freigegeben); User ist Billable, Cashier-Migrationen published + ausgefuehrt; lokale invoices()-Relation ueberschreibt bewusst die Cashier-Methode Hybride Rechnungskreise (Entscheidung 12.06.2026): - invoice_number_sequences + InvoiceNumberGenerator: atomare fortlaufende Nummern pro Kreis (STR- fuer den neuen Stripe-Shop, MAN- fuer den manuellen Legacy-Kreis); Alt-Archiv legacy_invoices bleibt unveraendert - ManualInvoiceService + billing:generate-manual-invoices (Scheduler taeglich 04:30): prueft aktive/grandfathered user_payment_options ohne Stripe-Subscription auf erreichtes Periodenende, friert die Rechnungsadresse als Snapshot ein, stellt die MAN-Rechnung aus (Zahlungsziel billing.manual_due_days) und schaltet die Periode weiter; Konditions-Overrides via legacy_conditions, sonst Netto-Preis + billing.vat_rate; nicht abrechenbare Faelle werden geloggt und beim naechsten Lauf erneut geprueft Submit-Gate: - User::hasActiveBooking() prueft jetzt echt (hinter billing.enforce_booking): Cashier-Abo, bezahlter Einzel-/Extra-PM-Kauf oder laufende Legacy-Vereinbarung (MAN-Kreis) Suite: 468 passed, 4 skipped (17 neue Billing-Tests). Pint clean. Offen fuer 9E: Stripe-Checkout/Webhooks, STR-Spiegelung, Slot-Logik auf Plan-Kontingent, Migration der aktiven Legacy-Zahlungen in user_payment_options. Co-Authored-By: Claude Fable 5 --- .../Commands/GenerateManualInvoices.php | 70 ++++ app/Enums/SinglePurchaseStatus.php | 21 ++ app/Enums/SinglePurchaseType.php | 30 ++ app/Models/Plan.php | 51 +++ app/Models/SinglePurchase.php | 68 ++++ app/Models/User.php | 38 +- .../Billing/InvoiceNumberGenerator.php | 69 ++++ app/Services/Billing/ManualInvoiceService.php | 156 +++++++++ composer.json | 1 + composer.lock | 328 +++++++++++++++++- config/billing.php | 22 ++ database/factories/PlanFactory.php | 36 ++ database/factories/SinglePurchaseFactory.php | 45 +++ ...6_06_12_100632_create_customer_columns.php | 40 +++ ...6_12_100633_create_subscriptions_table.php | 37 ++ ...100634_create_subscription_items_table.php | 34 ++ ...d_meter_id_to_subscription_items_table.php | 28 ++ ...event_name_to_subscription_items_table.php | 28 ++ ..._create_invoice_number_sequences_table.php | 29 ++ .../2026_06_12_100724_create_plans_table.php | 39 +++ ...2_100724_create_single_purchases_table.php | 46 +++ database/seeders/PlanSeeder.php | 36 ++ docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md | 69 +++- docs/user-admin/checkliste-user-backend.md | 6 +- routes/console.php | 13 + .../Feature/Billing/HasActiveBookingTest.php | 75 ++++ .../Billing/InvoiceNumberGeneratorTest.php | 18 + .../Billing/ManualInvoiceGenerationTest.php | 137 ++++++++ 28 files changed, 1545 insertions(+), 25 deletions(-) create mode 100644 app/Console/Commands/GenerateManualInvoices.php create mode 100644 app/Enums/SinglePurchaseStatus.php create mode 100644 app/Enums/SinglePurchaseType.php create mode 100644 app/Models/Plan.php create mode 100644 app/Models/SinglePurchase.php create mode 100644 app/Services/Billing/InvoiceNumberGenerator.php create mode 100644 app/Services/Billing/ManualInvoiceService.php create mode 100644 database/factories/PlanFactory.php create mode 100644 database/factories/SinglePurchaseFactory.php create mode 100644 database/migrations/2026_06_12_100632_create_customer_columns.php create mode 100644 database/migrations/2026_06_12_100633_create_subscriptions_table.php create mode 100644 database/migrations/2026_06_12_100634_create_subscription_items_table.php create mode 100644 database/migrations/2026_06_12_100635_add_meter_id_to_subscription_items_table.php create mode 100644 database/migrations/2026_06_12_100636_add_meter_event_name_to_subscription_items_table.php create mode 100644 database/migrations/2026_06_12_100724_create_invoice_number_sequences_table.php create mode 100644 database/migrations/2026_06_12_100724_create_plans_table.php create mode 100644 database/migrations/2026_06_12_100724_create_single_purchases_table.php create mode 100644 database/seeders/PlanSeeder.php create mode 100644 tests/Feature/Billing/HasActiveBookingTest.php create mode 100644 tests/Feature/Billing/InvoiceNumberGeneratorTest.php create mode 100644 tests/Feature/Billing/ManualInvoiceGenerationTest.php diff --git a/app/Console/Commands/GenerateManualInvoices.php b/app/Console/Commands/GenerateManualInvoices.php new file mode 100644 index 0000000..551ff03 --- /dev/null +++ b/app/Console/Commands/GenerateManualInvoices.php @@ -0,0 +1,70 @@ +option('dry-run'); + $limit = max(1, (int) $this->option('limit')); + + $due = $service->duePaymentOptions(limit: $limit); + + if ($due->isEmpty()) { + $this->info('Keine fälligen Zahlungsvereinbarungen gefunden.'); + + return self::SUCCESS; + } + + $created = 0; + $skipped = 0; + + foreach ($due as $option) { + if ($dryRun) { + $this->line(sprintf( + '[dry-run] Fällig: Vereinbarung #%d (User #%s, Periode bis %s)', + $option->id, + $option->user_id, + $option->current_period_end->toDateString(), + )); + + continue; + } + + $invoice = $service->invoiceFor($option); + + if ($invoice) { + $created++; + $this->line(sprintf('Rechnung %s für Vereinbarung #%d erstellt.', $invoice->number, $option->id)); + } else { + $skipped++; + $this->warn(sprintf('Vereinbarung #%d übersprungen (siehe Log).', $option->id)); + } + } + + if (! $dryRun) { + $this->info(sprintf('%d Rechnung(en) erstellt, %d übersprungen.', $created, $skipped)); + } + + return self::SUCCESS; + } +} diff --git a/app/Enums/SinglePurchaseStatus.php b/app/Enums/SinglePurchaseStatus.php new file mode 100644 index 0000000..824416f --- /dev/null +++ b/app/Enums/SinglePurchaseStatus.php @@ -0,0 +1,21 @@ + 'Ausstehend', + self::Paid => 'Bezahlt', + self::Consumed => 'Eingelöst', + self::Refunded => 'Erstattet', + }; + } +} diff --git a/app/Enums/SinglePurchaseType.php b/app/Enums/SinglePurchaseType.php new file mode 100644 index 0000000..3c92f2d --- /dev/null +++ b/app/Enums/SinglePurchaseType.php @@ -0,0 +1,30 @@ + 'Einzel-Pressemitteilung', + self::ExtraPm => 'Extra-PM', + self::Boost => 'Boost / Platzierung', + self::ProofPdf => 'Veröffentlichungsnachweis (PDF)', + }; + } + + /** + * Käufe, die zum Einreichen/Veröffentlichen einer PM berechtigen + * (relevant für das Submit-Gate und den Slot-Verbrauch). + */ + public function grantsSubmission(): bool + { + return in_array($this, [self::SinglePm, self::ExtraPm], true); + } +} diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 0000000..94af782 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,51 @@ + 'integer', + 'yearly_price_cents' => 'integer', + 'press_release_quota' => 'integer', + 'daily_limit' => 'integer', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true)->orderBy('sort_order'); + } +} diff --git a/app/Models/SinglePurchase.php b/app/Models/SinglePurchase.php new file mode 100644 index 0000000..6fb079b --- /dev/null +++ b/app/Models/SinglePurchase.php @@ -0,0 +1,68 @@ + SinglePurchaseType::class, + 'status' => SinglePurchaseStatus::class, + 'price_cents' => 'integer', + 'paid_at' => 'datetime', + 'consumed_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } + + /** + * Bezahlte, noch nicht eingelöste Käufe, die zum Einreichen berechtigen. + */ + public function scopeGrantingSubmission(Builder $query): Builder + { + return $query + ->where('status', SinglePurchaseStatus::Paid->value) + ->whereIn('type', [ + SinglePurchaseType::SinglePm->value, + SinglePurchaseType::ExtraPm->value, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e155365..f55ccf0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\Portal; use App\Enums\RegistrationType; +use App\Enums\UserPaymentOptionStatus; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; +use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; @@ -21,7 +23,7 @@ use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { /** @use HasFactory */ - use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; + use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -96,10 +98,11 @@ class User extends Authenticatable * Submit-Gate aus dem Decision-Update §5.1: Einreichen zur Prüfung * erfordert eine aktive Buchung. * - * Stub bis zum Tarif-Modul (Phase 9D/9E): solange - * `billing.enforce_booking` deaktiviert ist (Default), gilt jede:r als - * gebucht. Das Tarif-Modul ersetzt den Rumpf durch die echte - * Subscription-/Einzelkauf-Prüfung — die Schnittstelle bleibt stabil. + * Hybrides Modell: Eine Buchung ist entweder ein aktives Stripe-Abo + * (Cashier, STR-Kreis), ein bezahlter Einmalkauf (Einzel-PM/Extra-PM) + * oder eine laufende Legacy-Zahlungsvereinbarung (manueller MAN-Kreis). + * Solange `billing.enforce_booking` deaktiviert ist, bleibt das Gate + * offen (Launch-Schalter). */ public function hasActiveBooking(): bool { @@ -107,7 +110,20 @@ class User extends Authenticatable return true; } - return false; + if ($this->subscribed()) { + return true; + } + + if ($this->singlePurchases()->grantingSubmission()->exists()) { + return true; + } + + return $this->userPaymentOptions() + ->whereIn('status', [ + UserPaymentOptionStatus::Active->value, + UserPaymentOptionStatus::Grandfathered->value, + ]) + ->exists(); } /** @@ -169,6 +185,16 @@ class User extends Authenticatable return $this->hasMany(UserPaymentOption::class); } + public function singlePurchases(): HasMany + { + return $this->hasMany(SinglePurchase::class); + } + + /** + * Lokale Rechnungen (STR- und MAN-Kreis). Überschreibt bewusst die + * gleichnamige Cashier-Methode — Stripe-Rechnungen werden beim + * Webhook-Sync (9E) in diese Tabelle gespiegelt. + */ public function invoices(): HasMany { return $this->hasMany(Invoice::class); diff --git a/app/Services/Billing/InvoiceNumberGenerator.php b/app/Services/Billing/InvoiceNumberGenerator.php new file mode 100644 index 0000000..9e3f4e9 --- /dev/null +++ b/app/Services/Billing/InvoiceNumberGenerator.php @@ -0,0 +1,69 @@ +next(self::CIRCLE_STRIPE); + } + + public function nextManualNumber(): string + { + return $this->next(self::CIRCLE_MANUAL); + } + + public function next(string $circle): string + { + if (! in_array($circle, [self::CIRCLE_STRIPE, self::CIRCLE_MANUAL], true)) { + throw new InvalidArgumentException("Unbekannter Rechnungskreis: {$circle}"); + } + + $number = DB::transaction(function () use ($circle): int { + $sequence = DB::table('invoice_number_sequences') + ->where('circle', $circle) + ->lockForUpdate() + ->first(); + + if (! $sequence) { + DB::table('invoice_number_sequences')->insert([ + 'circle' => $circle, + 'next_number' => 2, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 1; + } + + DB::table('invoice_number_sequences') + ->where('id', $sequence->id) + ->update(['next_number' => $sequence->next_number + 1, 'updated_at' => now()]); + + return (int) $sequence->next_number; + }); + + $padding = (int) config('billing.invoice_number_padding', 5); + + return sprintf('%s-%s', $circle, str_pad((string) $number, $padding, '0', STR_PAD_LEFT)); + } +} diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php new file mode 100644 index 0000000..18c4da2 --- /dev/null +++ b/app/Services/Billing/ManualInvoiceService.php @@ -0,0 +1,156 @@ + + */ + public function duePaymentOptions(?Carbon $asOf = null, int $limit = 50): Collection + { + $asOf = $asOf ?? today(); + + return UserPaymentOption::query() + ->whereIn('status', [ + UserPaymentOptionStatus::Active->value, + UserPaymentOptionStatus::Grandfathered->value, + ]) + ->whereNull('stripe_subscription_id') + ->whereDate('current_period_end', '<=', $asOf) + ->orderBy('current_period_end') + ->limit($limit) + ->with(['user.billingAddress', 'paymentOption']) + ->get(); + } + + /** + * Stellt die fällige Rechnung für eine Vereinbarung aus und schaltet die + * Periode weiter. Gibt die Rechnung zurück oder null, wenn die + * Vereinbarung (noch) nicht abrechenbar ist — dann bleibt die Periode + * unverändert und der nächste Lauf versucht es erneut. + */ + public function invoiceFor(UserPaymentOption $option, ?Carbon $asOf = null): ?Invoice + { + $asOf = $asOf ?? today(); + + $user = $option->user; + $interval = $this->billingInterval($option); + + if (! $user) { + Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $option->id]); + + return null; + } + + if (! $interval) { + Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $option->id]); + + return null; + } + + $billingAddress = $user->billingAddress; + + if (! $billingAddress) { + Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [ + 'user_payment_option_id' => $option->id, + 'user_id' => $user->id, + ]); + + return null; + } + + [$amountCents, $taxCents, $totalCents] = $this->resolveAmounts($option); + + return DB::transaction(function () use ($option, $user, $billingAddress, $amountCents, $taxCents, $totalCents, $interval, $asOf): Invoice { + // Adresse pro Rechnung einfrieren (Snapshot-Tabelle). + $addressSnapshot = InvoiceBillingAddress::query()->create([ + 'salutation_key' => $billingAddress->salutation_key, + 'title' => $billingAddress->title, + 'name' => $billingAddress->name, + 'address1' => $billingAddress->address1, + 'address2' => $billingAddress->address2, + 'postal_code' => $billingAddress->postal_code, + 'city' => $billingAddress->city, + 'country_code' => $billingAddress->country_code, + ]); + + $invoice = Invoice::query()->create([ + 'user_id' => $user->id, + 'invoice_billing_address_id' => $addressSnapshot->id, + 'number' => $this->numbers->nextManualNumber(), + 'status' => InvoiceStatus::Open->value, + 'amount_cents' => $amountCents, + 'tax_cents' => $taxCents, + 'total_cents' => $totalCents, + 'currency' => $option->paymentOption?->currency ?? 'EUR', + 'invoice_date' => $asOf, + 'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)), + ]); + + $option->update([ + 'current_period_start' => $option->current_period_end, + 'current_period_end' => $interval === 'yearly' + ? $option->current_period_end->copy()->addYear() + : $option->current_period_end->copy()->addMonth(), + ]); + + return $invoice; + }); + } + + private function billingInterval(UserPaymentOption $option): ?string + { + $interval = $option->legacy_conditions['interval'] + ?? $option->paymentOption?->interval; + + return in_array($interval, ['monthly', 'yearly'], true) ? $interval : null; + } + + /** + * @return array{0: int, 1: int, 2: int} [amount_cents, tax_cents, total_cents] + */ + private function resolveAmounts(UserPaymentOption $option): array + { + $conditions = $option->legacy_conditions ?? []; + + if (isset($conditions['amount_cents'], $conditions['total_cents'])) { + $amount = (int) $conditions['amount_cents']; + $total = (int) $conditions['total_cents']; + + return [$amount, (int) ($conditions['tax_cents'] ?? $total - $amount), $total]; + } + + $amount = (int) ($conditions['amount_cents'] ?? $option->paymentOption?->price_cents ?? 0); + $tax = (int) round($amount * (float) config('billing.vat_rate', 0.19)); + + return [$amount, $tax, $amount + $tax]; + } +} diff --git a/composer.json b/composer.json index 89b7807..3954ef5 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "blade-ui-kit/blade-heroicons": "^2.6", + "laravel/cashier": "^16.5", "laravel/fortify": "^1.27", "laravel/framework": "^12.0", "laravel/sanctum": "^4.1", diff --git a/composer.lock b/composer.lock index 6db2f41..4c4be24 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cbc29fc1cf64ca319c7c0ef7e0c1088c", + "content-hash": "7ad3d072c1669ef5d37e58ba10187b58", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1369,6 +1369,95 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "laravel/cashier", + "version": "v16.5.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/cashier-stripe.git", + "reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/b6bcd6b4d79acead34d00a5a528c904d67c5e08a", + "reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/log": "^10.0|^11.0|^12.0|^13.0", + "illuminate/notifications": "^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + "moneyphp/money": "^4.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.1", + "stripe/stripe-php": "^17.3.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.0|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.22.1", + "symfony/polyfill-php84": "^1.32" + }, + "require-dev": { + "dompdf/dompdf": "^2.0|^3.0", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10", + "spatie/laravel-ray": "^1.40" + }, + "suggest": { + "dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).", + "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.", + "spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Cashier\\CashierServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "16.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Cashier\\": "src/", + "Laravel\\Cashier\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Dries Vints", + "email": "dries@laravel.com" + } + ], + "description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.", + "keywords": [ + "billing", + "laravel", + "stripe" + ], + "support": { + "issues": "https://github.com/laravel/cashier/issues", + "source": "https://github.com/laravel/cashier" + }, + "time": "2026-05-05T21:18:35+00:00" + }, { "name": "laravel/fortify", "version": "v1.36.2", @@ -2826,6 +2915,96 @@ }, "time": "2026-04-15T16:41:08+00:00" }, + { + "name": "moneyphp/money", + "version": "v4.9.0", + "source": { + "type": "git", + "url": "https://github.com/moneyphp/money.git", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^12.0", + "doctrine/instantiator": "^1.5.0 || ^2.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.8.1", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.1.0", + "moneyphp/iso-currencies": "^3.4", + "php-http/message": "^1.16.0", + "php-http/mock-client": "^1.6.0", + "phpbench/phpbench": "^1.2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1.9", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.9", + "psr/cache": "^1.0.1 || ^2.0 || ^3.0", + "ticketswap/phpstan-error-formatter": "^1.1" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Money\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" + } + ], + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], + "support": { + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.9.0" + }, + "time": "2026-05-04T20:23:15+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -4306,6 +4485,65 @@ ], "time": "2026-03-17T22:46:46+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v17.6.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", + "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.72.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v17.6.0" + }, + "time": "2025-08-27T19:32:42+00:00" + }, { "name": "symfony/clock", "version": "v8.0.8", @@ -5467,6 +5705,94 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "445c90e341fccda10311019cf82ff73bb7343945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", + "reference": "445c90e341fccda10311019cf82ff73bb7343945", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T11:52:53+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.36.0", diff --git a/config/billing.php b/config/billing.php index e05ae35..2ec4616 100644 --- a/config/billing.php +++ b/config/billing.php @@ -16,4 +16,26 @@ return [ 'enforce_booking' => env('BILLING_ENFORCE_BOOKING', false), + /* + |-------------------------------------------------------------------------- + | Hybride Rechnungskreise + |-------------------------------------------------------------------------- + | + | Alle neuen Abschlüsse laufen über Stripe und erhalten fortlaufende + | Nummern im STR-Kreis. Laufende Legacy-Zahlungen werden ab Relaunch im + | eigenen MAN-Kreis weiter per Rechnung abgerechnet (Fälligkeitsprüfung + | via `billing:generate-manual-invoices`). Die Alt-Rechnungen aus den + | Ursprungsportalen bleiben unverändert in `legacy_invoices`. + | + */ + + 'invoice_number_padding' => 5, + + // Zahlungsziel für Rechnungen des manuellen Kreises (Tage). + 'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14), + + // MwSt-Satz für den manuellen Kreis, wenn die Vereinbarung keine + // expliziten Beträge in legacy_conditions mitbringt. + 'vat_rate' => env('BILLING_VAT_RATE', 0.19), + ]; diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php new file mode 100644 index 0000000..0c68007 --- /dev/null +++ b/database/factories/PlanFactory.php @@ -0,0 +1,36 @@ + + */ +class PlanFactory extends Factory +{ + protected $model = Plan::class; + + public function definition(): array + { + $monthly = fake()->numberBetween(19, 199) * 100; + + return [ + 'slug' => fake()->unique()->slug(2), + 'name' => fake()->words(2, true), + 'monthly_price_cents' => $monthly, + 'yearly_price_cents' => $monthly * 10, + 'currency' => 'EUR', + 'press_release_quota' => fake()->numberBetween(3, 60), + 'daily_limit' => null, + 'is_active' => true, + 'sort_order' => fake()->numberBetween(0, 10), + ]; + } + + public function inactive(): static + { + return $this->state(fn (): array => ['is_active' => false]); + } +} diff --git a/database/factories/SinglePurchaseFactory.php b/database/factories/SinglePurchaseFactory.php new file mode 100644 index 0000000..fae6fd4 --- /dev/null +++ b/database/factories/SinglePurchaseFactory.php @@ -0,0 +1,45 @@ + + */ +class SinglePurchaseFactory extends Factory +{ + protected $model = SinglePurchase::class; + + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'type' => SinglePurchaseType::SinglePm, + 'status' => SinglePurchaseStatus::Pending, + 'price_cents' => 1900, + 'currency' => 'EUR', + ]; + } + + public function paid(): static + { + return $this->state(fn (): array => [ + 'status' => SinglePurchaseStatus::Paid, + 'paid_at' => now(), + ]); + } + + public function consumed(): static + { + return $this->state(fn (): array => [ + 'status' => SinglePurchaseStatus::Consumed, + 'paid_at' => now()->subDay(), + 'consumed_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_06_12_100632_create_customer_columns.php b/database/migrations/2026_06_12_100632_create_customer_columns.php new file mode 100644 index 0000000..974b381 --- /dev/null +++ b/database/migrations/2026_06_12_100632_create_customer_columns.php @@ -0,0 +1,40 @@ +string('stripe_id')->nullable()->index(); + $table->string('pm_type')->nullable(); + $table->string('pm_last_four', 4)->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex([ + 'stripe_id', + ]); + + $table->dropColumn([ + 'stripe_id', + 'pm_type', + 'pm_last_four', + 'trial_ends_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_12_100633_create_subscriptions_table.php b/database/migrations/2026_06_12_100633_create_subscriptions_table.php new file mode 100644 index 0000000..ccbcc6d --- /dev/null +++ b/database/migrations/2026_06_12_100633_create_subscriptions_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id'); + $table->string('type'); + $table->string('stripe_id')->unique(); + $table->string('stripe_status'); + $table->string('stripe_price')->nullable(); + $table->integer('quantity')->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'stripe_status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/database/migrations/2026_06_12_100634_create_subscription_items_table.php b/database/migrations/2026_06_12_100634_create_subscription_items_table.php new file mode 100644 index 0000000..420e23f --- /dev/null +++ b/database/migrations/2026_06_12_100634_create_subscription_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('subscription_id'); + $table->string('stripe_id')->unique(); + $table->string('stripe_product'); + $table->string('stripe_price'); + $table->integer('quantity')->nullable(); + $table->timestamps(); + + $table->index(['subscription_id', 'stripe_price']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscription_items'); + } +}; diff --git a/database/migrations/2026_06_12_100635_add_meter_id_to_subscription_items_table.php b/database/migrations/2026_06_12_100635_add_meter_id_to_subscription_items_table.php new file mode 100644 index 0000000..033bb82 --- /dev/null +++ b/database/migrations/2026_06_12_100635_add_meter_id_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_id')->nullable()->after('stripe_price'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_id'); + }); + } +}; diff --git a/database/migrations/2026_06_12_100636_add_meter_event_name_to_subscription_items_table.php b/database/migrations/2026_06_12_100636_add_meter_event_name_to_subscription_items_table.php new file mode 100644 index 0000000..b157b3a --- /dev/null +++ b/database/migrations/2026_06_12_100636_add_meter_event_name_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_event_name')->nullable()->after('quantity'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_event_name'); + }); + } +}; diff --git a/database/migrations/2026_06_12_100724_create_invoice_number_sequences_table.php b/database/migrations/2026_06_12_100724_create_invoice_number_sequences_table.php new file mode 100644 index 0000000..46d33a1 --- /dev/null +++ b/database/migrations/2026_06_12_100724_create_invoice_number_sequences_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('circle', 8)->unique(); + $table->unsignedBigInteger('next_number')->default(1); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoice_number_sequences'); + } +}; diff --git a/database/migrations/2026_06_12_100724_create_plans_table.php b/database/migrations/2026_06_12_100724_create_plans_table.php new file mode 100644 index 0000000..377a696 --- /dev/null +++ b/database/migrations/2026_06_12_100724_create_plans_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('slug', 40)->unique(); + $table->string('name', 80); + $table->unsignedInteger('monthly_price_cents'); + $table->unsignedInteger('yearly_price_cents'); + $table->string('currency', 3)->default('EUR'); + $table->unsignedInteger('press_release_quota'); + $table->unsignedInteger('daily_limit')->nullable(); + $table->string('stripe_product_id', 60)->nullable(); + $table->string('stripe_price_id_monthly', 60)->nullable(); + $table->string('stripe_price_id_yearly', 60)->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['is_active', 'sort_order'], 'plans_active_sort_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('plans'); + } +}; diff --git a/database/migrations/2026_06_12_100724_create_single_purchases_table.php b/database/migrations/2026_06_12_100724_create_single_purchases_table.php new file mode 100644 index 0000000..1c5d8cf --- /dev/null +++ b/database/migrations/2026_06_12_100724_create_single_purchases_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('type', array_map( + static fn (SinglePurchaseType $type): string => $type->value, + SinglePurchaseType::cases() + )); + $table->enum('status', array_map( + static fn (SinglePurchaseStatus $status): string => $status->value, + SinglePurchaseStatus::cases() + ))->default(SinglePurchaseStatus::Pending->value); + $table->unsignedInteger('price_cents'); + $table->string('currency', 3)->default('EUR'); + $table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete(); + $table->string('stripe_checkout_session_id', 80)->nullable(); + $table->string('stripe_payment_intent_id', 80)->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'type', 'status'], 'single_purchases_user_type_status_idx'); + $table->index(['stripe_checkout_session_id'], 'single_purchases_stripe_session_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('single_purchases'); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php new file mode 100644 index 0000000..417b92c --- /dev/null +++ b/database/seeders/PlanSeeder.php @@ -0,0 +1,36 @@ + 'starter', 'name' => 'Starter', 'monthly_price_cents' => 2900, 'press_release_quota' => 3, 'daily_limit' => null, 'sort_order' => 1], + ['slug' => 'business', 'name' => 'Business', 'monthly_price_cents' => 4900, 'press_release_quota' => 10, 'daily_limit' => 2, 'sort_order' => 2], + ['slug' => 'pro', 'name' => 'Pro', 'monthly_price_cents' => 9900, 'press_release_quota' => 25, 'daily_limit' => 3, 'sort_order' => 3], + ['slug' => 'agency', 'name' => 'Agency', 'monthly_price_cents' => 19900, 'press_release_quota' => 60, 'daily_limit' => 5, 'sort_order' => 4], + ]; + + foreach ($plans as $plan) { + Plan::query()->updateOrCreate( + ['slug' => $plan['slug']], + [ + ...$plan, + 'yearly_price_cents' => $plan['monthly_price_cents'] * 10, + 'currency' => 'EUR', + 'is_active' => true, + ], + ); + } + } +} diff --git a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md index 7e9f50c..ed912e0 100644 --- a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md +++ b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md @@ -40,8 +40,8 @@ Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken: | **9B** ✅ | Slot-Verbrauch von Einreichung auf Veröffentlichung umstellen (Rot = kein Slot) | M | mittel (Idempotenz) | | **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, Subscriptions, Einzel-PM-Käufe; Quota-Stub ablösen | L | hoch (Datenmodell) | -| **9E** | Stripe-Anbindung (Laravel Cashier — **Dependency-Freigabe nötig**) | L | mittel | +| **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 (Cashier installiert ✅): Checkout, Webhooks, STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent | 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 | @@ -126,25 +126,60 @@ Modal-Hinweis statt Checkboxen, `submitForReview` wirft, API gibt 402. ## 3. Block 2 — Tarif-Modul (nach Review-Stopp) -### 9D · Tarif-Datenmodell +### Entscheidung 12.06.2026 — Hybride Rechnungsarchitektur -- Tabellen (Arbeitsstand, final beim Review-Stopp vor 9D): - `plans` (Starter/Business/Pro/Agency: Preis mtl./jährl., PMs/Monat, - Tageslimit), `subscriptions` (User, Plan, Zyklus, Status, Periodenstart/-ende), - `single_purchases` (Einzel-PM, Extra-PM, Boost, PDF — Typ, Preis, Status, - `applied_to_press_release_id`). -- `User::hasActiveBooking()` prüft echte Subscription oder offenen Einzel-PM-Kauf. -- Slot-Logik wechselt von `users.press_release_quota` auf Plan-Kontingent + - Periodenzähler; Stub-Spalten werden nach Migration entfernt. -- Kontingent-Anzeige (Modal, Editor) liest aus der neuen Quelle — - Schnittstelle `pressReleaseQuotaRemaining()` bleibt stabil. +Alle **neuen** Abschlüsse und Zahlungen laufen über **Stripe**. Die Umsetzung +ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand): + +| Kreis | Präfix | Inhalt | +|---|---|---| +| **Stripe-Shop** | `STR-` | Alles Neue (Abos, Einzel-PM, Credits) — komplette Abwicklung über Stripe, fortlaufende Nummer im STR-Kreis | +| **Manuell/Legacy** | `MAN-` | Laufende, noch aktive Alt-Zahlungen ab Relaunch: Fälligkeit wird im Hintergrund geprüft, Rechnung wie im Legacy-System ausgestellt | +| Alt-Archiv | — | Die importierten Alt-Rechnungen (`legacy_invoices`, 864 Stück) bleiben unverändert bestehen | + +### 9D · Tarif-Datenmodell — ✅ umgesetzt (12.06.2026) + +- **Cashier installiert** (`laravel/cashier` ^16.5, freigegeben); `User` ist + `Billable`, Cashier-Tabellen (`subscriptions`, `subscription_items`, + Customer-Spalten) migriert. Die lokale `invoices()`-Relation überschreibt + bewusst die Cashier-Methode. +- **`plans`**: Starter/Business/Pro/Agency mit Monats-/Jahrespreis + (Jahres = 10 × Monat), PM-Kontingent, Tageslimit, Stripe-IDs (nullable, + werden in 9E gepflegt). `PlanSeeder` idempotent. +- **`single_purchases`**: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit + Status Pending/Paid/Consumed/Refunded und Stripe-Checkout-Referenzen. +- **`invoice_number_sequences` + `InvoiceNumberGenerator`**: atomare, + fortlaufende Nummern pro Kreis (`STR-00001`, `MAN-00001`; Padding + konfigurierbar in `config/billing.php`). +- **MAN-Kreis**: `ManualInvoiceService` + Command + `billing:generate-manual-invoices` (Scheduler täglich 04:30) — findet + aktive/grandfathered `user_payment_options` **ohne** + `stripe_subscription_id` mit erreichtem `current_period_end`, friert die + Rechnungsadresse als Snapshot ein, stellt eine MAN-Rechnung aus + (Zahlungsziel `billing.manual_due_days`) und schaltet die Periode weiter. + Konditions-Overrides pro Vereinbarung über `legacy_conditions` + (`amount_cents`/`tax_cents`/`total_cents`/`interval`); ohne Override + Netto-Preis der `payment_option` + `billing.vat_rate`. Nicht abrechenbare + Fälle (fehlende Rechnungsadresse) werden geloggt und erneut versucht. +- **`User::hasActiveBooking()`** prüft jetzt echt (hinter + `billing.enforce_booking`): Cashier-Abo ∨ bezahlter Einzel-/Extra-PM-Kauf + ∨ aktive/grandfathered Legacy-Vereinbarung (MAN-Kreis). +- **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. Voraussetzung: + Die aktiven Legacy-Zahlungen müssen noch in `user_payment_options` + migriert werden (Tabelle ist aktuell leer — eigener Migrations-Schritt). ### 9E · Stripe (Laravel Cashier) -- **Vor Start freizugeben:** `laravel/cashier` als neue Dependency. -- Checkout für Abo (monatlich/jährlich) und Einmalzahlung (Einzel-PM, Credits). -- Webhooks (Subscription-Status, Zahlungsausfall) + lokale Spiegelung. -- Rechnungen an bestehende `invoices`-Struktur anbinden (Klärung beim Review). +- 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). ### 9F · Tarif-Seite + Checkout-UI diff --git a/docs/user-admin/checkliste-user-backend.md b/docs/user-admin/checkliste-user-backend.md index 6c91f65..871abc5 100644 --- a/docs/user-admin/checkliste-user-backend.md +++ b/docs/user-admin/checkliste-user-backend.md @@ -122,7 +122,11 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic - [x] Slot-Verbrauch von Einreichung auf **Veroeffentlichung** umstellen (Rot = kein Slot-Verbrauch, idempotent ueber Status-Logs; Submit-Guard bei 0 Rest-Slots) — Phase 9B. - [x] Submit-Gate vorbereitet: `User::hasActiveBooking()`-Stub (`billing.enforce_booking`, Default aus), Buchungs-Hinweis im Modal, Server-Guard + API 402 — Phase 9C. Echte Buchungs-Pruefung kommt mit dem Tarif-Modul. - [x] Funnel-Luecke geschlossen: Create-Form legte PMs direkt mit Status `review` an (ohne Blacklist/Quota/KI/Status-Log) — laeuft jetzt ueber `submitForReview` (9C). -- [ ] Tarif-Datenmodell + Checkout/Zahlung (Starter/Business/Pro/Agency, Einzel-PM 19 €, Jahrespreis „2 Monate gratis"); Quota-Stub abloesen. +- [x] Tarif-Datenmodell (Phase 9D, 12.06.): `plans` (4 Tiers + Seeder), `single_purchases` (Einzel-PM/Extra-PM/Boost/PDF), Laravel Cashier installiert (`User` ist Billable), `hasActiveBooking()` prueft hybrid (Stripe-Abo / Einmalkauf / Legacy-Vereinbarung). +- [x] Hybride Rechnungskreise (Entscheidung 12.06.): fortlaufende Nummern via `InvoiceNumberGenerator` — **STR-** fuer den neuen Stripe-Shop, **MAN-** fuer laufende Legacy-Zahlungen; Alt-Archiv (`legacy_invoices`) bleibt unveraendert. +- [x] MAN-Faelligkeitslauf: `billing:generate-manual-invoices` (taeglich 04:30) prueft `user_payment_options` ohne Stripe-Subscription auf erreichtes Periodenende, stellt Rechnung mit Adress-Snapshot aus und schaltet die Periode weiter (Konditions-Overrides via `legacy_conditions`). +- [ ] Aktive Legacy-Zahlungen in `user_payment_options` migrieren (Tabelle aktuell leer) — Voraussetzung fuer den ersten echten MAN-Lauf. +- [ ] Stripe-Checkout + Webhooks (Phase 9E): Produkte/Preise anlegen, STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent umstellen, Quota-Stub abloesen. Benoetigt `STRIPE_KEY`/`STRIPE_SECRET` in `.env`. - [ ] 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). diff --git a/routes/console.php b/routes/console.php index 0f2ada6..bd3978d 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ withoutOverlapping() ->runInBackground(); +// ======================================== +// Manueller Rechnungskreis (MAN-) — Legacy-Zahlungen +// ======================================== + +// Fällige Rechnungen für laufende Legacy-Zahlungsvereinbarungen ausstellen +// (Periodenende erreicht), wie im Altsystem. Neue Abschlüsse laufen über +// Stripe (STR-Kreis) und werden hier nicht angefasst. +Schedule::command(GenerateManualInvoices::class) + ->dailyAt('04:30') + ->withoutOverlapping() + ->runInBackground(); + // ======================================== // PM-Kontingent (Stub bis zum echten Tarif-Modul) // ======================================== diff --git a/tests/Feature/Billing/HasActiveBookingTest.php b/tests/Feature/Billing/HasActiveBookingTest.php new file mode 100644 index 0000000..1c7e059 --- /dev/null +++ b/tests/Feature/Billing/HasActiveBookingTest.php @@ -0,0 +1,75 @@ +set('billing.enforce_booking', true); +}); + +test('without the enforce flag everyone counts as booked', function () { + config()->set('billing.enforce_booking', false); + + expect(User::factory()->create()->hasActiveBooking())->toBeTrue(); +}); + +test('a user without any booking is rejected when the gate is enforced', function () { + expect(User::factory()->create()->hasActiveBooking())->toBeFalse(); +}); + +test('an active cashier subscription counts as booking', function () { + $user = User::factory()->create(); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_test_123', + 'stripe_status' => 'active', + 'stripe_price' => 'price_test', + 'quantity' => 1, + ]); + + expect($user->hasActiveBooking())->toBeTrue(); +}); + +test('a paid unconsumed single purchase counts as booking', function () { + $user = User::factory()->create(); + SinglePurchase::factory()->paid()->create(['user_id' => $user->id]); + + expect($user->hasActiveBooking())->toBeTrue(); +}); + +test('a consumed single purchase does not count as booking', function () { + $user = User::factory()->create(); + SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]); + + expect($user->hasActiveBooking())->toBeFalse(); +}); + +test('an active legacy payment agreement counts as booking (manual circle)', function () { + $user = User::factory()->create(); + + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'payment_option_id' => PaymentOption::factory()->create()->id, + 'status' => UserPaymentOptionStatus::Active->value, + 'stripe_subscription_id' => null, + ]); + + expect($user->hasActiveBooking())->toBeTrue(); +}); + +test('a cancelled legacy agreement does not count as booking', function () { + $user = User::factory()->create(); + + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'payment_option_id' => PaymentOption::factory()->create()->id, + 'status' => UserPaymentOptionStatus::Cancelled->value, + 'stripe_subscription_id' => null, + ]); + + expect($user->hasActiveBooking())->toBeFalse(); +}); diff --git a/tests/Feature/Billing/InvoiceNumberGeneratorTest.php b/tests/Feature/Billing/InvoiceNumberGeneratorTest.php new file mode 100644 index 0000000..627c9d9 --- /dev/null +++ b/tests/Feature/Billing/InvoiceNumberGeneratorTest.php @@ -0,0 +1,18 @@ +nextStripeNumber())->toBe('STR-00001'); + expect($generator->nextStripeNumber())->toBe('STR-00002'); + expect($generator->nextManualNumber())->toBe('MAN-00001'); + expect($generator->nextStripeNumber())->toBe('STR-00003'); + expect($generator->nextManualNumber())->toBe('MAN-00002'); +}); + +test('an unknown circle is rejected', function () { + expect(fn () => app(InvoiceNumberGenerator::class)->next('XXX')) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/Billing/ManualInvoiceGenerationTest.php b/tests/Feature/Billing/ManualInvoiceGenerationTest.php new file mode 100644 index 0000000..7528504 --- /dev/null +++ b/tests/Feature/Billing/ManualInvoiceGenerationTest.php @@ -0,0 +1,137 @@ +create(); + BillingAddress::factory()->create(['user_id' => $user->id]); + + $paymentOption = PaymentOption::factory()->create([ + 'price_cents' => 4900, + 'interval' => 'monthly', + ...$optionOverrides, + ]); + + return UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'payment_option_id' => $paymentOption->id, + 'status' => UserPaymentOptionStatus::Active->value, + 'stripe_subscription_id' => null, + 'current_period_start' => today()->subMonth(), + 'current_period_end' => today()->subDay(), + 'legacy_conditions' => null, + ...$overrides, + ]); +} + +test('a due manual agreement gets a MAN invoice and the period advances', function () { + Carbon::setTestNow('2026-06-12 08:00:00'); + + $agreement = manualAgreement([ + 'current_period_end' => '2026-06-11', + ]); + + $invoice = app(ManualInvoiceService::class)->invoiceFor($agreement); + + expect($invoice)->not->toBeNull(); + expect($invoice->number)->toBe('MAN-00001'); + expect($invoice->status)->toBe(InvoiceStatus::Open); + expect($invoice->amount_cents)->toBe(4900); + expect($invoice->tax_cents)->toBe(931); + expect($invoice->total_cents)->toBe(5831); + expect($invoice->due_date->toDateString())->toBe('2026-06-26'); + + $fresh = $agreement->fresh(); + expect($fresh->current_period_start->toDateString())->toBe('2026-06-11'); + expect($fresh->current_period_end->toDateString())->toBe('2026-07-11'); + + Carbon::setTestNow(); +}); + +test('legacy_conditions override amounts and interval', function () { + Carbon::setTestNow('2026-06-12 08:00:00'); + + $agreement = manualAgreement([ + 'current_period_end' => '2026-06-10', + 'legacy_conditions' => [ + 'amount_cents' => 10000, + 'tax_cents' => 1900, + 'total_cents' => 11900, + 'interval' => 'yearly', + ], + ]); + + $invoice = app(ManualInvoiceService::class)->invoiceFor($agreement); + + expect($invoice->amount_cents)->toBe(10000); + expect($invoice->tax_cents)->toBe(1900); + expect($invoice->total_cents)->toBe(11900); + expect($agreement->fresh()->current_period_end->toDateString())->toBe('2027-06-10'); + + Carbon::setTestNow(); +}); + +test('the invoice freezes the billing address as a snapshot', function () { + $agreement = manualAgreement(); + $agreement->user->billingAddress->update(['name' => 'Alpha GmbH', 'city' => 'Berlin']); + + $invoice = app(ManualInvoiceService::class)->invoiceFor($agreement); + + $agreement->user->billingAddress->update(['name' => 'Beta GmbH', 'city' => 'Hamburg']); + + expect($invoice->fresh()->invoiceBillingAddress->name)->toBe('Alpha GmbH'); + expect($invoice->fresh()->invoiceBillingAddress->city)->toBe('Berlin'); +}); + +test('agreements without a billing address are skipped and keep their period', function () { + $agreement = manualAgreement(); + $agreement->user->billingAddress->delete(); + + $periodEnd = $agreement->current_period_end->toDateString(); + + $invoice = app(ManualInvoiceService::class)->invoiceFor($agreement->fresh()); + + expect($invoice)->toBeNull(); + expect(Invoice::count())->toBe(0); + expect($agreement->fresh()->current_period_end->toDateString())->toBe($periodEnd); +}); + +test('stripe-managed agreements are never picked up by the manual circle', function () { + manualAgreement(['stripe_subscription_id' => 'sub_123']); + + expect(app(ManualInvoiceService::class)->duePaymentOptions())->toHaveCount(0); +}); + +test('agreements with a future period end are not due', function () { + manualAgreement(['current_period_end' => today()->addWeek()]); + + expect(app(ManualInvoiceService::class)->duePaymentOptions())->toHaveCount(0); +}); + +test('the command invoices all due agreements', function () { + manualAgreement(); + manualAgreement(); + + $this->artisan(GenerateManualInvoices::class)->assertSuccessful(); + + expect(Invoice::count())->toBe(2); + expect(Invoice::pluck('number')->sort()->values()->all())->toBe(['MAN-00001', 'MAN-00002']); +}); + +test('the command dry-run does not create invoices', function () { + manualAgreement(); + + $this->artisan(GenerateManualInvoices::class, ['--dry-run' => true])->assertSuccessful(); + + expect(Invoice::count())->toBe(0); +});