Search by

maestrodimateo / simple-mobile-money

mebalenoel

A simple, unified Laravel API for Gabonese mobile money aggregators (E-Billing, SingPay, PViT)

Package info

github.com/maestrodimateo/simple-mobile-money

pkg:composer/maestrodimateo/simple-mobile-money

Statistics

Installs: 145

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.3.2 2026-09-07 14:20 UTC

This package is auto-updated.

Last update: 2026-09-07 14:22:48 UTC


README

Simple Mobile Money — one Laravel interface for every Gabonese mobile money aggregator (E-Billing, SingPay, PViT)

Tests Quality Latest Version on Packagist License

One simple, unified Laravel API to collect and disburse mobile money payments in Gabon — through E-Billing, SingPay and PViT (Airtel Money & Moov Money).

You write the same code for every provider. The package hides each API's base URL, auth scheme, payload format and webhook shape behind neutral DTOs, a single facade, and Laravel events.

Nouveau sur le mobile money ? Lis le Guide du débutant — une explication pas à pas (en français) du fonctionnement du package et de chaque agrégateur.

$response = MobileMoney::driver('singpay')->pay(new PaymentRequest(
    amount: 500,                 // XAF
    reference: 'ORDER-1042',     // your unique reference
    msisdn: '074000000',         // customer's number
    operator: Operator::AIRTEL,
));

Table of contents

Section Description
How it works Lifecycle of a payment — read this first
Requirements PHP & Laravel versions
Installation Composer, config, migration
Provider setup Credentials for E-Billing, SingPay, PViT
Quickstart End-to-end payment in 3 steps
Payout (cash-out) Send money to a beneficiary
API reference Facade, DTOs, enums, events, exceptions
Webhooks Route, security, local testing
Security model How webhooks are verified
Configuration Full config reference
Testing Package tests & MobileMoney::fake()
Recipes & FAQ Common patterns
Roadmap What's next


How it works

A mobile money collection is asynchronous: pay() only starts it. The real outcome (success / failure) arrives later through a webhook, which the package turns into a Laravel event.


Two flows

Flow Providers What pay() returns What you do
USSD push SingPay (default), PViT, E-Billing (ussd_push) PaymentResponse with needsRedirect() === false Tell the user to confirm the push (PIN) on their phone, then wait for the event
Hosted redirect E-Billing (default), SingPay (ext) PaymentResponse with a redirectUrl Redirect the user to that URL; they pay on the provider's page and come back

Full lifecycle

  Your app                         Package                       Provider
     │  pay(PaymentRequest)           │                              │
     │ ─────────────────────────────► │  initiate + store txn        │
     │                                │ ───────────────────────────► │
     │ ◄───────────────────────────── │  PaymentResponse (PENDING)   │
     │  (redirect OR "confirm push")  │                              │
     │                                │        ┌── customer pays ────┤
     │                                │        │  (PIN / hosted page)│
     │                                │ ◄──────┴── webhook ───────── │
     │                                │  re-verify status via API    │
     │                                │ ───────────────────────────► │
     │ ◄── PaymentSucceeded event ─── │  update txn + dispatch event │
     │  fulfill the order             │                              │

Key idea: never mark an order as paid from the pay() response. Fulfil it only when you receive the PaymentSucceeded event.



Requirements

  • PHP 8.3+
  • Laravel 12.x / 13.x


Installation

composer require maestrodimateo/simple-mobile-money

Publish the config and run the migration:

php artisan vendor:publish --tag=mobile-money-config
php artisan migrate

The transactions table is registered automatically.



Provider setup

Pick your default provider, then fill in credentials for the ones you use.

MOBILE_MONEY_PROVIDER=singpay

E-Billing (Digitech)

Credentials come from your merchant profile (LAB or PROD).

EBILLING_USERNAME=
EBILLING_SHARED_KEY=
EBILLING_ENV=lab            # lab | production
EBILLING_FLOW=redirect      # redirect (hosted portal) | ussd_push (Airtel/Moov)
# EBILLING_EXPIRY_PERIOD=30 # bill validity in minutes (optional)

E-Billing requires payer_email and payer_name — set payerEmail and payerName on the PaymentRequest.

E-Billing is callback-only: no status endpoint, so status() throws (supportsStatusQuery() === false).


Payout credentials (SHAP API)

Payout uses separate OAuth2 credentials:

EBILLING_PAYOUT_API_ID=
EBILLING_PAYOUT_API_SECRET=

