One Interface, Three Payment Gateways: Cashworx's Payment Layer
How Cashworx's API abstracts multiple payment gateways — its own settlement gateway, Paystack, and Flutterwave — behind one interface, with config-driven fallback and vendor status normalization.
Cashworx is a tax and service payment platform built for Nigerian state government use. From the start, the backend couldn't assume a single payment provider — government adoption of any one gateway is never guaranteed to be permanent, and the platform needed to add, swap, or fall back between providers without that decision rippling through the rest of the codebase.
The interface
Every gateway in cashworxs_api implements the same contract:
interface PaymentGatewayInterface
{
public function createInvoice(array $invoiceData): array;
public function processPayment(array $paymentData): array;
public function verifyPayment(string $reference): array;
public function getPaymentStatus(string $reference): string;
public function webhookHandler(array $payload): array;
}
Two concrete implementations exist today — CashworxsGateway, which talks to the platform's own settlement API with token authentication, and PaystackGateway, which talks to Paystack's public API directly. Each one translates its provider's request/response shape into the same array structure (success, data, gateway, and where relevant reference), so nothing downstream needs to know which provider actually handled the call.
A manager that resolves gateways by name, not by branch
PaymentGatewayManager is a small factory/registry — it doesn't run any payment logic itself, it just knows how to build and cache a gateway instance for a given name:
public function gateway(?string $name = null): PaymentGatewayInterface
{
$gatewayName = $name ?? $this->defaultGateway;
if (!isset($this->gateways[$gatewayName])) {
$this->gateways[$gatewayName] = $this->createGateway($gatewayName);
}
return $this->gateways[$gatewayName];
}
The default gateway and the fallback order both come from config, not code:
'default_gateway' => env('PAYMENT_DEFAULT_GATEWAY', 'cashworxs'),
'fallback_order' => [
'cashworxs',
'paystack',
'flutterwave',
// 'stripe'
],
That commented-out stripe line is the whole point. Adding a fourth gateway means writing one new class and uncommenting a line — not touching the controller, the invoice flow, or the payment flow.
Fallback lives in the service layer, not the controller
PaymentService::processPayment() is where the resilience actually happens. It accepts a list of gateways to try (or falls back to the configured default), and walks through them in order:
foreach ($gateways as $gateway) {
try {
DB::beginTransaction();
$result = $this->gatewayManager->gateway($gateway)->processPayment($paymentData);
if ($result['success']) {
// store payment, update invoice, record transaction
DB::commit();
return ['success' => true, 'payment' => $payment, 'gateway' => $gateway];
}
} catch (\Exception $e) {
DB::rollback();
$lastError = $e->getMessage();
continue;
}
}
Each attempt is its own database transaction, so a failed attempt on gateway A never leaves a half-written payment record behind before gateway B is tried. Only once every gateway in the list has failed does the service record a single failed transaction with the list of gateways it attempted — which turns "why did this payment fail" into a one-row lookup instead of a log-diving exercise.
Normalizing what "paid" means
Every provider reports status in its own vocabulary — Paystack says success, Flutterwave says successful, Cashworx's own gateway reports a bare 1. Rather than let that leak into the application, config/payment.php maps each provider's status strings to one internal vocabulary:
'status_mapping' => [
'cashworxs' => ['1' => 'completed', '0' => 'pending', 'failed' => 'failed'],
'paystack' => ['success' => 'completed', 'pending' => 'pending', 'failed' => 'failed', 'abandoned' => 'failed'],
'flutterwave' => ['successful' => 'completed', 'pending' => 'pending', 'failed' => 'failed', 'cancelled' => 'failed'],
],
Alongside it, each gateway config carries a supports map — invoices, payments, verification, webhooks, refunds — so the system can ask "can this provider issue a refund?" as a config lookup instead of a hardcoded assumption.
Where the abstraction still leaks
Not everything is fully decoupled yet. Webhook post-processing in PaymentController::processWebhookData() still switches on the gateway name to decide how to read the payload:
switch ($gateway) {
case 'paystack': $this->processPaystackWebhook($data); break;
case 'flutterwave': $this->processFlutterwaveWebhook($data); break;
case 'cashworx': $this->processCashworxWebhook($data); break;
}
That's the one spot where adding a new gateway still means editing an existing file rather than just adding one — a reasonable next refactor would be pushing that payload-parsing responsibility back into each gateway class, behind the interface, the same way webhookHandler() already is.
Takeaway
None of this is exotic — it's a textbook Strategy pattern with a factory in front of it. What makes it worth writing about is where the seams were actually drawn: the interface speaks in outcomes (completed/pending/failed), not in any one vendor's vocabulary, and the things likely to change — which gateway is default, which order to fall back through, what each gateway is capable of — live in config, not in if statements. The one place that still branches on provider name is a useful reminder that "abstracted" is a spectrum, not a checkbox.