Flo

Customer Wallet

A mobile-first wallet your customers carry in their pocket — built on the same double-entry ledger that runs the rest of the platform.

LedgerFlow ships a customer-facing wallet as an installable PWA (the HTMX UI, also wrapped for the Play Store as a Trusted Web Activity) and as a mirrored set of SQLPage screens. Both front-ends call one self-scoped access layerapi.me_* — so a customer sees and moves only their own money, whichever channel they use.

Behind the simple Send · Request · Top up · Settle · Pay by QR buttons sits the full journal engine: every movement is a balanced double-entry transaction, every fee is its own auditable posting, and every read is constrained by row-level security keyed to the authenticated customer. This page explains the options, who they are for (P2P, B2B), how fees are applied, and the security model that makes it safe to run under the customer's own low-privilege role.

KYC & Verification →

Two Front-Ends, One Access Layer

The wallet exposes six money movements around a single account balance. Each maps to exactly one endpoint in the shared api.me_* layer:

Action Endpoint Direction Typical use
Send api.me_send_payment out P2P transfer or B2B pay-a-merchant
Request api.me_request_payment pull Ask another customer to pay you
Top up api.me_load_funds in (cash-in) Load the wallet from card / bank
Settle api.me_settle_funds out (cash-out) Withdraw to a bank account
Pay by QR api.me_request_open / api.me_request_fulfill in Show a code; anyone scans to pay you
Statement api.me_statement read Paginated history + proof-of-payment

The HTMX PWA (docker_mounts/htmx-ui/) runs under the customer's own JWT and calls PostgREST directly; the SQLPage customer pages set a session GUC and call the same functions. Because both go through api.me_*, a change to an endpoint updates both UIs at once — same enforcement, different transport. Real-time payment_received notifications are pushed over WebSocket to the payee the moment a transfer posts.

Person-to-Person (P2P)

The everyday case: one customer pays another, or asks to be paid.

Send debits the payer's wallet and credits the payee's in a single balanced transaction, with a payer note, a recipient note, and a shared reference. The payee receives a live in-app notification and can email a proof-of-payment PDF straight from the statement line.

Request is the pull equivalent — you nominate another customer, an amount and a reference, and they are prompted to approve payment from one of their accounts. Nothing leaves anyone's balance until the payer acts.

Pay by QR removes the need to know the other party in advance. The payee generates an open request (api.me_request_open) carrying a single-use bearer token with an expiry; anyone who scans the QR fetches the details (api.me_request_fetch) and fulfils it from their own account (api.me_request_fulfill). The token is locked on fulfilment, so a code can never be paid twice, can't be self-paid, and stops working once it expires.

Business-to-Business / Merchant (B2B)

The same api.me_send_payment rail carries merchant payments — a customer paying a shop, or one business settling with another. What differs is not the mechanism but the fee profile attached to the paying account:

  • A merchant-acquiring profile can charge the payer, the payee (merchant), or both, and can take the fee on top of the amount or out of it.
  • Because every account is created from an account profile that points at a customer_fee_profiles row, the platform operator (PSP) prices P2P and merchant traffic independently without changing any code.
  • Merchant collections settle to a bank via Settle, and a merchant's static "Scan-to-Pay-Me" QR (configurable on the Home / Login screen) lets walk-up customers pay in person with no pre-registration.

The wallet is therefore a single product that serves consumer P2P, merchant acceptance, and business disbursement — the ledger treats them identically; the fee engine and KYC tier express the commercial difference.

How Fees Are Applied

Fees are configuration, not code. Each account inherits a customer_fee_profiles row (via its account profile), and that row carries an independent block of settings for every operation — send, receive, load, settle, merchant, and the per-message fees (SMS / WhatsApp / email). For each block the engine resolves the fee in four steps:

1 · Threshold gate*_fee_threshold. If the transaction amount is at or below the threshold, the fee is zero. This lets small-value transfers run free while larger ones are priced.

2 · Rate — percentage first, then flat. If *_fee_percentage > 0, the fee is least(amount × percentage%, *_fee_capped) — a percentage capped at a ceiling. Otherwise the absolute flat fee *_fee_absolute applies. (The cap is ignored when the percentage is zero.)

3 · Source*_fee_deduct_source ∈ {payer, payee, both}. Decides which party bears the fee. both charges each side its own fee leg.

