maestrodimateo / simple-mobile-money
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
Requires
- php: ^8.3 || ^8.4
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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 thePaymentSucceededevent.
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_emailandpayer_name— setpayerEmailandpayerNameon thePaymentRequest.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+ formgrant_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 theebilling-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->statusisPENDING— 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
PayoutInitiated → PayoutSucceeded / 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 ifsupportsStatusQuery()isfalse(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 |
transactionis the storedTransactionmodel (ornullif 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:
-
IP allowlist — optionally restricts callbacks to known IPs (
webhooks.allowed_ips). Configure TrustedProxies sorequest()->ip()is the real client IP behind a load balancer. -
Reference matching — matches the callback to a stored transaction and rejects any webhook pointing to a different provider transaction than the one on record.
-
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'sextflow, the re-verified provider transaction must also match the merchant reference. -
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. -
Replay-safe — dispatches terminal events only on the transition into the final state. A replayed webhook cannot re-trigger fulfilment.
-
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.
