Accept order payments and wallet deposits
This tutorial uses one Parakit installation for two payment purposes:
- A customer pays for an order.
- A customer deposits money into their application wallet.
Both use the same FIB gateway and webhook. Your application distinguishes them with a namespaced payment reference and metadata, then performs the correct business action when Parakit fires PaymentSucceeded.
Parakit handles the gateway call, webhook verification, transaction status, and duplicate webhook delivery. Your application still owns order fulfilment and wallet accounting.
Before you start
Install Parakit and configure FIB as the default gateway:
composer require froshly/parakit
php artisan parakit:installPARAKIT_DEFAULT=fib
FIB_BASE_URL=https://fib.stage.fib.iq
FIB_CLIENT_ID=your-fib-client-id
FIB_CLIENT_SECRET=your-fib-client-secret
FIB_CALLBACK_URL=https://yourapp.com/payments/webhooks/fibVerify the connection before building the application flow:
php artisan parakit:doctor --gateway=fibSee Installation and the FIB gateway guide for the complete setup.
Use namespaced references
Payment::for($model) uses only the model's key as the payment reference. An order and a wallet deposit can both have an ID of 123, so a bare ID is ambiguous when handling a successful payment.
Use namespaced references instead:
order:123
wallet_deposit:123Also store the payment purpose and application model ID in metadata. Parakit persists this metadata on its payment_transactions row and returns it on the transaction carried by lifecycle events.
Pay for an order
The example assumes the order stores amount_due, currency, status, payment_transaction_id, and paid_at.
Create the FIB payment from your checkout controller:
use App\Models\Order;
use Froshly\Parakit\Enums\Currency;
use Froshly\Parakit\Facades\Payment;
public function store(Order $order)
{
abort_unless($order->user_id === auth()->id(), 403);
abort_if($order->status === 'paid', 409, 'Order is already paid.');
$response = Payment::for("order:{$order->id}")
->amount($order->amount_due, Currency::IQD)
->description("Order #{$order->id}")
->idempotencyKey("order:{$order->id}")
->metadata([
'purpose' => 'order',
'order_id' => (string) $order->id,
])
->charge();
return view('payments.fib', [
'qrCode' => $response->qrCode,
'readableCode' => $response->readableCode,
'deepLink' => $response->deepLink,
'expiresAt' => $response->expiresAt,
]);
}Because PARAKIT_DEFAULT=fib, the example does not need ->driver('fib'). Add it when the customer can choose between gateways.
The response means FIB accepted and created the payment. It does not mean the order has been paid. Show the QR code or deep link, then wait for PaymentSucceeded.
The order ID is a suitable idempotency key when an order can have only one payment attempt. If your application allows a new attempt after a terminal failure, create a payment-attempt record and include its ID in both the reference and idempotency key.
Deposit into a wallet
Create one application-owned WalletDeposit record for each deposit attempt before calling the gateway. A minimal record needs:
idwallet_idamountcurrencystatus(pending,completed, orfailed)- nullable
payment_transaction_id - nullable
credited_at
Then initiate the payment:
use App\Models\WalletDeposit;
use Froshly\Parakit\Enums\Currency;
use Froshly\Parakit\Facades\Payment;
use Illuminate\Http\Request;
public function store(Request $request)
{
$validated = $request->validate([
'amount' => ['required', 'integer', 'min:1000'],
]);
$wallet = $request->user()->wallet;
$deposit = WalletDeposit::create([
'wallet_id' => $wallet->id,
'amount' => $validated['amount'],
'currency' => Currency::IQD->value,
'status' => 'pending',
]);
$response = Payment::for("wallet_deposit:{$deposit->id}")
->amount($deposit->amount, Currency::IQD)
->description("Wallet deposit #{$deposit->id}")
->idempotencyKey("wallet-deposit:{$deposit->id}")
->metadata([
'purpose' => 'wallet_deposit',
'deposit_id' => (string) $deposit->id,
])
->charge();
return view('payments.fib', [
'qrCode' => $response->qrCode,
'readableCode' => $response->readableCode,
'deepLink' => $response->deepLink,
'expiresAt' => $response->expiresAt,
]);
}The minimum deposit in this example is an application rule, not a Parakit requirement. IQD amounts are whole dinars, so 1000 means IQD 1,000.
Apply successful payments
Use one queued listener to route successful payments by their purpose:
namespace App\Listeners;
use App\Models\Order;
use App\Models\WalletDeposit;
use App\Models\WalletLedgerEntry;
use Froshly\Parakit\Events\PaymentSucceeded;
use Froshly\Parakit\Models\PaymentTransaction;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\DB;
use LogicException;
class CompleteSuccessfulPayment implements ShouldQueue
{
public int $tries = 5;
public function handle(PaymentSucceeded $event): void
{
$transaction = $event->transaction;
match ($transaction->metadata['purpose'] ?? null) {
'order' => $this->completeOrder($transaction),
'wallet_deposit' => $this->completeDeposit($transaction),
default => throw new LogicException('Unknown payment purpose.'),
};
}
private function completeOrder(PaymentTransaction $transaction): void
{
DB::transaction(function () use ($transaction): void {
$order = Order::query()
->lockForUpdate()
->findOrFail($transaction->metadata['order_id']);
if ($order->status === 'paid') {
return;
}
if (
(int) $order->amount_due !== $transaction->amount
|| $order->currency !== $transaction->currency->value
) {
throw new LogicException('Order payment amount mismatch.');
}
$order->update([
'status' => 'paid',
'payment_transaction_id' => $transaction->id,
'paid_at' => now(),
]);
});
}
private function completeDeposit(PaymentTransaction $transaction): void
{
DB::transaction(function () use ($transaction): void {
$deposit = WalletDeposit::query()
->lockForUpdate()
->findOrFail($transaction->metadata['deposit_id']);
if ($deposit->status === 'completed') {
return;
}
if (
(int) $deposit->amount !== $transaction->amount
|| $deposit->currency !== $transaction->currency->value
) {
throw new LogicException('Wallet deposit amount mismatch.');
}
WalletLedgerEntry::create([
'wallet_id' => $deposit->wallet_id,
'wallet_deposit_id' => $deposit->id,
'payment_transaction_id' => $transaction->id,
'type' => 'credit',
'amount' => $transaction->amount,
'currency' => $transaction->currency->value,
]);
$deposit->update([
'status' => 'completed',
'payment_transaction_id' => $transaction->id,
'credited_at' => now(),
]);
});
}
}Register the listener in your application's event configuration or in a service provider:
use App\Listeners\CompleteSuccessfulPayment;
use Froshly\Parakit\Events\PaymentSucceeded;
use Illuminate\Support\Facades\Event;
Event::listen(
PaymentSucceeded::class,
CompleteSuccessfulPayment::class,
);When you use an asynchronous queue connection, keep a queue worker running so the listener can process:
php artisan queue:workIf the wallet has a cached balance column, update it inside the same database transaction as the ledger entry. Do not update the balance separately.
Make wallet credits idempotent
Parakit deduplicates gateway webhooks, but your listener must also be safe to retry. A queue worker can retry a failed job, and Parakit's pending-payment sweeper can also discover a completed payment.
Add unique indexes to the wallet ledger:
$table->unique('wallet_deposit_id');
$table->unique('payment_transaction_id');The locked deposit row, completed check, database transaction, and unique indexes ensure one successful deposit creates one wallet credit.
Use the wallet ledger as the financial record. Avoid incrementing a wallet balance without also inserting a uniquely identified ledger entry.
Handle failed payments
PaymentFailed and PaymentCancelled report asynchronous terminal outcomes. Listen for them when your UI needs to mark the related order payment attempt or wallet deposit as failed. Never fulfil the order or create a wallet ledger entry from those events.
charge() can also throw before returning a payment response. Handle that exception in the controller and show a safe error to the customer. A timeout or GatewayUnavailableException has an unknown remote outcome, so leave the application record pending and let Parakit's sweeper reconcile it instead of immediately declaring it failed.
A later retry should use a new payment-attempt or wallet-deposit record. Do not reuse an idempotency key for a genuinely new gateway charge.
Rules to keep
- Use one gateway webhook for every payment purpose.
- Use namespaced references such as
order:123andwallet_deposit:123. - Store the application purpose and model ID in payment metadata.
- Treat
charge()as payment initiation, not payment confirmation. - Fulfil orders and credit wallets only from
PaymentSucceeded. - Compare the paid amount and currency with your application record.
- Make every success handler transactional and idempotent.
- Treat browser redirects, QR pages, and deep links as UI only.
For the underlying mechanics, see Charging a customer and Handling webhooks.