4 · Method*_fee_deduct_method:

  • balance_deduct — the fee is charged on top of the transfer; the payee receives the full amount and the fee comes additionally out of the payer's (and/or payee's) balance.
  • send_receive_deduct — the fee is taken out of the transfer amount; the payee receives the amount less the fee.

Whatever the outcome, the fee is never hidden inside the transfer: it posts as its own debit/credit pair into a fees-collection (revenue) account within the same parent transaction. That is why a wallet statement can show a transfer line and its fee line separately, and why api.me_statement deliberately pairs each posting with the opposing leg of equal magnitude so fee lines and transfer lines attribute to the correct counterparty.

The Six Fee-Bearing Operations

Every operation has the same seven-field shape in customer_fee_profiles, so an operator configures them consistently:

Field Meaning
*_fee_absolute Flat fee, used when percentage is 0
*_fee_percentage Percentage of amount (e.g. 1.5 = 1.5%)
*_fee_capped Ceiling on the percentage fee
*_fee_threshold Minimum amount before any fee applies
*_fee_deduct_source payer · payee · both
*_fee_deduct_method balance_deduct · send_receive_deduct
*_fee_narration Text that labels the fee posting

Prefix * with send_payment, receive_payment, load_payment, or settle_payment. Merchant pricing is expressed through the send/receive blocks on the merchant's account profile. Messaging fees (request_payment_*, confirm_payment_*, request_balance_*) are flat amounts that recover the cost of an SMS, WhatsApp, or email notification.

Worked Example — A Capped Percentage Send Fee

A profile prices outbound P2P sends like this:

send_payment_fee_threshold    = 10.00     -- free under 10
send_payment_fee_percentage   = 1.50      -- 1.5% above that
send_payment_fee_capped       = 5.00      -- never more than 5
send_payment_fee_absolute     = 0.50      -- (only if percentage were 0)
send_payment_fee_deduct_source = payer
send_payment_fee_deduct_method = balance_deduct
  • Send 8.00 → amount ≤ threshold → fee 0.00; payee receives 8.00.
  • Send 100.00 → 1.5% = 1.50 ≤ cap → fee 1.50; payer's balance −101.50, payee +100.00 (balance_deduct).
  • Send 1,000.00 → 1.5% = 15.00 → capped to 5.00; payer −1,005.00, payee +1,000.00.
  • Switch the method to send_receive_deduct and the last case becomes payer −1,000.00, payee +995.00 — the fee comes out of the amount instead.

Every one of those fee numbers is a real ledger posting into the revenue account — reconciled and audited like any other transaction.

Security — Enforced at the Database, Not the App

The wallet runs under the customer's own low-privilege role (j_customer_*), not a trusted service account. That is a deliberate choice: a stolen token or a tampered client can never do more than the customer's own role plus row-level security permit. Defence is layered:

  • Identity is server-derived. api.current_customer_id() reads the user id from the signed JWT (PostgREST) or the session GUC (SQLPage). The client never asserts who it is in a parameter — it can't pass ?user_id=eq.<someone-else> and be believed.
  • Row-Level Security on journal.customer_account_links and journal.customer_request_payment scopes every read to the authenticated customer. The read views are security_invoker, so RLS actually applies to the calling role.
  • SECURITY DEFINER write RPCs with an ownership gate. Every money movement calls api.customer_owns_account() before acting, then delegates to the privileged base functions. The customer can move money, but only from accounts they own.
  • The known leak is closed. The postgres-owned api.customer_account_links* views (which bypass RLS) were revoked from end-customer roles — they previously let one customer read another's balances. Customers use api.me_accounts instead.
  • Browser hardening. A strict Content-Security-Policy connect-src allowlist, an htmx validateUrl origin allowlist, a network-first service worker that never caches the cross-origin authed API, and 401 → automatic logout close off the front-end attack surface. The Android TWA is bound to the origin via Digital Asset Links.
  • The ledger's own guarantees sit underneath: balanced double-entry (debits = credits), trigger-based audit logging with full JSON before/after diffs in security.system_audit, and the platform's real-time cyclic fraud scoring on every posting.

Safe-by-Design QR Requests

