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

  1. Tenant clicks Pay in the portal. The route /api/payments/<channel>/initiate creates a MobileMoneyTransaction in PENDING state and calls Flutterwave to start the charge.
  2. Flutterwave drives the tenant's UX (STK push, hosted checkout, bank transfer details).
  3. On settlement, Flutterwave POSTs the webhook to /api/payments/<channel>/callback. The route verifies the verif-hash header (timing-safe), then calls FlutterwaveService.handleCallback().
  4. The handler does an atomic updateMany on the transaction guarded by status: not SUCCESSFUL so concurrent retries can't double-process. It validates the callback amount + currency against the original transaction.
  5. On success, it auto-reconciles into a RentPayment (overpayment is clamped to the outstanding balance with the difference flagged in notes).
  6. 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 the TenantApplication by applicationFeeRef, and atomically sets applicationFeePaidAt.
  • SaaS subscription — separate webhook at /api/webhooks/flutterwave-subscription; pays the org's monthly invoice and reactivates the subscription if it was in PAST_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.