PAYIN OAuth2 (AWS Cognito)

E-Billing is migrating PAYIN from HTTP Basic to AWS Cognito OAuth2 (production cutover 2026-08-31). The driver supports both — it stays on Basic until you flip the toggle.

EBILLING_AUTH=oauth2
EBILLING_OAUTH_CLIENT_ID=
EBILLING_OAUTH_CLIENT_SECRET=
# EBILLING_OAUTH_SCOPE="ebilling-api/invoice:create ebilling-api/payment:create"
# EBILLING_OAUTH_TOKEN_URL=
# EBILLING_OAUTH_TOKEN_TTL=3300
OAuth2 details
  • Uses client-credentials flow (HTTP Basic client_id:secret + form grant_type=client_credentials&scope=…).
  • Token is cached automatically.
  • Scopes: ebilling-api/invoice:create (create a bill), ebilling-api/payment:create (USSD push). The portal shows names without the ebilling-api/ prefix — always use the prefix in config.
  • Secret rotation: Digitech auto-rotates client_secret (lab every 30 days, prod every 90). Update during the grace period.
  • This covers PAYIN only — the SHAP payout API keeps its own EBILLING_PAYOUT_API_* credentials.

SingPay

Credentials come from SingPay Workspace (client.singpay.ga).

SINGPAY_CLIENT_ID=
SINGPAY_CLIENT_SECRET=
SINGPAY_WALLET=
# SINGPAY_DISBURSEMENT=        # required only for a production wallet
SINGPAY_FLOW=ussd_push         # ussd_push (direct) | ext (hosted page)

For the ext (hosted page) flow:

SINGPAY_REDIRECT_SUCCESS=https://your-app.com/pay/success
SINGPAY_REDIRECT_ERROR=https://your-app.com/pay/error
# SINGPAY_LOGO_URL=

PViT (mypvit / BakoAI, API v2)

Credentials come from your PViT merchant space. Auth is the X-Secret header.

PVIT_SECRET=sk_live_xxxxxxxx
PVIT_CODE_URL=
PVIT_ACCOUNT_OPERATION_CODE=
PVIT_CALLBACK_URL_CODE=

