SimpliFi — Payments (module M3)
How SimpliFi takes money for WiFi packages, PPPoE bills, platform invoices and SMS credits, how
each Ghanaian gateway is set up, and how the money (and Henapp's share) flows.
Contract details for other modules live in docs/ARCHITECTURE.md §7.2, §9 and §18.
1. Money flow
Customer ──pays──▶ gateway ──settles──▶ ISP (own gateway) SimpliFi invoices its share later
Customer ──pays──▶ SimpliFi Paystack ──split──▶ ISP subaccount SimpliFi's share kept at source
| Own gateways (default) | SimpliFi Collect (collection_mode = platform_split) |
|
|---|---|---|
| Who owns the gateway account | The ISP (Paystack / Hubtel / MTN MoMo / link / cash) | Henapp (platform Paystack gateway) |
| Where the money settles | ISP's account, immediately | ISP's MoMo wallet / bank via a Paystack subaccount |
| Henapp's share | Invoiced per billing period (M8) | Deducted from every payment (transaction_charge) |
payments.platform_fee |
amount × revenue share % (recorded, invoiced later) | same amount, fee_collected_at_source = true (excluded from invoices) |
- Revenue share (default 10 %):
platform_fee = amount × tenants.revenue_share_percent / 100on successful package and PPPoE invoice payments. Fixed billing:platform_fee = 0; the fixed fee is invoiced per cycle. Platform invoices and SMS credit purchases never carry a fee. - Gateway charges (Settings → Payments → "Gateway charges", setting
payments.fee_bearer):isp(default) — customers pay the exact price, the provider's fee comes out of the ISP's money;customer— the per-method "Fee added when customers pay the charges (%)" is added on top at checkout (stored inpayments.meta.surcharge; the package price stays inpayments.amount). - With SimpliFi Collect, Paystack's own fees come out of the ISP's share (
bearer = subaccount).
2. Payment lifecycle
- A module creates the payment:
PaymentService::create([...])(statuspending, referenceSF-<ULID>). - The payer picks a method on the hosted page (
tenant.pay.show,/pay/{uuid}) — or the module callsinitiate()itself. Mobile-money prompts set the status toprocessing. - Confirmation always comes from the gateway, server-side: webhook ⇒ verify API, the pay page polling
api.payments.status(verify at most every 10 s), or thepayments:reconcilejob (every minute, backoff 15 s → 15 min). PaymentService::markSuccessful()— the only success path — locks the row, checksamount paid >= amount(elsefailed/amount_mismatch), setspaid_at,verified_at, channel, network, the platform fee, and dispatchesPaymentCompletedonce, after commit.- Open payments older than 2 h get one last check and are failed with
timeout. Money that arrives later (webhook / "Verify now") is still honoured.
Payment-link payments without a signed webhook: the payer submits the provider's transaction ID ⇒
processing ⇒ staff open Payments and Approve (after checking their statement) or Reject.
A transaction ID can only be used once per payment method.
3. Webhook URLs
Each saved gateway shows its own URL (Settings → Payments, or Super admin → Payment gateways), e.g.
https://<APP_URL host>/webhooks/payments/paystack/12
https://<APP_URL host>/webhooks/payments/hubtel/13
https://<APP_URL host>/webhooks/payments/mtn_momo/14
https://<APP_URL host>/webhooks/payments/payment_link/15
Hosted checkouts return the payer to /webhooks/payments/return/{payment uuid} (Paystack
callback_url, Hubtel returnUrl / cancellationUrl), which verifies and forwards to the pay page.
Every inbound call is stored in webhook_logs (without Authorization / cookie headers).
4. Gateway setup
Paystack (recommended)
- Keys: Paystack Dashboard → Settings → API Keys & Webhooks. Paste the secret key
(
sk_test_…while testing,sk_live_…live); the public key is optional. - Webhook: paste the gateway's webhook URL in the same dashboard page. SimpliFi checks the
x-paystack-signatureheader (HMAC-SHA512 of the raw body with the secret key) and only acts oncharge.success, then re-verifies withGET /transaction/verify/{reference}. Docs: https://paystack.com/docs/payments/webhooks/ - How customers pay:
- Mobile Money prompt (
momo_direct, default) —POST /chargewithmobile_money {phone, provider}; providermtn(MTN),vod(Telecel),atl(AirtelTigo), detected from the number (payers can override for ported numbers). Telecel may answersend_otp: the pay page then asks for the SMS code (POST /charge/submit_otp). Docs: https://paystack.com/docs/api/charge/, https://paystack.com/docs/payments/payment-channels/#mobile-money - Checkout page (
checkout) —POST /transaction/initialize, channels from the "Checkout page channels" field (defaultmobile_money,card). Docs: https://paystack.com/docs/api/transaction/#initialize
- Mobile Money prompt (
- Paystack requires an e-mail; payers without one get
<phone>@<PAYMENTS_FALLBACK_EMAIL_DOMAIN or APP host>. - Test mode: use your
sk_test_key and Paystack's test numbers / cards from https://paystack.com/docs/payments/test-payments/ (Ghana MoMo test numbers are listed there, e.g.0551234987for MTN). "Test connection" callsGET /balance.
Hubtel
- Credentials (Hubtel merchant dashboard → API keys): API ID (client ID), API key (client secret) and the merchant account number / POS Sales ID. All three must belong to the same account.
- Modes: Direct Receive Money (
direct, default) —POST https://rmp.hubtel.com/merchantaccount/merchants/{POS}/receive/mobilemoneywith channelmtn-gh/vodafone-gh/tigo-gh; Online Checkout (checkout) —POST https://payproxyapi.hubtel.com/items/initiate(hosted page, MoMo + cards). - Confirmation: Hubtel callbacks are not signed, so a callback only triggers the status check
GET https://api-txnstatus.hubtel.com/transactions/{POS}/status?clientReference=…(Paid⇒ success). Ask Hubtel to enable Direct Receive Money and to whitelist your server's public IP for the status API — without it "Test connection" reports HTTP 403 and payments only confirm by callback + status. - Docs (sign-in required): https://developers.hubtel.com (Receive Money, Transaction Status Check),
https://dev-docs.hubtel.com/frontend/tools-and-sdks/checkout/api-reference.html (Online Checkout).
The three URLs are configurable (
HUBTEL_RECEIVE_URL,HUBTEL_STATUS_URL,HUBTEL_CHECKOUT_URL) in case Hubtel changes them. Hubtel has no public sandbox: test with small live amounts.
MTN MoMo (Collections API)
- Sandbox: register at https://momodeveloper.mtn.com, subscribe to Collections, create an API
user + key (
POST /v1_0/apiuser,POST /v1_0/apiuser/{id}/apikey) and paste the subscription key, API user (UUID) and API key. Test mode always useshttps://sandbox.momodeveloper.mtn.com, target environmentsandboxand currency EUR (sandbox limitation). Sandbox test MSISDNs such as46733123450(failed) /46733123451(rejected) /46733123452(timeout) simulate outcomes; other numbers succeed. - Live: MTN Ghana issues production credentials and the target environment (default
mtnghana); live mode callshttps://proxy.momoapi.mtn.comin GHS and only accepts MTN numbers. - Callbacks: tick "Send our callback URL" only when the API user's callback host is this server (MTN
rejects other hosts). Payments are confirmed by polling
GET /collection/v1_0/requesttopay/{id}anyway. - Docs: https://momodeveloper.mtn.com/api-documentation/api-description
Payment link (GTBank / Squad, ExpressPay, Zeepay …)
- Payment link URL with placeholders
{amount}(e.g.5.00),{reference}(SF-…),{phone}(024…),{email}; must start withhttps://(orhttp://). - Without a webhook the payer pays, comes back and submits the transaction ID; staff approve it.
- With a webhook: set the signing secret, the signature header (default
X-Signature, value = hex HMAC-SHA256 of the raw body, optionally prefixedsha256=), and the JSON paths of our reference, the status and the amount (data.reference, …). Values listed in "Status values meaning paid" complete the payment automatically (the signed webhook is the confirmation); the amount must cover the payment.
Cash / manual
Instructions only. Staff record cash sales through the voucher / sales screens, which call
PaymentService::recordCash() (immediately successful, user_id = the staff member).
5. SimpliFi Collect (platform split)
- Super admin → Payment gateways → add Henapp's Paystack gateway (live secret key) and paste its webhook URL into Henapp's Paystack dashboard.
- The ISP owner opens Settings → Payments → Collection mode → chooses a MoMo wallet or bank (list
from Paystack
GET /bank?type=mobile_money|ghipss) and the account number. SimpliFi creates a Paystack subaccount (POST /subaccount,percentage_charge= revenue share %), storestenants.paystack_subaccount_codeand switches toplatform_split. - Package / PPPoE payments then use the platform Paystack gateway with
subaccount,transaction_charge= platform fee in pesewas andbearer = subaccount. Docs: https://paystack.com/docs/payments/split-payments/
Only the owner (billing.manage) can change the collection mode or the settlement account.
When a tenant's billing mode changes, update the subaccount (re-submit the form) so Paystack's default
percentage matches; each payment also sends an explicit transaction_charge.
6. For other modules
$payments = app(\App\Services\Payments\PaymentService::class);
$payment = $payments->create([
'gateway' => $gateway, // optional: let the payer choose on the pay page
'purpose' => Payment::PURPOSE_PACKAGE,
'amount' => $package->price,
'package_id' => $package->id,
'customer_id' => $customer?->id,
'phone' => $phone,
'meta' => ['return_url' => route('portal.success'), 'mac' => $ctx->clientMac],
]);
return redirect()->away(\App\Services\Payments\PaymentUrls::pay($payment));
Listen for PaymentCompleted (check purpose, wrap work in tenancy()->runAs()); set
fulfilled_at when you deliver. Poll GET /api/payments/{uuid}/status from your own pages if needed.
7. Operations
- Scheduler:
payments:reconcileevery minute (verify due payments, expire stale ones). - Config / env:
PAYMENTS_HTTP_TIMEOUT(15),PAYMENTS_PENDING_TIMEOUT(120 min),PAYMENTS_FALLBACK_EMAIL_DOMAIN,PAYSTACK_BASE_URL,HUBTEL_*_URL,MTN_MOMO_*_URL. - Captive portal walled garden: allow the APP host plus the gateway hosts (
*.paystack.com,*.paystack.co,*.hubtel.com,*.mtn.com.gh) so payers can reach checkout pages before login. - Troubleshooting: Super admin → Payments → a payment shows its webhook log (signature valid / errors) and raw gateway responses; "Verify now" re-checks with the gateway (also for failed payments).
8. More gateways (M11 / M12)
Settings → Payments now opens with a searchable gateway picker ("Recommended for Ghana" first, then by region) and each gateway page shows its own setup guide, the webhook URL to copy and a Test credentials button that checks the keys before saving. Drag the configured methods to set their order; "Request a gateway" sends SimpliFi a support ticket for anything missing.
| Gateway | Guide | Notes |
|---|---|---|
| Flutterwave | docs/gateways/flutterwave.md |
Standard checkout or Ghana MoMo charge; webhook verif-hash |
| pawaPay | docs/gateways/pawapay.md |
MoMo prompt in 21 countries (MTN / Telecel / AirtelTigo in Ghana) |
| expressPay | docs/gateways/expresspay.md |
Ghana checkout; server IP must be whitelisted |
| theTeller (PaySwitch) | docs/gateways/theteller.md |
Checkout or MoMo prompt |
| Moolre | docs/gateways/moolre.md |
MoMo prompt (with SMS code on first use) or Web POS link |
| Squad by GTCO + GTBank Ghana link | docs/gateways/squad.md |
Squad = NGN / USD; GTBank Ghana is a payment-link preset |
| Stripe | docs/gateways/stripe.md |
Checkout Sessions for diaspora cards |
| DPO Pay | docs/gateways/dpo.md |
Pan-African hosted page (XML API) |
| Korapay | docs/gateways/korapay.md |
Checkout or MoMo (GH / KE) |
| Zeepay | docs/gateways/zeepay.md |
Payment-link preset (no verifiable public production API) |
| M-Pesa (Safaricom Daraja) | docs/gateways/mpesa_daraja.md |
Kenya STK Push (paybill or till); confirmed by STK query |
| Pesapal | docs/gateways/pesapal.md |
Hosted checkout KE / UG / TZ / RW / MW / ZM / ZW; IPN registered automatically |
| AzamPay | docs/gateways/azampay.md |
Tanzania USSD push; RSA-signed callback is the confirmation |
| ClickPesa | docs/gateways/clickpesa.md |
Tanzania USSD push or checkout link; optional checksum; no sandbox |
| Relworx | docs/gateways/relworx.md |
Uganda / Kenya / Tanzania prompt; Relworx-Signature webhooks; no sandbox |
| ioTec Pay | docs/gateways/iotecpay.md |
Uganda prompt into an ioTec wallet; shared-secret callback header |
| JulyPay | docs/gateways/julypay.md |
Uganda payment-link preset (API docs not public): transaction ID + approval |
| Fapshi | docs/gateways/fapshi.md |
Cameroon MTN / Orange prompt or payment link; x-wh-secret webhooks |
| Payfast | docs/gateways/payfast.md |
South Africa hosted page; ITN validated (signature, IP, server confirmation) |
| Paynow | docs/gateways/paynow.md |
Zimbabwe web checkout or EcoCash / OneMoney Express; SHA512-hashed messages |
| Airtel Money | docs/gateways/airtel_money.md |
Airtel Africa Open API USSD push in 13 markets; hashed callbacks |
Payment-link family (M12): any driver extending PaymentLinkDriver (today payment_link and julypay) gets the
"submit transaction ID ⇒ staff approve" flow (PaymentGateway::isLinkDriver()).
Walled garden: also allow the hosts of the gateways you enable (e.g. *.flutterwave.com, *.pawapay.io,
*.expresspaygh.com, *.theteller.net, *.moolre.com, *.squadco.com, *.stripe.com, *.3gdirectpay.com,
*.korapay.com). Hosted pages of M12 gateways: *.pesapal.com, *.clickpesa.com, *.fapshi.com, *.payfast.co.za,
*.paynow.co.zw (prompt-only gateways — M-Pesa, AzamPay, Relworx, ioTec, Airtel Money — need nothing extra).