The Pay-by-QR flow is bearer-token based but tightly bounded:

  • The token is a UUIDv7 minted server-side by api.me_request_open, with a caller-set expiry (default 60 minutes).
  • api.me_request_fulfill locks the request row (FOR UPDATE) before paying, so two simultaneous scans cannot double-pay — the second sees already paid.
  • Self-payment is rejected (you can't pay a request into the same account it pays from).
  • Only the minimal payer-facing fields are exposed by api.me_request_fetch; the token grants the right to pay, not to browse the payee's account.
  • The static "Scan-to-Pay-Me" Home/Login QR is client-side only and carries no credentials — it merely pre-fills the Send screen, where the normal authenticated, RLS-checked path takes over.

Real-Time, Content-Driven Notifications

When a payment posts, api.notify_payment_received() pushes a structured payload to the payee only, targeted by their email JWT claim, over the WebSocket server:

{ "type":"payment_received", "title":"Payment received",
  "body":"You received 50.00 USD into Wallet.",
  "severity":"success", "icon":"wallet",
  "actions":[{"label":"View statement",
              "href":"/app/statement.html?account_id=…"}] }

The notification is best-effort — it is fired after the payment commits and never rolls the payment back if delivery fails. The UI renders it by shape, so new notification types need no front-end change. Action links are restricted to same-origin relative paths and every DOM node is built with textContent / createElement, keeping the bell and toast CSP-safe. The SQLPage and HTMX front-ends render the identical typed payloads, so the two channels stay mirrored.

API-First — The Wallet Is Just the me_* Endpoints

There is no private "wallet protocol". Every button in the mobile app is a call to a self-scoped api.me_* PostgREST endpoint that an integrator can drive directly under a customer's JWT. That makes it straightforward to embed wallet movements into a partner app, a kiosk, or an automated disbursement run.

Base URL: https://api.ledgerflow.ai

Authentication is the platform-standard JWT flow: authenticate once, then send Authorization: Bearer <token> on every request. The token carries the customer's id and email claims, which the database uses for identity and notification targeting — the caller never supplies them as parameters.

API specification: https://swagger.ledgerflow.ai — every me_* endpoint, its parameters, and response schema is browsable without writing code. Because the endpoints are SECURITY DEFINER with an ownership gate, the same authorisation that protects the UI protects the API: a customer can only ever touch their own accounts.

Move Money — Send, Request, Top-up, Settle

-- Send (P2P or B2B). Fees are applied automatically
-- from the paying account's fee profile.
POST /rpc/me_send_payment
Authorization: Bearer <token>
Content-Type: application/json

{
  "payer_account_id_p": 4567,
  "payee_account_id_p": 8901,
  "amount_p": 100.00,
  "payer_narration_p": "Lunch",
  "payee_narration_p": "Thanks",
  "transaction_reference_p": "INV-002214"
}
-- Returns: { "transaction_id": ..., "error_message": null }

-- Request a payment from another customer
POST /rpc/me_request_payment
{ "payer_user_id_p": 222, "payee_account_id_p": 4567,
  "amount_p": 40.00, "payer_narration_p": "Share",
  "payee_narration_p": "Dinner", "transaction_reference_p": "SPLIT-7781" }

-- Top up (cash-in) and Settle (cash-out)
POST /rpc/me_load_funds
{ "wallet_account_id_p": 4567, "amount_p": 250.00, "narration_p": "Top up" }

POST /rpc/me_settle_funds
{ "wallet_account_id_p": 4567, "amount_p": 90.00, "narration_p": "Settle funds" }

Pay by QR & Read the Statement

-- Payee: mint an open (QR) request — returns a token + expiry
POST /rpc/me_request_open
Authorization: Bearer <token>
Content-Type: application/json

{ "payee_account_id_p": 4567, "amount_p": 25.00,
  "transaction_reference_p": "STALL-12", "payee_narration_p": "Coffee",
  "expires_minutes_p": 60 }
-- Returns: { "token": "...", "amount": 25.00, "currency": "USD",
--            "expires_at": "...", "error_message": null }

-- Payer: fetch the request, then fulfil it from your own account
POST /rpc/me_request_fetch     { "token_p": "<uuid>" }
POST /rpc/me_request_fulfill   { "token_p": "<uuid>", "payer_account_id_p": 9012 }

-- Statement (own accounts only; ownership-gated)
POST /rpc/me_statement
{ "account_id_p": 4567, "period_p": "ThisMonth" }
-- Returns dr/cr, running balance, reference, counterparty_name, …

-- Email a proof-of-payment PDF to the counterparty of a line
POST /rpc/me_send_proof_of_payment
{ "account_id_p": 4567, "transaction_id_p": 778899 }

All me_* calls reject any account the bearer does not own (HTTP 403).