Note: Some provider details are not publicly documented (PViT's Airtel v2 operator code, E-Billing operator machine codes). They are configurable in config/mobile-money.php (providers.*.operators) — confirm them with each provider.



Quickstart

Three steps: initiate → webhook → fulfil.


Step 1 — Start the payment

use Maestrodimateo\MobileMoney\Facades\MobileMoney;
use Maestrodimateo\MobileMoney\Data\PaymentRequest;
use Maestrodimateo\MobileMoney\Enums\Operator;

class CheckoutController
{
    public function pay(Request $request)
    {
        $response = MobileMoney::driver('singpay')->pay(new PaymentRequest(
            amount: 500,
            reference: 'ORDER-'.$order->id,
            msisdn: $request->input('phone'),
            operator: Operator::AIRTEL,
            description: "Order #{$order->id}",
        ));

        // Hosted flow → redirect the customer
        if ($response->needsRedirect()) {
            return redirect()->away($response->redirectUrl);
        }

        // USSD-push flow → customer confirms on their phone
        return view('checkout.pending', ['reference' => $response->reference]);
    }
}

$response->status is PENDING — do not treat it as paid yet.


Step 2 — Register the webhook URL

The package exposes POST /mobile-money/webhook/{provider} automatically. Register the matching public URL in each provider's dashboard:

https://your-app.com/mobile-money/webhook/singpay
https://your-app.com/mobile-money/webhook/ebilling
https://your-app.com/mobile-money/webhook/pvit

Step 3 — Fulfil the order on success

use Maestrodimateo\MobileMoney\Events\PaymentSucceeded;

class FulfilOrder
{
    public function handle(PaymentSucceeded $event): void
    {
        $order = Order::where('reference', $event->result->reference)->firstOrFail();

        if ($order->isPaid()) {
            return; // idempotent: the same webhook may arrive more than once
        }

        $order->markPaid();
    }
}

Register it in your EventServiceProvider (or with an attribute listener). A PaymentFailed event is dispatched for failed / cancelled / expired payments.



Payout (cash-out)

Send money out to a beneficiary (refund, cashback, withdrawal).


Provider support

Provider Payout Notes
E-Billing Yes Any beneficiary via SHAP. Synchronous for Gabon operators.
PViT Yes "Rendu monnaie" via GIVE_CHANGE.
SingPay No SingPay's only cash-out is a transfer tied to a prior collection — not an arbitrary payout.

Usage

use Maestrodimateo\MobileMoney\Data\PayoutRequest;
use Maestrodimateo\MobileMoney\Enums\Operator;
use Maestrodimateo\MobileMoney\Enums\PayoutType;

$response = MobileMoney::driver('ebilling')->payout(new PayoutRequest(
    amount: 5000,
    reference: 'PAYOUT-42',
    msisdn: '074000000',
    operator: Operator::AIRTEL,
    type: PayoutType::REFUND,       // refund | cashback | withdrawal
));

$response->status;  // PaymentStatus::SUCCESS (synchronous)

Balance & status

MobileMoney::driver('ebilling')->balance();                 // ['airtelmoney' => 305394, ...]
MobileMoney::driver('ebilling')->payoutStatus('PAYOUT-42'); // PaymentStatus

Payout events

PayoutInitiatedPayoutSucceeded / PayoutFailed (fired immediately since the payout is synchronous). Payouts are stored in the same table with type = payout.



API reference


Facade — MobileMoney

MobileMoney::driver(?string $provider = null): Gateway   // null = default provider
MobileMoney::fake(): MobileMoneyFake                      // test double (see Testing)

Gateway methods

Method Returns Description
pay(PaymentRequest) PaymentResponse Start a collection
status(string $providerRef) PaymentStatus Poll current status
supportsStatusQuery() bool Whether the provider supports status polling
verify(string $merchantRef, ?string $providerRef) PaymentStatus Re-verify bound to merchant reference
parseWebhook(Request) CallbackResult Normalise a raw webhook
name() string The provider key

status() throws if supportsStatusQuery() is false (E-Billing).


PaymentRequest

What you build and pass to pay().

Field Type Required Notes
amount int yes XAF, integer
reference string yes Your unique reference (PViT: max 15 chars)
msisdn string yes Customer number, e.g. 074000000
operator Operator yes AIRTEL or MOOV
description ?string Shown to the customer
payerName ?string Required for E-Billing
payerEmail ?string Required for E-Billing
callbackUrl ?string Per-request return URL (hosted flow)
disbursement ?string SingPay only: ID of the pre-registered disbursement recipient (falls back to config)
metadata array Echoed back; never sent to provider

PaymentResponse

Returned by pay().

Property Type Notes
status PaymentStatus Usually PENDING
reference string Your reference
providerReference ?string Provider transaction / bill id
redirectUrl ?string Set for hosted flows
raw array Raw provider response
needsRedirect() bool true when a redirect is required

PaymentStatus (enum)

PENDING · SUCCESS · FAILED · CANCELLED · EXPIRED · AMBIGUOUS · UNKNOWN

$status->isFinal();       // true for SUCCESS / FAILED / CANCELLED / EXPIRED
$status->isSuccessful();  // true only for SUCCESS

Operator (enum)

Operator::AIRTEL · Operator::MOOV

$operator->label();  // "Airtel Money" / "Moov Money"

CallbackResult

Carried by webhook events.

Property Type
reference string
status PaymentStatus
providerReference ?string
amount ?int
operator ?string
raw array

Events

Event When Properties
PaymentInitiated After pay() provider, request, response, transaction
PaymentSucceeded Webhook re-verified as success provider, result, transaction
PaymentFailed Webhook re-verified as failed provider, result, transaction
PayoutInitiated After payout() provider, request, response, transaction
PayoutSucceeded Payout confirmed success provider, result, transaction
PayoutFailed Payout confirmed failure provider, result, transaction

transaction is the stored Transaction model (or null if persistence is off).


Transaction model

Stored in mobile_money_transactions.

provider · type · reference · provider_reference · status · amount · currency · msisdn · operator · description · metadata · raw


Exceptions

Exception When
InvalidConfigurationException Missing credential or unmapped operator
ProviderRequestException Provider returned an HTTP error (->provider, ->statusCode, ->context)

Both extend MobileMoneyException.



Webhooks


Endpoint POST {webhooks.path}/{provider} (default mobile-money/webhook/{provider})
Route name mobile-money.webhook
CSRF Excluded — the route is registered outside the web group
Response { "responseCode": 200, "transactionId": "..." } (shape required by PViT; others just need HTTP 200)
Disable MOBILE_MONEY_WEBHOOKS=false

Testing locally: expose your app with a tunnel (e.g. php artisan expose or ngrok) and register the public tunnel URL in the provider dashboards.



Security model

None of the three aggregators sign their webhooks. The callback body is treated as an untrusted hint. On every webhook the package:

  1. IP allowlist — optionally restricts callbacks to known IPs (webhooks.allowed_ips). Configure TrustedProxies so request()->ip() is the real client IP behind a load balancer.

  2. Reference matching — matches the callback to a stored transaction and rejects any webhook pointing to a different provider transaction than the one on record.

  3. Status re-verification — re-verifies the status against the provider API using the stored provider reference, never the one in the webhook body (webhooks.verify_status, on by default). For SingPay's ext flow, the re-verified provider transaction must also match the merchant reference.

  4. Fail-closed — returns HTTP 4xx when it cannot re-verify or the status is uncertain (UNKNOWN / AMBIGUOUS). Never moves a transaction out of a terminal state. For E-Billing (callback-only), a matching amount is mandatory.

  5. Replay-safe — dispatches terminal events only on the transition into the final state. A replayed webhook cannot re-trigger fulfilment.

  6. Rate-limited — by default (webhooks.middleware), since each call triggers a synchronous outbound verification.

Keep persistence enabled (store.enabled) in production. With storage off, the package can only do a best-effort check — your app must validate the reference ↔ provider-reference mapping itself.



Configuration

config/mobile-money.php

Key Default Description
default ebilling Provider used by MobileMoney::driver()
Storage
store.enabled true Persist transactions (also gates the auto-migration)
store.store_raw true Persist raw payloads (contain PII) — false to omit
store.table mobile_money_transactions Table name
store.model Transaction::class Custom model must extend Transaction
Webhooks
webhooks.enabled true Register the webhook route
webhooks.path mobile-money/webhook Base path (provider is appended)
webhooks.middleware ['throttle:60,1'] Middleware for the webhook route
webhooks.verify_status true Re-verify before trusting a success
webhooks.allowed_ips [] Per-provider IP allowlist ([] = no check)
Providers
providers.* Credentials, base URLs, operator maps


Testing

Package tests

composer test        # Pest
composer lint        # Pint (code style)
composer analyse     # PHPStan / Larastan level 6

Faking in your app

MobileMoney::fake() swaps the manager for a test double — no HTTP, no raw payloads to stub.

use Maestrodimateo\MobileMoney\Facades\MobileMoney;

it('places an order', function () {
    MobileMoney::fake();

    $this->post('/checkout', ['amount' => 500])->assertOk();

    MobileMoney::assertPaid(fn ($request, $provider) => $request->amount === 500);
});

Stub responses

Method Effect
respondToPayments(PaymentStatus) Status returned by pay() (default PENDING)
respondToPayouts(PaymentStatus) Status returned by payout() (default SUCCESS)
respondToStatus(PaymentStatus) Status returned by status() / verify()
respondToBalance(array) Value returned by balance()

Assertions

Method Asserts
assertPaid($callback | int | null) A payment was made
assertPayout($callback | int | null) A payout was made
assertNothingSent() No payment or payout was made

Testing a declined payment

use Maestrodimateo\MobileMoney\Enums\PaymentStatus;

it('handles a declined payment', function () {
    MobileMoney::fake()->respondToPayments(PaymentStatus::FAILED);

    // ... assert your code reacts to the FAILED response
});

The fake covers pay() / payout() only. It does not persist transactions or dispatch events (those are webhook-driven). To test that path, Http::fake() the provider and POST the webhook route.



Recipes & FAQ


Custom Transaction model Point store.model at your own model (extend the package's Transaction).


Go stateless Set store.enabled=false. Persist what you need from the events yourself. Read the security note first.


Idempotent fulfilment The same webhook can arrive more than once. Guard your listener: if ($order->isPaid()) return;


Poll status Call MobileMoney::driver('pvit')->status($providerReference) where $providerReference is from PaymentResponse->providerReference or the stored transaction. Not available for E-Billing (supportsStatusQuery() === false).


Test with HTTP fakes

use Illuminate\Support\Facades\Http;

Http::fake([
    'gateway.singpay.ga/*' => Http::response([
        'transaction' => ['id' => 'tx-1', 'status' => 'Start'],
    ]),
]);

$response = MobileMoney::driver('singpay')->pay(/* ... */);
expect($response->providerReference)->toBe('tx-1');


Roadmap

  • Direct Airtel Money / Moov Money integrations (operator contracts required).


License

MIT.