Money
Payment integrations
Mobile Money, cards, and bank transfer — all through one provider so reconciliation works automatically.
Flutterwave
PropertyOS uses Flutterwave as the single payment gateway. One API key unlocks every channel popular in East Africa:
- MTN Mobile Money — Uganda, Rwanda networks via STK push.
- Airtel Money — same UX, Airtel network.
- Card — Visa, Mastercard, Verve via Flutterwave hosted checkout.
- Bank transfer — virtual account numbers issued per transaction; tenant pays with their bank app, system matches automatically.
Environment configuration
Add to .env.local:
FLUTTERWAVE_SECRET_KEY="FLWSECK-..." FLUTTERWAVE_PUBLIC_KEY="FLWPUBK-..." FLUTTERWAVE_WEBHOOK_HASH="your-strong-random-string"
The WEBHOOK_HASH is the value you paste into the Flutterwave dashboard's webhook config and must match exactly — the system rejects unsigned webhooks via timing-safe comparison.
The payment flow
- Tenant clicks Pay in the portal. The route
/api/payments/<channel>/initiatecreates aMobileMoneyTransactioninPENDINGstate and calls Flutterwave to start the charge. - Flutterwave drives the tenant's UX (STK push, hosted checkout, bank transfer details).
- On settlement, Flutterwave POSTs the webhook to
/api/payments/<channel>/callback. The route verifies theverif-hashheader (timing-safe), then callsFlutterwaveService.handleCallback(). - The handler does an atomic
updateManyon the transaction guarded bystatus: not SUCCESSFULso concurrent retries can't double-process. It validates the callback amount + currency against the original transaction. - On success, it auto-reconciles into a
RentPayment(overpayment is clamped to the outstanding balance with the difference flagged in notes). - Errors propagate so the webhook route returns 500 — Flutterwave retries the webhook on its own schedule until the system finalizes cleanly.
Reconciliation safety
- Atomic status transitions — only one path can move a transaction to SUCCESSFUL.
- Amount + currency checks — tampered/replayed webhooks claiming a lower amount are rejected and the transaction flips to FAILED.
- Bank balance via increment — concurrent payments to the same bank account never race to overwrite each other's balance.
- Fetch timeouts — 30s on every outbound Flutterwave API call so a hung provider doesn't block dashboard pages.
Application fees & SaaS subscriptions
Two non-rent payment paths share the same webhook signature mechanism:
- Application fees — webhook callback identifies the payment via
meta.kind = "application_fee", finds theTenantApplicationbyapplicationFeeRef, and atomically setsapplicationFeePaidAt. - SaaS subscription — separate webhook at
/api/webhooks/flutterwave-subscription; pays the org's monthly invoice and reactivates the subscription if it was inPAST_DUE. Atomic PAID transition so concurrent webhooks don't double-extend the period.
Manual reconciliation
If a tenant pays cash at the office or via channels outside Flutterwave, record the payment manually under Rent → open the charge → Record Payment. The same GL entries post — only the source channel differs.