Write one rule in plain terms
Name the account, currency, action, time window, and limit. Say whether a pending transfer reserves part of the limit, and when a failed or reversed transfer gives that room back. A rule such as “₦50,000 per day” is incomplete until the team decides which clock defines a day and which channels share the count.
Use a server-side decision for every channel. The mobile app may display the remaining amount, but it cannot be the source of truth. OWASP’s transaction authorization guide says the server must enforce transaction checks and must reject skipped steps.
Run the cross-channel matrix
| Test | Pass result | Record to check |
|---|---|---|
| Spend ₦30,000 in the app, then ₦25,000 by USSD | Second transfer is blocked | Shared account counter |
| Send two ₦30,000 API requests at once | At most one is accepted | Committed transfer records |
| Change device after a transfer | Remaining limit stays the same | Account ID, not device ID |
| Reverse a failed transfer | Limit room returns once under policy | Reversal and counter entries |
| Call an old API version | Same limit applies | Authorization decision log |
Test races at the decision point
Two requests can both read “₦50,000 left” before either one writes. If both succeed, the app can spend twice the intended amount. Reproduce this with synchronized test requests, then inspect committed transfers rather than relying on HTTP responses. The limit check and reservation should be one atomic database operation or use a transaction method that gives the same guarantee.
Also test a retry after a timeout. A retry of one transfer must carry the same operation ID and return the first result. A new transfer must get a new ID and face the remaining limit. This keeps retry safety separate from the limit rule.
Check exceptions and staff actions
Some products let staff raise a customer limit or release a held transfer. Test who can request the change, who approves it, when it takes effect, and whether the customer is told. An old approval must not authorize a larger amount after the details change. Review support tools and batch jobs because they often use a different route from the app.
Keep a trace that shows account ID, channel, old total, new reservation, policy version, decision, and final transfer state. Do not put full customer details or secrets in that log. The test passes when the same account rule holds across the app, API, USSD, and staff path, including concurrent calls.
Recover a counter without restoring room twice
Use PAY-14 with a fixed policy window: the limit is 50,000 minor units, a completed app transfer used 30,000, and a USSD request asks for 25,000. Deny it before the provider call because 55,000 exceeds the limit. Keep the customer identity stable across channels. Store the policy time zone; changing a profile time zone must not reset the allowance.
Crash after reserving limit room but before submitting the transfer. The recovery worker must find the operation ID and pending intent, not reserve the amount again. On a final provider failure, release that operation’s reservation once. On a timeout, keep it pending. At midnight, close the old window under its stored boundaries. A late callback must update the old operation rather than move its use into a new day. Test policy timezone changes as a controlled migration with an effective time.
PostgreSQL’s isolation guide describes concurrent update behavior. Verify that the shared counter and transfer intent commit together, then compare counters with funded transfer records after recovery. Repair a mismatch through an audited operation rather than resetting the whole customer total.
Related controls
Pair this matrix with wallet balance concurrency tests and safe transfer retries. For a product-wide test plan, see the fintech security review.