Selling credits
Credits for Laravel keeps the books; your payment provider takes the money. Grant the credits from the provider's webhook with the payment's id as the idempotency key, so a webhook delivered twice grants once.
Laravel Cashier (Stripe)
Sell a pack as a one-off Stripe Checkout, the number of credits in the session's metadata:
Route::post('/billing/credits', function (Request $request) {
return $request->user()->checkout(['price_credits_1000' => 1], [
'success_url' => route('billing'),
'cancel_url' => route('billing'),
'metadata' => ['credits' => 1_000],
]);
});
Grant them when Stripe says the session is paid (enable the checkout.session.completed event on your webhook endpoint):
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
if ($event->payload['type'] !== 'checkout.session.completed') {
return;
}
$session = $event->payload['data']['object'];
if ($session['payment_status'] !== 'paid' || ! isset($session['metadata']['credits'])) {
return;
}
Cashier::findBillable($session['customer'])?->credits()->grant(
(int) $session['metadata']['credits'],
'Credit pack',
source: 'top-up',
key: "stripe:{$session['id']}",
meta: ['stripe_session' => $session['id']],
);
});
Laravel Cashier (Paddle)
return view('billing', [
'checkout' => $request->user()->checkout('pri_credits_1000')->customData(['credits' => 1_000]),
]);
use Laravel\Paddle\Events\TransactionCompleted;
Event::listen(TransactionCompleted::class, function (TransactionCompleted $event) {
$credits = $event->payload['data']['custom_data']['credits'] ?? null;
if ($credits !== null) {
$event->billable->credits()->grant((int) $credits, 'Credit pack', source: 'top-up', key: "paddle:{$event->payload['data']['id']}");
}
});
Credits that come with a plan
A subscription's monthly credits are an allowance, not a webhook grant: tell the package what each plan gives and it grants every period on first use, anchored to the subscription's start if you like, upgrades included.
Credits::allowancesUsing(fn (User $user, string $wallet) => match (true) {
$user->subscribedToPrice('price_pro') => [Allowance::monthly(10_000)->anchoredAt($user->subscription()->created_at)],
default => [],
});
Refunded payments
When a payment is refunded, decide what happens to credits that may already be spent. Taking back what is left is one operator adjustment:
$wallet = $user->credits();
if ($left = min($credits, $wallet->available())) {
$wallet->adjust(-$left, "Refund of payment {$paymentId}", key: "refund:{$paymentId}");
}
Expiring purchases
Purchased credits often expire after a year. Pass the expiry with the grant; spends take the soonest-expiring credits first, so a customer's plan credits and older purchases are used before newer ones:
$wallet->grant(1_000, 'Credit pack', expiresAt: now()->addYear(), source: 'top-up', key: "stripe:{$session['id']}");