Skip to content
←Back to projects
Lead Backend / Full-Stack

Multi-Gateway Payment Engine

Design and implementation of a gateway-agnostic, fault-tolerant installment payment processing engine (BNPL). Features automatic gateway failover, synchronous and webhook-based asynchronous tracking, distributed lock idempotency, and dynamic balance recalculation upon order modification.

Multi-Gateway Payment Engine

Detailed Implementation Overview

The Business Problem and Key Challenges

On e-commerce platforms with high-value transactions (travel bookings, holidays, annual subscriptions), purchases often involve complex payment rules. The challenge is not limited to processing a one-off transaction; it involves managing a financial lifecycle spread over time.

  • Installments and deposits: collecting an initial deposit at the time of booking and scheduling collection of the outstanding balance.
  • Order volatility: bookings and associated services may change between the initial deposit and the final installment, requiring dynamic recalculation of outstanding amounts without modifying installments that have already been collected.
  • Payment gateway availability (PSPs): temporary provider outages or payment failures may cause customers to abandon the checkout process.
  • Webhook processing and concurrency: asynchronous notifications from payment providers and repeated user submissions can lead to duplicate charges and inconsistent payment states.

Architecture and Technical Solutions

┌────────────────────────────────────────────────────────┐
│               Client Interface (Vue 3 / TS)            │
└───────────────────────────┬────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────────┐
│            Payment Abstraction Manager                 │
│      ┌────────────────────┬────────────────────┐       │
│      │  PrimaryPSPAdapter │ SecondaryPSPAdapter│       │
│      └─────────┬──────────┴─────────┬──────────┘       │
└────────────────┼────────────────────┼──────────────────┘
                 │                    │
                 ▼                    ▼
┌────────────────────────────────────────────────────────┐
│             Core Payment Processing Engine             │
│   - Routing Policy (Configuration & Business Rules)    │
│   - Redis Distributed Lock (Concurrency Control)       │
│   - Order Ledger & Dynamic Balance Calculator          │
└───────────────────────────┬────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────────┐
│               Asynchronous Queue Engine                │
│   - Laravel Horizon / Redis Queue                      │
│   - Webhook Event Processing with Retry Policies       │
└────────────────────────────────────────────────────────┘

1. Payment Gateway Abstraction (Adapter Pattern and Failover)

The application does not depend directly on proprietary SDKs. A unified PaymentGatewayInterface contract hides each provider's implementation details.

Gateway selection and failover rules depend on several parameters: configuration, payment type, and product category. A routing policy determines which gateways are eligible and which rules apply to each transaction.

If the primary gateway fails, the engine can switch to a fallback gateway according to these rules. However, a timeout with an uncertain outcome must not automatically trigger another attempt through a different provider, as this could result in a duplicate charge.

2. Idempotency and Concurrent Processing Protection

To prevent duplicate charges caused by simultaneous submissions or page refreshes:

  • Acquire an atomic Redis lock (Cache::lock) keyed by a unique idempotency key.
  • Persist payment operations in the database, with a unique constraint on the idempotency key.
  • Validate state transitions and return the result of an operation that has already been executed.
  • Reconcile operations with uncertain outcomes before any further attempt.

Redis locking limits concurrent processing but does not, by itself, guarantee idempotency. Persistent operation records and database integrity constraints remain essential.

3. Dynamic Installment Schedule Calculator

The engine initially generates a payment schedule consisting of planned installments (SCHEDULED). When a booking is amended or modified:

  • Completed installments (COMPLETED) remain unchanged in the payment history.
  • The outstanding balance is recalculated and distributed across future installments that can still be modified.
  • If the amount already collected exceeds the revised total, any refund is handled separately.
  • Installments currently being processed must be protected against concurrent modifications.

Background workers execute scheduled collections using secure payment tokens stored with the provider, according to the mechanisms supported by that provider.

4. Asynchronous Webhook Processing

Provider notifications are received, their authenticity is verified using the available signature mechanisms, and the events are placed in a Redis queue. Actual state changes are processed outside the direct HTTP request/response cycle.

Processing must also account for duplicate notifications and events received out of order. Event deduplication and state transition validation help prevent inconsistencies between the internal system and the actual payment state.

Code Illustration (Generic and Abstracted Examples)

1. Gateway Contract and Failover Router

namespace App\Domain\Payment\Contracts;

use App\Domain\Payment\DTOs\PaymentRequest;
use App\Domain\Payment\DTOs\PaymentResult;

interface PaymentGatewayInterface
{
    public function getProviderIdentifier(): string;

    public function charge(PaymentRequest $request): PaymentResult;
}

The routing policy determines which gateways are eligible and which rules apply to the transaction.

namespace App\Domain\Payment\DTOs;

final readonly class PaymentContext
{
    public function __construct(
        public string $paymentType,
        public string $productCategory,
        public array $configuration
    ) {}
}namespace App\Domain\Payment\Services;

use App\Domain\Payment\DTOs\PaymentContext;

class RoutingPolicy
{
    public function resolve(PaymentContext $context): RoutingDecision
    {
        // Apply rules based on configuration,
        // payment type, and product category.
        // Return eligible gateways and fallback rules.
    }
}

RoutingDecision represents the decision produced by the routing policy: eligible gateways, priority order, and rules governing subsequent attempts.

The router applies this decision and manages the permitted attempts. The exceptions and helper methods below are illustrative.

namespace App\Domain\Payment\Services;

use App\Domain\Payment\Contracts\PaymentGatewayInterface;
use App\Domain\Payment\DTOs\PaymentRequest;
use App\Domain\Payment\DTOs\PaymentResult;
use App\Domain\Payment\Exceptions\PaymentGatewayUnavailableException;
use App\Domain\Payment\Exceptions\PaymentOutcomeUnknownException;
use Psr\Log\LoggerInterface;

