diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 3bc3307..f087799 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -6,6 +6,7 @@ use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseType; use App\Models\Plan; use App\Models\SinglePurchase; +use App\Services\Billing\CreditTopupService; use App\Services\Billing\StripeCheckoutService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -68,6 +69,26 @@ class CheckoutController extends Controller return $this->checkout->forSinglePurchase($request->user(), $purchase); } + /** + * Wallet-Aufladung über ein Credit-Paket. Wie jeder Checkout setzt das + * eine vollständige Rechnungsadresse voraus; die Erfüllung (Gutschrift) + * übernimmt der Webhook über die `credit_topup_id`-Metadaten. + */ + public function creditTopup(Request $request, string $pack, CreditTopupService $topups): Checkout|RedirectResponse + { + if (! $request->user()->hasCompleteBillingAddress()) { + return $this->backToProfile(); + } + + if (! $topups->findPack($pack)) { + return $this->backToBookings(__('Dieses Credit-Paket ist nicht verfügbar.')); + } + + $topup = $topups->startTopup($request->user(), $pack); + + return $this->checkout->forCreditTopup($request->user(), $topup); + } + /** * Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode, * Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll. diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 5200c7f..06fa549 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -4,10 +4,12 @@ namespace App\Listeners; use App\Enums\InvoiceStatus; use App\Enums\SinglePurchaseStatus; +use App\Models\CreditTopup; use App\Models\Invoice; use App\Models\InvoiceBillingAddress; use App\Models\SinglePurchase; use App\Models\User; +use App\Services\Billing\CreditTopupService; use App\Services\Billing\InvoiceNumberGenerator; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; @@ -36,7 +38,7 @@ class ProcessStripeWebhook { match ($event->payload['type'] ?? null) { 'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []), - 'checkout.session.completed' => $this->fulfillSinglePurchase($event->payload['data']['object'] ?? []), + 'checkout.session.completed' => $this->fulfillCheckout($event->payload['data']['object'] ?? []), default => null, }; } @@ -117,6 +119,44 @@ class ProcessStripeWebhook ]); } + /** + * Eine abgeschlossene Checkout-Session kann einen Einzel-PM-Kauf oder eine + * Wallet-Aufladung erfüllen — die Metadaten entscheiden welchen. + * + * @param array $session + */ + private function fulfillCheckout(array $session): void + { + $this->fulfillSinglePurchase($session); + $this->fulfillCreditTopup($session); + } + + /** + * @param array $session + */ + private function fulfillCreditTopup(array $session): void + { + $topupId = $session['metadata']['credit_topup_id'] ?? null; + + if (! $topupId) { + return; + } + + $topup = CreditTopup::query()->find((int) $topupId); + + if (! $topup) { + return; + } + + app(CreditTopupService::class)->fulfill( + $topup, + $session['id'] ?? null, + $session['payment_intent'] ?? null, + ); + + Log::info('Wallet-Aufladung gutgeschrieben.', ['credit_topup_id' => $topup->id]); + } + /** * @param array $session */ diff --git a/app/Models/CreditTopup.php b/app/Models/CreditTopup.php new file mode 100644 index 0000000..8556795 --- /dev/null +++ b/app/Models/CreditTopup.php @@ -0,0 +1,44 @@ + 'integer', + 'price_cents' => 'integer', + 'status' => SinglePurchaseStatus::class, + 'paid_at' => 'datetime', + 'credited_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 962e966..f7f7f74 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -316,6 +316,11 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(ReviewCheck::class); } + public function creditTopups(): HasMany + { + return $this->hasMany(CreditTopup::class); + } + /** * Aktuelles Credit-Guthaben (1 Credit = 1 €). 0, solange keine Wallet * angelegt wurde. diff --git a/app/Services/Billing/CreditTopupService.php b/app/Services/Billing/CreditTopupService.php new file mode 100644 index 0000000..4628333 --- /dev/null +++ b/app/Services/Billing/CreditTopupService.php @@ -0,0 +1,92 @@ + + */ + public function packs(): array + { + return array_values(config('credits.packs', [])); + } + + /** + * @return array{key: string, price_cents: int, credits: int}|null + */ + public function findPack(string $key): ?array + { + foreach ($this->packs() as $pack) { + if ($pack['key'] === $key) { + return $pack; + } + } + + return null; + } + + /** + * Legt einen Pending-Topup für ein Paket an (vor dem Stripe-Checkout). + */ + public function startTopup(User $user, string $packKey): CreditTopup + { + $pack = $this->findPack($packKey); + + if (! $pack) { + throw new \InvalidArgumentException("Unbekanntes Credit-Paket: {$packKey}."); + } + + return $user->creditTopups()->create([ + 'pack_key' => $pack['key'], + 'credits' => $pack['credits'], + 'price_cents' => $pack['price_cents'], + 'currency' => 'EUR', + 'status' => SinglePurchaseStatus::Pending, + ]); + } + + /** + * Schreibt einen bezahlten Topup der Wallet gut. Idempotent: bereits + * gutgeschriebene Topups (credited_at gesetzt) werden übersprungen. + */ + public function fulfill(CreditTopup $topup, ?string $sessionId = null, ?string $paymentIntentId = null): void + { + if ($topup->credited_at !== null) { + return; + } + + DB::transaction(function () use ($topup, $sessionId, $paymentIntentId): void { + $topup->update([ + 'status' => SinglePurchaseStatus::Paid, + 'paid_at' => $topup->paid_at ?? now(), + 'credited_at' => now(), + 'stripe_checkout_session_id' => $sessionId ?? $topup->stripe_checkout_session_id, + 'stripe_payment_intent_id' => $paymentIntentId ?? $topup->stripe_payment_intent_id, + ]); + + $this->wallet->credit( + $topup->user, + $topup->credits, + CreditTransactionType::Topup, + "Credit-Paket {$topup->pack_key}", + $topup, + ); + }); + } +} diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index c25b853..a390515 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -2,6 +2,7 @@ namespace App\Services\Billing; +use App\Models\CreditTopup; use App\Models\Plan; use App\Models\SinglePurchase; use App\Models\User; @@ -117,4 +118,24 @@ class StripeCheckoutService 'metadata' => ['single_purchase_id' => (string) $purchase->id], ]); } + + /** + * Stripe-Checkout für eine Wallet-Aufladung. Ad-hoc-Charge über den + * Netto-Paketpreis (Stripe Tax ergänzt die USt.); die `credit_topup_id` + * in den Metadaten schließt den Kreis im Webhook (Wallet-Gutschrift). + */ + public function forCreditTopup(User $user, CreditTopup $topup): Checkout + { + $this->syncTaxIdFromBillingAddress($user); + + return $user->checkoutCharge( + $topup->price_cents, + "Credit-Guthaben ({$topup->credits} Credits)", + 1, + [ + ...$this->sessionOptions(), + 'metadata' => ['credit_topup_id' => (string) $topup->id], + ], + ); + } } diff --git a/config/credits.php b/config/credits.php index 4eaa5f9..a18100f 100644 --- a/config/credits.php +++ b/config/credits.php @@ -42,6 +42,18 @@ return [ */ 'proof_pdf' => 3, + /* + | Credit-Pakete (Wallet-Topup über Stripe). 1 Credit = 1 € beim Ausgeben; + | größere Pakete enthalten Bonus-Credits (Volumenrabatt steckt im Paket). + | `price_cents` ist der Netto-Zahlbetrag, `credits` die Wallet-Gutschrift. + */ + 'packs' => [ + ['key' => 'p10', 'price_cents' => 1000, 'credits' => 10], + ['key' => 'p25', 'price_cents' => 2500, 'credits' => 27], + ['key' => 'p50', 'price_cents' => 5000, 'credits' => 55], + ['key' => 'p100', 'price_cents' => 10000, 'credits' => 115], + ], + /* | Depublizieren (Magic-Link-Pfad G) — bewusst am teuersten, mit Bedenkzeit. */ diff --git a/database/migrations/2026_06_17_145303_create_credit_topups_table.php b/database/migrations/2026_06_17_145303_create_credit_topups_table.php new file mode 100644 index 0000000..a5c6c0c --- /dev/null +++ b/database/migrations/2026_06_17_145303_create_credit_topups_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('pack_key'); + $table->integer('credits'); + $table->integer('price_cents'); + $table->string('currency', 3)->default('EUR'); + $table->string('status')->default('pending'); + $table->string('stripe_checkout_session_id')->nullable(); + $table->string('stripe_payment_intent_id')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamp('credited_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_topups'); + } +}; diff --git a/routes/customer.php b/routes/customer.php index 3e6ce39..59e64a1 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -42,6 +42,8 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin ->name('checkout.subscription'); Route::get('checkout/einzel-pm', [CheckoutController::class, 'singlePm']) ->name('checkout.single-pm'); + Route::get('checkout/credits/{pack}', [CheckoutController::class, 'creditTopup']) + ->name('checkout.credit-topup'); Route::get('checkout/abo-verwalten', [CheckoutController::class, 'billingPortal']) ->name('checkout.billing-portal'); Volt::route('invoices', 'customer.invoices')->name('invoices.index'); diff --git a/tests/Feature/CreditTopupTest.php b/tests/Feature/CreditTopupTest.php new file mode 100644 index 0000000..8883db0 --- /dev/null +++ b/tests/Feature/CreditTopupTest.php @@ -0,0 +1,105 @@ +seed(RolesAndPermissionsSeeder::class); + $this->topups = app(CreditTopupService::class); + $this->wallet = app(CreditWalletService::class); +}); + +test('the bonus pack staffel is configured as agreed', function () { + expect($this->topups->packs())->toBe([ + ['key' => 'p10', 'price_cents' => 1000, 'credits' => 10], + ['key' => 'p25', 'price_cents' => 2500, 'credits' => 27], + ['key' => 'p50', 'price_cents' => 5000, 'credits' => 55], + ['key' => 'p100', 'price_cents' => 10000, 'credits' => 115], + ]); +}); + +test('starting a topup creates a pending record without crediting yet', function () { + $user = User::factory()->create(); + + $topup = $this->topups->startTopup($user, 'p25'); + + expect($topup->status)->toBe(SinglePurchaseStatus::Pending); + expect($topup->credits)->toBe(27); + expect($topup->price_cents)->toBe(2500); + expect($this->wallet->balance($user))->toBe(0); +}); + +test('an unknown pack is rejected', function () { + $user = User::factory()->create(); + + expect(fn () => $this->topups->startTopup($user, 'nope')) + ->toThrow(InvalidArgumentException::class); +}); + +test('fulfilling a topup credits the wallet exactly once', function () { + $user = User::factory()->create(); + $topup = $this->topups->startTopup($user, 'p50'); + + $this->topups->fulfill($topup, 'cs_test_1', 'pi_test_1'); + $this->topups->fulfill($topup->fresh(), 'cs_test_1', 'pi_test_1'); // Replay + + expect($this->wallet->balance($user))->toBe(55); + expect($topup->fresh()->status)->toBe(SinglePurchaseStatus::Paid); + expect($topup->fresh()->credited_at)->not->toBeNull(); + expect($user->creditTransactions()->count())->toBe(1); +}); + +test('the checkout webhook credits the wallet from session metadata', function () { + $user = User::factory()->create(); + $topup = $this->topups->startTopup($user, 'p100'); + + $event = new WebhookReceived([ + 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'id' => 'cs_test_hook', + 'payment_intent' => 'pi_test_hook', + 'metadata' => ['credit_topup_id' => (string) $topup->id], + ]], + ]); + + app(ProcessStripeWebhook::class)->handle($event); + + expect($this->wallet->balance($user))->toBe(115); + expect($topup->fresh()->credited_at)->not->toBeNull(); +}); + +test('the topup checkout route redirects to the profile without a billing address', function () { + $user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]); + $user->assignRole('customer'); + + $this->actingAs($user) + ->get(route('me.checkout.credit-topup', ['pack' => 'p25'])) + ->assertRedirect(route('me.profile')); +}); + +test('the topup checkout route starts a stripe checkout with a complete billing address', function () { + $user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]); + $user->assignRole('customer'); + BillingAddress::factory()->create(['user_id' => $user->id]); + + $checkout = Mockery::mock(Checkout::class); + $checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test')); + $mock = Mockery::mock(StripeCheckoutService::class); + $mock->shouldReceive('forCreditTopup')->once()->andReturn($checkout); + app()->instance(StripeCheckoutService::class, $mock); + + $this->actingAs($user) + ->get(route('me.checkout.credit-topup', ['pack' => 'p25'])) + ->assertRedirect('https://checkout.stripe.com/c/pay/test'); + + expect($user->creditTopups()->where('pack_key', 'p25')->exists())->toBeTrue(); +});