diff --git a/app/Enums/CreditTransactionType.php b/app/Enums/CreditTransactionType.php new file mode 100644 index 0000000..44e92e5 --- /dev/null +++ b/app/Enums/CreditTransactionType.php @@ -0,0 +1,33 @@ + 'Aufladung', + self::Spend => 'Verbrauch', + self::Refund => 'Erstattung', + self::Grant => 'Gutschrift', + }; + } + + /** + * Schreibt die Buchung dem Saldo gut (statt ihn zu belasten)? + */ + public function isCredit(): bool + { + return in_array($this, [self::Topup, self::Refund, self::Grant], true); + } +} diff --git a/app/Enums/Tier.php b/app/Enums/Tier.php new file mode 100644 index 0000000..7de6fa3 --- /dev/null +++ b/app/Enums/Tier.php @@ -0,0 +1,36 @@ + 'Einzel', + self::Starter => 'Starter', + self::Business => 'Business', + self::Pro => 'Pro', + self::Agency => 'Agency', + }; + } + + /** + * Leitet das Tier aus einem `plans.slug` ab; unbekannt/kein Abo → Einzel. + */ + public static function fromPlanSlug(?string $slug): self + { + return self::tryFrom((string) $slug) ?? self::Einzel; + } +} diff --git a/app/Exceptions/InsufficientCreditsException.php b/app/Exceptions/InsufficientCreditsException.php new file mode 100644 index 0000000..9fe5647 --- /dev/null +++ b/app/Exceptions/InsufficientCreditsException.php @@ -0,0 +1,25 @@ +required - $this->available); + } +} diff --git a/app/Models/CreditTransaction.php b/app/Models/CreditTransaction.php new file mode 100644 index 0000000..53f5e42 --- /dev/null +++ b/app/Models/CreditTransaction.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + protected $fillable = [ + 'credit_wallet_id', + 'user_id', + 'amount_credits', + 'balance_after', + 'type', + 'description', + 'reference_type', + 'reference_id', + ]; + + protected function casts(): array + { + return [ + 'amount_credits' => 'integer', + 'balance_after' => 'integer', + 'type' => CreditTransactionType::class, + ]; + } + + public function wallet(): BelongsTo + { + return $this->belongsTo(CreditWallet::class, 'credit_wallet_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function reference(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/CreditWallet.php b/app/Models/CreditWallet.php new file mode 100644 index 0000000..114a527 --- /dev/null +++ b/app/Models/CreditWallet.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'balance_credits', + ]; + + protected function casts(): array + { + return [ + 'balance_credits' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(CreditTransaction::class)->orderByDesc('created_at'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3cb77d2..4f6ad23 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\Portal; use App\Enums\RegistrationType; +use App\Enums\Tier; use App\Enums\UserPaymentOptionStatus; use Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; @@ -140,6 +141,15 @@ class User extends Authenticatable implements MustVerifyEmail ->first(); } + /** + * Abrechnungs-Tier für die Credit-Preisableitung. Ohne aktives Abo gilt + * `Tier::Einzel` (Pay-per-Release). + */ + public function currentTier(): Tier + { + return Tier::fromPlanSlug($this->currentPlan()?->slug); + } + /** * Hat dieser User ein unbegrenztes PM-Kontingent? * @@ -291,6 +301,25 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(SinglePurchase::class); } + public function creditWallet(): HasOne + { + return $this->hasOne(CreditWallet::class); + } + + public function creditTransactions(): HasMany + { + return $this->hasMany(CreditTransaction::class); + } + + /** + * Aktuelles Credit-Guthaben (1 Credit = 1 €). 0, solange keine Wallet + * angelegt wurde. + */ + public function creditBalance(): int + { + return (int) ($this->creditWallet?->balance_credits ?? 0); + } + /** * Lokale Rechnungen (STR- und MAN-Kreis). Überschreibt bewusst die * gleichnamige Cashier-Methode — Stripe-Rechnungen werden beim diff --git a/app/Services/Billing/CreditPricingService.php b/app/Services/Billing/CreditPricingService.php new file mode 100644 index 0000000..1022c57 --- /dev/null +++ b/app/Services/Billing/CreditPricingService.php @@ -0,0 +1,87 @@ +value] ?? $table[Tier::Einzel->value]); + } + + public function extraPmCreditsFor(User $user): int + { + return $this->extraPmCredits($user->currentTier()); + } + + /** + * Boost-Preis (Credits) für eine Laufzeit in Tagen. + */ + public function boostCredits(int $days): int + { + $table = config('credits.boost', []); + + if (! isset($table[$days])) { + throw new \InvalidArgumentException("Unbekannte Boost-Laufzeit: {$days} Tage."); + } + + return (int) $table[$days]; + } + + /** + * Verfügbare Boost-Laufzeiten als [Tage => Credits], aufsteigend. + * + * @return array + */ + public function boostOptions(): array + { + $table = config('credits.boost', []); + ksort($table); + + return $table; + } + + public function proofPdfCredits(): int + { + return (int) config('credits.proof_pdf'); + } + + public function depublishCredits(): int + { + return (int) config('credits.depublish'); + } + + /** + * Freie Prüfungen pro Monat für das Tier (Prüfkontingent §4.3). + */ + public function reviewFreeQuota(Tier $tier): int + { + $table = config('credits.review.free_per_month', []); + + return (int) ($table[$tier->value] ?? $table[Tier::Einzel->value]); + } + + public function reviewDailyLimit(): int + { + return (int) config('credits.review.daily_limit'); + } + + public function reviewOverflowCost(): int + { + return (int) config('credits.review.overflow_cost'); + } +} diff --git a/app/Services/Billing/CreditWalletService.php b/app/Services/Billing/CreditWalletService.php new file mode 100644 index 0000000..fdd8bcd --- /dev/null +++ b/app/Services/Billing/CreditWalletService.php @@ -0,0 +1,110 @@ +firstOrCreate(['user_id' => $user->id]); + } + + public function balance(User $user): int + { + return (int) ($user->creditWallet()->value('balance_credits') ?? 0); + } + + /** + * Schreibt dem User Credits gut (Aufladung, Erstattung oder Gutschrift). + */ + public function credit( + User $user, + int $credits, + CreditTransactionType $type = CreditTransactionType::Topup, + ?string $description = null, + ?Model $reference = null, + ): CreditTransaction { + if ($credits <= 0) { + throw new \InvalidArgumentException('Gutschrift muss positiv sein.'); + } + + if (! $type->isCredit()) { + throw new \InvalidArgumentException("Buchungsart {$type->value} ist keine Gutschrift."); + } + + return $this->post($user, $credits, $type, $description, $reference); + } + + /** + * Belastet die Wallet. Wirft InsufficientCreditsException, wenn das + * Guthaben nicht reicht (Mini-Checkout-Signal). + */ + public function debit( + User $user, + int $credits, + ?string $description = null, + ?Model $reference = null, + ): CreditTransaction { + if ($credits <= 0) { + throw new \InvalidArgumentException('Belastung muss positiv sein.'); + } + + return $this->post($user, -$credits, CreditTransactionType::Spend, $description, $reference); + } + + /** + * Reicht das Guthaben für eine Belastung? + */ + public function canAfford(User $user, int $credits): bool + { + return $this->balance($user) >= $credits; + } + + private function post( + User $user, + int $signedAmount, + CreditTransactionType $type, + ?string $description, + ?Model $reference, + ): CreditTransaction { + return DB::transaction(function () use ($user, $signedAmount, $type, $description, $reference): CreditTransaction { + $wallet = CreditWallet::query()->firstOrCreate(['user_id' => $user->id]); + $wallet = CreditWallet::query()->lockForUpdate()->find($wallet->id); + + $newBalance = $wallet->balance_credits + $signedAmount; + + if ($newBalance < 0) { + throw new InsufficientCreditsException(abs($signedAmount), $wallet->balance_credits); + } + + $wallet->update(['balance_credits' => $newBalance]); + + return $wallet->transactions()->create([ + 'user_id' => $user->id, + 'amount_credits' => $signedAmount, + 'balance_after' => $newBalance, + 'type' => $type, + 'description' => $description, + 'reference_type' => $reference?->getMorphClass(), + 'reference_id' => $reference?->getKey(), + ]); + }); + } +} diff --git a/config/credits.php b/config/credits.php new file mode 100644 index 0000000..4eaa5f9 --- /dev/null +++ b/config/credits.php @@ -0,0 +1,79 @@ + [ + Tier::Einzel->value => 19, + Tier::Starter->value => 15, + Tier::Business->value => 12, + Tier::Pro->value => 10, + Tier::Agency->value => 8, + ], + + /* + | Boost (Platzierung Startseite + Branchenseite) — nach Dauer in Tagen. + | Pro-Tag-Preis sinkt mit der Dauer; Einstieg bleibt unter dem PM-Preis. + */ + 'boost' => [ + 7 => 12, + 14 => 20, + 30 => 35, + ], + + /* + | Veröffentlichungsnachweis-PDF — pauschal pro PM (Impulskauf). + */ + 'proof_pdf' => 3, + + /* + | Depublizieren (Magic-Link-Pfad G) — bewusst am teuersten, mit Bedenkzeit. + */ + 'depublish' => 25, + + /* + | Kostenpflichtige Magic-Link-Pfade C/D (Phase 2, Anker „zu bestätigen"). + */ + 'paths' => [ + 'correction' => 8, // C – inhaltliche Korrektur + 'update' => 4, // D – Update/Ergänzung + ], + + /* + |-------------------------------------------------------------------------- + | Prüfzähler / Prüfkontingent (Decision-Update §4.2/§4.3) + |-------------------------------------------------------------------------- + | + | Eigener Zähler, getrennt von der Wallet. Tier-gestaffelte Freiprüfungen + | pro Account/Monat (aggregiert, nicht pro PM). Burst-Schutz per Tageslimit. + | Overflow: leerer Zähler → je weitere Prüfung 1 Credit aus der Wallet. + */ + 'review' => [ + 'free_per_month' => [ + Tier::Einzel->value => 4, + Tier::Starter->value => 12, + Tier::Business->value => 30, + Tier::Pro->value => 60, + Tier::Agency->value => 120, + ], + 'daily_limit' => 10, + 'overflow_cost' => 1, + ], + +]; diff --git a/database/factories/CreditTransactionFactory.php b/database/factories/CreditTransactionFactory.php new file mode 100644 index 0000000..8077090 --- /dev/null +++ b/database/factories/CreditTransactionFactory.php @@ -0,0 +1,34 @@ + + */ +class CreditTransactionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $amount = $this->faker->numberBetween(1, 50); + + return [ + 'credit_wallet_id' => CreditWallet::factory(), + 'user_id' => User::factory(), + 'amount_credits' => $amount, + 'balance_after' => $amount, + 'type' => CreditTransactionType::Topup, + 'description' => null, + 'reference_type' => null, + 'reference_id' => null, + ]; + } +} diff --git a/database/factories/CreditWalletFactory.php b/database/factories/CreditWalletFactory.php new file mode 100644 index 0000000..be8f50d --- /dev/null +++ b/database/factories/CreditWalletFactory.php @@ -0,0 +1,29 @@ + + */ +class CreditWalletFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'balance_credits' => 0, + ]; + } + + public function withBalance(int $credits): static + { + return $this->state(fn (): array => ['balance_credits' => $credits]); + } +} diff --git a/database/migrations/2026_06_17_141218_create_credit_wallets_table.php b/database/migrations/2026_06_17_141218_create_credit_wallets_table.php new file mode 100644 index 0000000..d23c10a --- /dev/null +++ b/database/migrations/2026_06_17_141218_create_credit_wallets_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->integer('balance_credits')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_wallets'); + } +}; diff --git a/database/migrations/2026_06_17_141219_create_credit_transactions_table.php b/database/migrations/2026_06_17_141219_create_credit_transactions_table.php new file mode 100644 index 0000000..23f4ccf --- /dev/null +++ b/database/migrations/2026_06_17_141219_create_credit_transactions_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('credit_wallet_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->integer('amount_credits'); + $table->integer('balance_after'); + $table->string('type'); + $table->string('description')->nullable(); + $table->nullableMorphs('reference'); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_transactions'); + } +}; diff --git a/tests/Feature/CreditPricingServiceTest.php b/tests/Feature/CreditPricingServiceTest.php new file mode 100644 index 0000000..03473f5 --- /dev/null +++ b/tests/Feature/CreditPricingServiceTest.php @@ -0,0 +1,62 @@ +pricing = app(CreditPricingService::class); +}); + +test('extra-pm credits follow the tier staffel', function () { + expect($this->pricing->extraPmCredits(Tier::Einzel))->toBe(19); + expect($this->pricing->extraPmCredits(Tier::Starter))->toBe(15); + expect($this->pricing->extraPmCredits(Tier::Business))->toBe(12); + expect($this->pricing->extraPmCredits(Tier::Pro))->toBe(10); + expect($this->pricing->extraPmCredits(Tier::Agency))->toBe(8); +}); + +test('a user without a plan is on the Einzel tier and pays the full extra-pm rate', function () { + $user = User::factory()->create(); + + expect($user->currentTier())->toBe(Tier::Einzel); + expect($this->pricing->extraPmCreditsFor($user))->toBe(19); +}); + +test('a subscriber inherits the tier of the plan slug', function () { + $user = User::factory()->create(); + $plan = Plan::factory()->create([ + 'slug' => 'business', + 'stripe_price_id_monthly' => 'price_biz_'.fake()->unique()->randomNumber(6), + ]); + subscribeUserToPlan($user, $plan); + + expect($user->currentTier())->toBe(Tier::Business); + expect($this->pricing->extraPmCreditsFor($user))->toBe(12); +}); + +test('boost credits follow the duration staffel and reject unknown durations', function () { + expect($this->pricing->boostCredits(7))->toBe(12); + expect($this->pricing->boostCredits(14))->toBe(20); + expect($this->pricing->boostCredits(30))->toBe(35); + expect($this->pricing->boostOptions())->toBe([7 => 12, 14 => 20, 30 => 35]); + + expect(fn () => $this->pricing->boostCredits(99)) + ->toThrow(InvalidArgumentException::class); +}); + +test('flat prices match the decision update', function () { + expect($this->pricing->proofPdfCredits())->toBe(3); + expect($this->pricing->depublishCredits())->toBe(25); +}); + +test('review free quota follows the tier staffel', function () { + expect($this->pricing->reviewFreeQuota(Tier::Einzel))->toBe(4); + expect($this->pricing->reviewFreeQuota(Tier::Starter))->toBe(12); + expect($this->pricing->reviewFreeQuota(Tier::Business))->toBe(30); + expect($this->pricing->reviewFreeQuota(Tier::Pro))->toBe(60); + expect($this->pricing->reviewFreeQuota(Tier::Agency))->toBe(120); + expect($this->pricing->reviewDailyLimit())->toBe(10); + expect($this->pricing->reviewOverflowCost())->toBe(1); +}); diff --git a/tests/Feature/CreditWalletServiceTest.php b/tests/Feature/CreditWalletServiceTest.php new file mode 100644 index 0000000..010665b --- /dev/null +++ b/tests/Feature/CreditWalletServiceTest.php @@ -0,0 +1,67 @@ +service = app(CreditWalletService::class); +}); + +test('a credit raises the balance and writes a ledger row', function () { + $user = User::factory()->create(); + + $tx = $this->service->credit($user, 20, CreditTransactionType::Topup, 'Paket 20'); + + expect($this->service->balance($user))->toBe(20); + expect($tx->amount_credits)->toBe(20); + expect($tx->balance_after)->toBe(20); + expect($tx->type)->toBe(CreditTransactionType::Topup); +}); + +test('a debit lowers the balance and records the balance after', function () { + $user = User::factory()->create(); + $this->service->credit($user, 20); + + $tx = $this->service->debit($user, 12, 'Extra-PM'); + + expect($this->service->balance($user))->toBe(8); + expect($tx->amount_credits)->toBe(-12); + expect($tx->balance_after)->toBe(8); + expect($tx->type)->toBe(CreditTransactionType::Spend); +}); + +test('debiting more than the balance throws and keeps the balance untouched', function () { + $user = User::factory()->create(); + $this->service->credit($user, 8); + + $threw = null; + try { + $this->service->debit($user, 15); + } catch (InsufficientCreditsException $e) { + $threw = $e; + } + + expect($threw)->toBeInstanceOf(InsufficientCreditsException::class); + expect($threw->shortfall())->toBe(7); + expect($this->service->balance($user))->toBe(8); + expect($user->creditTransactions()->count())->toBe(1); // nur die Aufladung +}); + +test('balance is zero and canAfford is false without a wallet', function () { + $user = User::factory()->create(); + + expect($this->service->balance($user))->toBe(0); + expect($this->service->canAfford($user, 1))->toBeFalse(); +}); + +test('a refund credits back a prior spend', function () { + $user = User::factory()->create(); + $this->service->credit($user, 30); + $this->service->debit($user, 25, 'Depublizieren'); + + $this->service->credit($user, 25, CreditTransactionType::Refund, 'Widerruf Depublizieren'); + + expect($this->service->balance($user))->toBe(30); +});