class PaymentGatewayRouter
{
    /** @var iterable<PaymentGatewayInterface> */
    private iterable $gateways;

    public function __construct(
        iterable $gateways,
        private readonly LoggerInterface $logger
    ) {
        $this->gateways = $gateways;
    }

    public function process(
        PaymentRequest $request,
        RoutingDecision $decision
    ): PaymentResult {
        foreach ($decision->getAllowedProviders() as $providerId) {
            $gateway = $this->findGateway($providerId);

            try {
                $result = $gateway->charge($request);

                if ($result->isSuccessful()) {
                    return $result;
                }

                if (!$decision->allowsFallbackAfter($result)) {
                    return $result;
                }
            } catch (PaymentOutcomeUnknownException $e) {
                // The payment may already have been processed.
                // Reconciliation is required before retrying.
                throw $e;
            } catch (PaymentGatewayUnavailableException $e) {
                if (!$e->isConfirmedNotProcessed()) {
                    throw new PaymentOutcomeUnknownException(
                        'Payment outcome requires reconciliation.',
                        previous: $e
                    );
                }

                $this->logger->warning(
                    'Payment gateway unavailable.',
                    [
                        'provider' => $providerId,
                        'reference' => $request->reference,
                    ]
                );

                if (!$decision->allowsFallbackAfter($e)) {
                    throw $e;
                }
            }
        }

        throw new PaymentGatewayUnavailableException(
            'No eligible payment gateway could process the request.'
        );
    }

    private function findGateway(
        string $providerId
    ): PaymentGatewayInterface {
        foreach ($this->gateways as $gateway) {
            if ($gateway->getProviderIdentifier() === $providerId) {
                return $gateway;
            }
        }

        throw new PaymentGatewayUnavailableException(
            'Configured payment gateway is not available.'
        );
    }
}

2. Idempotency Service and Secure Payment Execution

namespace App\Domain\Payment\Services;

use App\Domain\Payment\DTOs\PaymentRequest;
use App\Domain\Payment\DTOs\PaymentContext;
use App\Domain\Payment\DTOs\PaymentResult;
use App\Domain\Payment\Exceptions\ConcurrentTransactionException;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Facades\DB;

class IdempotentPaymentProcessor
{
    public function __construct(
        private readonly PaymentGatewayRouter $router,
        private readonly RoutingPolicy $routingPolicy,
        private readonly PaymentOperationRepository $operations,
        private readonly CacheRepository $cache
    ) {}

    public function execute(
        PaymentRequest $request,
        PaymentContext $paymentContext
    ): PaymentResult {
        $lock = $this->cache->lock(
            'payment:' . $request->idempotencyKey,
            60
        );

        if (!$lock->get()) {
            throw new ConcurrentTransactionException(
                'A payment operation is already being processed.'
            );
        }

        try {
            $operation = DB::transaction(
                fn () => $this->operations->findOrCreate(
                    $request->idempotencyKey,
                    $request
                )
            );

            if ($operation->isCompleted()) {
                return $operation->getResult();
            }

            if ($operation->isOutcomeUnknown()) {
                throw new ConcurrentTransactionException(
                    'Payment requires reconciliation before retry.'
                );
            }

            $decision = $this->routingPolicy->resolve($paymentContext);

            // The network call takes place outside the SQL transaction.
            $result = $this->router->process($request, $decision);

            DB::transaction(
                fn () => $this->operations->recordResult(
                    $operation,
                    $result
                )
            );

            return $result;
        } finally {
            $lock->release();
        }
    }
}

PaymentOperationRepository represents the persistence layer for payment operations. It must enforce idempotency key uniqueness, verify that repeated requests use consistent parameters, and protect state transitions.

If execution is interrupted between the provider call and result persistence, the operation must be reconciled before another attempt is made. Redis locking does not replace these persistence and reconciliation mechanisms.

3. Dynamic Installment Schedule Recalculation

namespace App\Domain\Payment\Services;

use App\Domain\Payment\Models\PaymentSchedule;
use Illuminate\Support\Collection;
use InvalidArgumentException;

class InstallmentRecalculator
{
    /**
     * @param Collection<int, PaymentSchedule> $schedules
     */
    public function recalculateRemaining(
        Collection $schedules,
        int $newTotalMinor
    ): Collection {
        $completedAmount = $schedules
            ->filter(fn (PaymentSchedule $item) => $item->isCompleted())
            ->sum(fn (PaymentSchedule $item) => $item->getAmountMinor());

        $remainingBalance = $newTotalMinor - $completedAmount;

        $pendingItems = $schedules->filter(
            fn (PaymentSchedule $item) => $item->isPending()
        );

        if ($remainingBalance <= 0) {
            $pendingItems->each(
                fn (PaymentSchedule $item) => $item->markAsCancelled()
            );

            return $schedules;
        }

        $pendingCount = $pendingItems->count();

        if ($pendingCount === 0) {
            throw new InvalidArgumentException(
                'No remaining installment can accommodate the balance.'
            );
        }

        $baseAmount = intdiv($remainingBalance, $pendingCount);
        $remainder = $remainingBalance % $pendingCount;

        foreach ($pendingItems->values() as $index => $schedule) {
            $amount = $baseAmount
                + ($index < $remainder ? 1 : 0);

            $schedule->updateAmountMinor($amount);
        }

        return $schedules;
    }
}

The calculation uses minor currency units (for example, cents for euros) rather than floating-point numbers to avoid precision and distribution errors. This snippet assumes that PENDING installments can be modified and that all amounts use the same currency.

// TECHNOLOGY STACK

PHP8.4, Laravel 11, Typescript, Vue.js 3, MySQL, Redis, Docker