Wallet-Topup ueber Stripe (Credit-Pakete mit Bonus-Staffel)

End-to-end-Aufladung der Credit-Wallet, spiegelt den Einzel-PM-Fluss:

- Paket-Staffel mit Bonus in config/credits.php (10->10, 25->27, 50->55,
  100->115 Credits; price_cents netto, Stripe Tax ergaenzt USt.)
- credit_topups (Pending->Paid) + CreditTopupService: startTopup legt den
  Pending-Kauf an, fulfill() schreibt die Wallet idempotent gut (credited_at)
- StripeCheckoutService::forCreditTopup via checkoutCharge + credit_topup_id
  Metadata; ProcessStripeWebhook schreibt bei checkout.session.completed gut
- Checkout-Route /admin/me/checkout/credits/{pack} mit Rechnungsadress-Gate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-17 14:56:23 +00:00
parent 69411b4c87
commit 344aac0740
10 changed files with 382 additions and 1 deletions

View file

@ -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.

View file

@ -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<string, mixed> $session
*/
private function fulfillCheckout(array $session): void
{
$this->fulfillSinglePurchase($session);
$this->fulfillCreditTopup($session);
}
/**
* @param array<string, mixed> $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<string, mixed> $session
*/

View file

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Enums\SinglePurchaseStatus;
use App\Services\Billing\CreditTopupService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Eine Wallet-Aufladung über Stripe. Anlage/Erfüllung über
* {@see CreditTopupService}.
*/
class CreditTopup extends Model
{
protected $fillable = [
'user_id',
'pack_key',
'credits',
'price_cents',
'currency',
'status',
'stripe_checkout_session_id',
'stripe_payment_intent_id',
'paid_at',
'credited_at',
];
protected function casts(): array
{
return [
'credits' => 'integer',
'price_cents' => 'integer',
'status' => SinglePurchaseStatus::class,
'paid_at' => 'datetime',
'credited_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -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.

View file

@ -0,0 +1,92 @@
<?php
namespace App\Services\Billing;
use App\Enums\CreditTransactionType;
use App\Enums\SinglePurchaseStatus;
use App\Models\CreditTopup;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Wallet-Aufladung über Credit-Pakete (Stripe). Der Kauf wird als Pending-
* CreditTopup angelegt und nach Zahlungseingang (Webhook) der Wallet
* gutgeschrieben idempotent über die `credited_at`-Marke.
*/
class CreditTopupService
{
public function __construct(private readonly CreditWalletService $wallet) {}
/**
* Verfügbare Credit-Pakete aus der Config.
*
* @return list<array{key: string, price_cents: int, credits: int}>
*/
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,
);
});
}
}

View file

@ -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],
],
);
}
}

View file

@ -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.
*/

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Wallet-Aufladung über Stripe. Spiegelt den Einzel-PM-Fluss: ein Pending-Kauf
* wird beim Checkout angelegt und per `checkout.session.completed`-Webhook auf
* `paid` gesetzt dann wird die Wallet gutgeschrieben (`credited_at` als
* Idempotenz-Marke gegen Doppelgutschrift bei wiederholten Webhooks).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('credit_topups', function (Blueprint $table): void {
$table->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');
}
};

View file

@ -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');

View file

@ -0,0 +1,105 @@
<?php
use App\Enums\SinglePurchaseStatus;
use App\Listeners\ProcessStripeWebhook;
use App\Models\BillingAddress;
use App\Models\User;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\CreditWalletService;
use App\Services\Billing\StripeCheckoutService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\RedirectResponse;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Events\WebhookReceived;
beforeEach(function (): void {
$this->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();
});