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:
parent
69411b4c87
commit
344aac0740
10 changed files with 382 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
44
app/Models/CreditTopup.php
Normal file
44
app/Models/CreditTopup.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
92
app/Services/Billing/CreditTopupService.php
Normal file
92
app/Services/Billing/CreditTopupService.php
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
105
tests/Feature/CreditTopupTest.php
Normal file
105
tests/Feature/CreditTopupTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue