承接 maestrodimateo/simple-mobile-money 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

maestrodimateo/simple-mobile-money

Composer 安装命令:

composer require maestrodimateo/simple-mobile-money

包简介

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

README 文档

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 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,
));

Contents

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.

There are two flows, depending on the provider:

Flow Providers What pay() returns What you do
USSD push SingPay (default), PViT, E-Billing (flow: 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 (flow: ext) PaymentResponse with a redirectUrl Redirect the user to that URL; they pay on the provider's page and come back

The 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 (the transactions table is registered automatically):

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

Provider setup

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

# ── default provider used when you call MobileMoney::driver() with no argument
MOBILE_MONEY_PROVIDER=singpay

E-Billing (Digitech)

Credentials come from your merchant profile (LAB or PROD). Auth is HTTP Basic.

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, otherwise the driver throws. It is also callback-only: it exposes no status endpoint, so status() throws for this driver (see How it works).

SingPay

Credentials come from SingPay Workspace (client.singpay.ga). A test wallet is created automatically; the same base URL is used for test and production.

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 flow (both required by SingPay; callbackUrl overrides success):
# 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=              # the {codeUrl} in your endpoints
PVIT_ACCOUNT_OPERATION_CODE=
PVIT_CALLBACK_URL_CODE=

Some provider details are not publicly documented (PViT's Airtel v2 operator code, E-Billing operator machine codes and status endpoint). They are configurable in config/mobile-money.php (providers.*.operators) rather than hard-coded — confirm them with each provider and adjust if needed.

Quickstart (end to end)

1. Start the payment (controller)

use Illuminate\Http\Request;
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,                       // XAF, integer
            reference: 'ORDER-'.$order->id,    // your unique reference
            msisdn: $request->input('phone'),  // e.g. '074000000'
            operator: Operator::AIRTEL,
            description: "Order #{$order->id}",
        ));

        // Hosted flow (E-Billing): send the customer to the provider's page.
        if ($response->needsRedirect()) {
            return redirect()->away($response->redirectUrl);
        }

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

$response->status is normally PaymentStatus::PENDING here. Do not treat it as paid yet.

2. Register the webhook URL

The package already exposes the route POST /mobile-money/webhook/{provider}. In each provider's merchant dashboard, register the matching public URL:

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

3. Fulfil the order when the payment succeeds (listener)

use Maestrodimateo\MobileMoney\Events\PaymentSucceeded;

class FulfilOrder
{
    public function handle(PaymentSucceeded $event): void
    {
        // $event->result is a CallbackResult (provider-neutral)
        $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.

That's the whole loop: initiate → (redirect or push) → event → fulfil.

API reference

Facade — MobileMoney

MobileMoney::driver(?string $provider = null): Gateway   // null = default provider

Each Gateway exposes:

Method Returns Purpose
pay(PaymentRequest $request) PaymentResponse Start a collection
status(string $providerReference) PaymentStatus Poll the current status (throws if supportsStatusQuery() is false)
supportsStatusQuery() bool Whether the provider exposes status polling (E-Billing: false)
verify(string $merchantRef, ?string $providerRef) PaymentStatus Re-verify bound to the merchant reference (used internally by the webhook)
parseWebhook(Request $request) CallbackResult Normalise a raw webhook (used internally)
name() string The provider key

PaymentRequest (you build this)

Field Type Required Notes
amount int yes XAF, integer (no decimals)
reference string yes Your unique reference (PViT: ≤ 15 chars)
msisdn string yes Customer number, e.g. 074000000
operator Operator yes Operator::AIRTEL or Operator::MOOV
description ?string no Shown to the customer
payerName ?string no
payerEmail ?string no
callbackUrl ?string no Per-request return URL (hosted flow)
metadata array no Echoed back to you; never sent to the 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 — with ->label() ("Airtel Money" / "Moov Money").

CallbackResult (carried by webhook events)

reference, status, providerReference, amount, operator, raw.

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/cancelled/expired provider, result, transaction

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

Transaction model

Stored in mobile_money_transactions: provider, reference, provider_reference, status (cast to PaymentStatus), amount, currency, msisdn, operator (cast to Operator), description, metadata, raw.

Exceptions

  • InvalidConfigurationException — missing credential or unmapped operator.
  • ProviderRequestException — the provider returned an HTTP error (->provider, ->statusCode, ->context).
  • Both extend MobileMoneyException.

Webhooks in depth

  • Endpoint: POST {webhooks.path}/{provider} (default mobile-money/webhook/{provider}), route name mobile-money.webhook.
  • No CSRF: the route is registered outside the web group, so external POSTs work without a token.
  • Acknowledgement: the endpoint replies { "responseCode": 200, "transactionId": "..." } — the exact shape PViT requires; the others just need HTTP 200.
  • Testing locally: expose your app with a tunnel (e.g. php artisan expose or ngrok) and register the public tunnel URL in the dashboards.
  • Disable entirely: set MOBILE_MONEY_WEBHOOKS=false (the route won't be registered).

Security model

None of the three aggregators cryptographically sign their webhooks, so the callback body is treated as an untrusted hint — never as truth. On every webhook the package:

  1. optionally restricts callbacks to an IP allowlist (webhooks.allowed_ips) — configure TrustedProxies so request()->ip() is the real client IP behind a load balancer;
  2. matches the callback to a stored transaction by merchant reference and rejects any webhook pointing to a different provider transaction than the one on record (reference-confusion protection);
  3. 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 — keep it on). For SingPay's ext flow (where no provider reference is known until the webhook), the outcome is additionally bound to the merchant reference: the re-verified provider transaction must belong to the expected order, or it is rejected;
  4. fails closed (HTTP 4xx, no success dispatched) when it cannot re-verify or is unsure (UNKNOWN / AMBIGUOUS), and never moves a transaction out of a terminal state. For E-Billing (callback-only), a matching amount is mandatory and a partial payment is never promoted to a full success;
  5. dispatches terminal events only on the transition into the final state, so a replayed webhook cannot re-trigger fulfilment;
  6. is rate-limited by default (webhooks.middleware), since each call triggers a synchronous outbound verification.

These guarantees rely on the stored transaction, so keep persistence enabled (store.enabled) in production. With storage off, the package can only do a best-effort check and your application must validate the reference ↔ provider-reference mapping itself.

Configuration reference

config/mobile-money.php:

Key Default Purpose
default ebilling Provider used by MobileMoney::driver()
store.enabled true Persist transactions (also gates the auto-migration)
store.store_raw true Persist providers' raw payloads (contain PII) — set false to omit
store.table mobile_money_transactions Table name
store.model Transaction::class Model used for persistence (a custom one must extend Transaction)
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 (throttling recommended)
webhooks.verify_status true Re-verify status before trusting a success
webhooks.allowed_ips [] Per-provider IP allowlist ([] = no IP check)
providers.* Credentials, base URLs and operator maps

Recipes & FAQ

Use a custom Transaction model — point store.model at your own model (extend the package's Transaction, or match its columns).

Go stateless — set store.enabled=false. You then persist whatever you need from the PaymentInitiated / PaymentSucceeded events yourself. Read the security note first.

Make fulfilment idempotent — the same webhook can arrive more than once. Guard your listener (if ($order->isPaid()) return;) as shown in the quickstart.

Poll instead of / in addition to webhooks — call MobileMoney::driver('pvit')->status($providerReference) where $providerReference is PaymentResponse->providerReference (or the stored Transaction->provider_reference). Not available for E-Billing, which is callback-only (supportsStatusQuery() === false).

Test your integration — the package uses Laravel's HTTP client, so fake it:

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');

Testing

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

Roadmap

  • Disbursement / payout (cash-out).
  • SingPay hosted payment page (/ext) as an alternative to the USSD-push flow.
  • Direct Airtel Money / Moov Money integrations (operator contracts required).

License

MIT.

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 1
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-07-10

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固