| wallet A | −5006250 | amount + fee + VAT + duty |
| settle_suspense:nibss | +5000000 | the transfer itself |
| fee_income:transfer:41 | +1000 | revenue, ours |
| vat_payable | +75 | pass-through, owed to the revenue service |
| stamp_duty_payable | +5000 | pass-through, owed to the state |
| nibss_fee_expense | −350 | cost, ours |
| nibss_payable | +350 | owed to NIBSS at settlement |
| Σ | 0 | seven entries, one journal, every category distinguishable |
Round seventeen: “fees, the company books, and who pays for what”
Seventeen rounds of moving customer money, and almost nothing about the bank's own. Yet every transaction generates revenue we earn and costs we incur, the two are settled on different schedules with different counterparties, and the difference between them is the entire business. This round builds the revenue side and the company books, and shows why keeping customer funds and company funds separable is a design property rather than an accounting convention.
The pressure: revenue is not a number in a spreadsheet
“Your CFO asks what a transfer actually earns us, net of everything we pay for it. Then the auditor asks you to prove that no customer money is funding our operations. Where do both answers come from?”
Both from the same place, which is the point of this round. If fees are booked properly, unit economics and safeguarding are the same query shaped differently.
one ₦50,000 transfer over NIP:
fee charged to the customer +₦10.00
NIBSS switching fee we pay −₦3.50
stamp duty collected for the state ₦0.00 net, we are an agent
VAT on our fee, collected ₦0.00 net, we are an agent
SMS alert we send −₦4.00
infrastructure, amortised −₦0.30
contribution ₦2.20
the SMS costs more than the switching fee, which is the kind of
fact that only exists if every cost is posted to the ledger.- Every fee we charge is priced, posted and attributable to a transaction.
- Every cost we incur is posted, including ones billed to us monthly in arrears.
- Amounts we collect on behalf of others never touch our revenue.
- Customer funds and company funds are separable by query, at any instant.
- Profit per transaction, per product and per channel is derived, not tracked.
- Fee computation adds under 2 ms to the posting path.
- The safeguarding check runs continuously with a hard pass or fail.
- Every reported revenue figure is reproducible months later, per Part 11.
- A pricing change is configuration, effective-dated, never a release.
A taxonomy of fees in a bank
Four categories, distinguished by who ultimately keeps the money. That question, rather than who pays it, is what determines the accounting.
| Category | Example | Who keeps it | Our books |
|---|---|---|---|
| Revenue | Transfer fee, card maintenance, FX spread, interchange received | Us | Income. Hits the P&L |
| Cost | NIBSS switching, scheme fees, SMS, PSP charges | A supplier | Expense. Hits the P&L |
| Pass-through | Stamp duty, VAT, levies collected for a government | A third party | Liability. Never revenue, never expense |
| Contra | Waivers, promotional discounts, goodwill credits | Nobody; forgone revenue | Reduces income. Booked separately, not hidden |
Fees we charge: pricing, waivers, and tiers
Pricing is data, effective-dated, and resolved exactly like the limits in Part 18. The same pattern, for the same reason: it changes on someone else's timetable.
CREATE TABLE fee_schedules ( id UUID PRIMARY KEY, fee_code TEXT NOT NULL, -- transfer.nip | card.maintenance -- what this schedule applies to. null = any. account_type TEXT, kyc_tier INT, channel TEXT, -- app | ussd | pos | api currency CHAR(3) NOT NULL, -- the band this row prices. bands must tile the range with no gaps. min_amount_minor BIGINT NOT NULL DEFAULT 0, max_amount_minor BIGINT, -- flat + percentage + cap is the general form. most rows use one. flat_minor BIGINT NOT NULL DEFAULT 0, rate_bps INT NOT NULL DEFAULT 0, cap_minor BIGINT, -- the regulatory instrument that constrains this price, if any regulatory_ref TEXT, effective_from TIMESTAMPTZ NOT NULL, effective_to TIMESTAMPTZ );
the CBN transfer fee bands, as an example of why bands exist:
≤ ₦5,000 → ₦10
₦5,001 to ₦50,000 → ₦25
> ₦50,000 → ₦50
these are regulatory MAXIMA, not suggestions.
so the pricing engine must be able to prove it never charged
above the cap in force on that date, which is why regulatory_ref
and effective dating are on the row.- A waived fee is not the absence of a fee. It is a fee charged and a contra entry that offsets it.
- Booking it that way makes forgone revenue visible, so the business can see what promotions actually cost.
- Simply not charging makes the promotion free in the accounts and invisible in analysis, which is how a campaign runs for a year without anyone knowing its cost.
- It also keeps the customer's statement honest: they can see the fee and the waiver.
| wallet A | −1000 | fee charged as normal |
| fee_income:transfer:41 | +1000 | revenue recognised |
| fee_waiver_contra | −1000 | forgone revenue, visible |
| wallet A | +1000 | customer refunded, net effect zero for them |
| Σ | 0 | the promotion's cost is now a queryable number |
Fees we pay: interchange, scheme, provider, rail
The costs side, which is usually worse instrumented than the revenue side and therefore where margin quietly disappears.
| Cost | Billed | Known at transaction time? | Posting approach |
|---|---|---|---|
| NIBSS switching | Per transaction, settled daily | Yes, it is a published tariff | Post at transaction time |
| Card scheme fees | Monthly, itemised | Approximately | Accrue an estimate, true up on the invoice |
| PSP collection fee | Netted at settlement | Yes | Post at transaction time |
| SMS and notification | Monthly, per message | Yes, per message | Post per message, per Part 12's cost attribution |
| Interchange paid | Netted at card settlement | Yes, by scheme table | Post at clearing |
| Cloud and infrastructure | Monthly | No | Accrue monthly, allocate by driver for unit economics |
- At transaction time, post an estimated cost to expense and to an accrual liability.
- When the invoice arrives, post the difference between the accrual and the actual.
- Settle the invoice against the liability.
- Monitor the true-up variance: a consistently wrong estimate means the model needs fixing, and a sudden change means the supplier changed their pricing.
| scheme_fee_expense | −25000000 | we under-accrued by ₦250,000 |
| scheme_fee_accrual | +25000000 | liability topped up to the invoiced amount |
| Σ | 0 | a 6% variance, which is a metric worth watching |
Fee posting: when, and against which account
- Inline, same journal as the transaction. The default. The fee and the movement that caused it are atomically linked and reconcile together.
- Deferred, same day. For fees whose amount depends on something not yet known, such as a card fee that depends on the final cleared amount.
- Periodic, batch. Account maintenance, monthly card fees, minimum balance charges. The Part 5 accrual batch, with a date-scoped idempotency key.
Which account bears the fee
| Scenario | Charged to | Note |
|---|---|---|
| Standard transfer | The sender | Gross model from Part 5: recipient gets the full amount |
| Merchant collection | The merchant | Netted from settlement, so the payer sees the round number |
| Card purchase | Nobody visible | We receive interchange. The cost is on the acquiring side |
| Failed transaction | Nobody | Charging for a failure is a complaint and often a regulatory issue |
| Reversed transaction | Depends on policy | Must be explicit. If the reversal is our fault, refund the fee |
| Corporate bulk file | The corporate, per line or per file | A contractual term, and both models exist |
// fee reversal follows the transaction reversal, by policy, and it is
// ALWAYS a new journal linked to the original. never an edit.
async function reverseWithFee(original: Journal, reason: Reason) {
const refundFee = policy.refundFeeOn(reason);
// our fault → refund. customer changed their mind → usually not.
await ledger.post({
idempotencyKey: `rev-${original.id}`,
kind: JournalKind.TRANSFER_REVERSAL,
reversesJournalId: original.id,
entries: [
...reverseEntriesOf(original, { includeFee: refundFee })
]
});
}Recognition: earned now, deferred, or amortised
When money arrives and when it becomes revenue are different questions, and conflating them misstates the P&L.
| Fee | Cash arrives | Revenue recognised | Why |
|---|---|---|---|
| Transfer fee | Now | Now | The service is complete at the moment of transfer |
| Card annual fee | Now | Over 12 months | We owe 12 months of service, so 11 months is a liability |
| Loan origination fee | At disbursement | Over the loan term | Under the effective interest method in most standards |
| Account maintenance | Monthly | Monthly | Charged for the period just served |
| Interchange | At settlement | At transaction | Earned when the transaction occurs, received later |
| wallet A | −600000 | customer pays the full year now |
| deferred_income:card_annual | +600000 | a liability: we owe 12 months of service |
| Σ | 0 | note that no revenue has been recognised yet |
| deferred_income:card_annual | −50000 | liability reduces |
| fee_income:card_annual | +50000 | revenue recognised for the month served |
| Σ | 0 | keyed cardfee-recog-{cardId}-{yearMonth}, so a re-run is a no-op |
splitExact
function exists for precisely this.Pass-through versus absorbed cost
pass-through we collect it and owe it onward
customer pays ₦50 duty → stamp_duty_payable +₦50
we remit ₦50 to the state → stamp_duty_payable −₦50
net effect on revenue: zero, correctly
absorbed we pay it and do not recover it
we pay NIBSS ₦3.50 → nibss_fee_expense −₦3.50
net effect on revenue: a real cost
recovered we pay it and charge it on
expense −₦3.50 and income +₦10.00
net: +₦6.50 margin, both sides visible- Do we keep it? Yes → income. No → liability.
- Did we choose to incur it? Yes → expense. It was imposed on the customer → pass-through.
- Is there a statutory remittance deadline? Then it is definitely a liability, and a late one carries penalties.
- Would the regulator consider it ours? The safest test, and the one that settles arguments.
The company chart of accounts, properly
Part 5 introduced GL classes. Here is what the bank's own accounts actually look like, and the structure matters because reporting is generated from it.
Customer funds versus company funds
The distinction the auditor cares about most, and it must be answerable by query rather than by explanation.
customer funds money we hold that belongs to someone else
= Σ customer deposits (2100)
+ Σ suspense balances (2900)
+ Σ pass-through payables (2200, 2210)
company funds money that is ours
= assets − liabilities − customer funds
= equity + retained earnings
the safeguarding assertion:
liquid assets held ≥ customer funds owed, at all times
if this ever fails, the bank is using customer money to
fund itself, which is the failure regulators exist to prevent.-- the single most important query in the company books. it is the
-- Part 10 machinery pointed at solvency rather than at drift.
WITH owed AS (
SELECT e.currency, SUM(e.amount) AS customer_funds
FROM entries e JOIN accounts a ON a.id = e.account_id
WHERE a.gl_code LIKE '21%' -- customer deposits
OR a.gl_code LIKE '29%' -- suspense: somebody's money
OR a.gl_code LIKE '22%' -- pass-through payables
GROUP BY e.currency
), held AS (
SELECT e.currency, -SUM(e.amount) AS liquid_assets
FROM entries e JOIN accounts a ON a.id = e.account_id
WHERE a.gl_code LIKE '11%' OR a.gl_code LIKE '12%'
GROUP BY e.currency
)
SELECT o.currency, h.liquid_assets, o.customer_funds,
h.liquid_assets - o.customer_funds AS buffer
FROM owed o JOIN held h USING (currency)
WHERE h.liquid_assets < o.customer_funds; -- must return ZERO rowsSafeguarding, and proving segregation daily
- Customer funds sit in a designated account at a separate institution, legally identified as client money.
- That account is not used for operational expenses, payroll, or anything of ours.
- The balance is reconciled daily against our sum of customer obligations.
- A shortfall must be funded immediately from company money, and reported.
- An excess is also a problem, because company money sitting in a client account is commingling in the other direction.
the daily assertion, both directions:
segregated account balance ₦4,821,340,000
sum of customer obligations ₦4,821,336,500
──────────
buffer ₦3,500
buffer < 0 → SHORTFALL. fund it today. report it.
buffer large → commingling. sweep the excess out.
a small deliberate buffer is normal and is itself policy.Reconciling user funds: the two-sided check
- Internal consistency. Every journal sums to zero per currency. Part 10, continuous.
- Sum of wallets. Σ customer deposit accounts equals the customer-funds figure used everywhere else. Catches classification errors.
- Wallets versus the bank. Customer obligations versus the segregated account. The safeguarding check.
- Wallets versus rails. What we believe settled versus what NIBSS, the schemes and the PSPs say. Part 10's external matching.
- Wallets versus statements shown. What the customer sees equals what the ledger says. Catches serving-layer drift.
-- layer 5: sample and compare. cheap, and it catches cache and
-- projection bugs that every other reconciliation is blind to.
async function servingDriftCheck(sampleSize = 5000) {
const accounts = await sampleRandomAccounts(sampleSize);
const drifts = [];
for (const a of accounts) {
const served = await bigtable.currentBalance(a.id); // what they see
const computed = await ledger.balanceFromEntries(a.id); // the truth
if (served !== computed) drifts.push({ a, served, computed });
}
// any drift at all is a bug. it is not a tolerance.
if (drifts.length) await alerts.page('serving.balance_drift', { drifts });
}Reconciling company accounts and internal transfers
Company accounts are reconciled differently from customer accounts, because their counterparty is usually an invoice or a statement rather than a rail.
| Company account | Reconciled against | Frequency |
|---|---|---|
| Fee income | Transaction count × applicable tariff, recomputed independently | Daily |
| Rail fee expense | The rail's own billing file | Daily or monthly |
| Scheme fee accrual | The monthly invoice | Monthly true-up |
| Interchange income | Scheme settlement reports | Daily |
| Stamp duty payable | Remittance advice from the state | On remittance |
| Nostro accounts | Correspondent bank statements | Daily |
| FX position | Treasury's own position records | Intraday |
- Moving money between two company accounts is still a journal with balanced entries, never an adjustment.
- It requires four-eyes, because it is the classic path for internal misappropriation.
- It must carry a reason code and a reference, because "internal transfer" with no narrative is exactly what an auditor flags.
- Transfers between customer-funds accounts and company-funds accounts are the highest-risk operation in the bank and should require finance approval, not engineering.
Unit economics: profit per transaction, derived
The payoff for posting every revenue and cost line. Because they all carry the journal id, unit economics is a join rather than a model.
-- contribution per transaction: every income and expense entry that
-- shares a journal_id with the transaction, summed.
SELECT j.kind, j.metadata->>'channel' AS channel,
COUNT(DISTINCT j.id) AS txn_count,
SUM(CASE WHEN a.gl_code LIKE '4%' THEN e.amount ELSE 0 END) AS income,
SUM(CASE WHEN a.gl_code LIKE '5%' THEN e.amount ELSE 0 END) AS expense,
SUM(CASE WHEN a.gl_code LIKE '4%' OR a.gl_code LIKE '5%'
THEN e.amount ELSE 0 END) AS contribution
FROM journal j
JOIN entries e ON e.journal_id = j.id
JOIN accounts a ON a.id = e.account_id
WHERE j.created_at >= $from AND j.created_at < $to
GROUP BY 1, 2
ORDER BY contribution; -- the loss-makers first. always look here.| Product | Income | Direct cost | Contribution | What it says |
|---|---|---|---|---|
| Transfer, app | ₦25.00 | ₦7.80 | ₦17.20 | The core earner |
| Transfer, USSD | ₦25.00 | ₦24.10 | ₦0.90 | Session charges nearly erase it |
| Card purchase | ₦120.00 | ₦34.00 | ₦86.00 | Why banks push cards |
| Bill payment | ₦0.00 | ₦11.20 | −₦11.20 | A loss leader. Is that deliberate? |
| Balance enquiry, USSD | ₦6.98 | ₦6.98 | ₦0.00 | Exactly break-even by regulation |
VAT, withholding, and stamp duty on the ledger
| Tax | Basis | Our role | Ledger treatment |
|---|---|---|---|
| VAT on fees | A percentage of our fee | Collecting agent | Liability on collection, cleared on remittance |
| Stamp duty | Flat, on qualifying credits above a threshold | Collecting agent | Liability, remitted per schedule |
| Withholding tax | On interest paid to customers | Withholding agent | Deducted from the customer's interest, held as a liability |
| Company income tax | On our profit | Taxpayer | An expense and a liability, computed periodically |
| interest_expense | −100000 | our full cost is still ₦1,000 |
| wallet A | +90000 | the customer receives the net |
| withholding_tax_payable | +10000 | we owe this to the revenue service |
| Σ | 0 | the customer's tax certificate is generated from this entry |
- A separate liability account per tax type. Netting them makes remittance reconciliation impossible and each has its own deadline.
- Never recognise collected tax as income, even briefly. The moment it lands it is a liability.
- Store the rate that was applied, not just the amount, because rates change and a 2024 transaction must be explicable under the 2024 rate. Same reasoning as Part 7's stored FX rates.
Month-end: accruals, provisions, and the P&L
- Cut off at a declared timestamp, and freeze it.
- Complete accruals: costs incurred but not invoiced, income earned but not received.
- Release deferred income for the month, per chapter 220.
- Compute provisions: expected credit loss on loans, and a provision for open disputes.
- Revalue FX positions at month-end rates, posting unrealised gain or loss.
- Run the trial balance and confirm it proves out per currency.
- Generate the P&L and balance sheet from the GL classes.
- Finance reviews and signs, or the close is held open with named exceptions.
- Freeze the reported figures, per Part 10 chapter 120.
the P&L, entirely derived from GL classes:
income (4000) ₦1,240,000,000
less waivers (4900, contra) −₦38,000,000
──────────────
net income ₦1,202,000,000
expenses (5000) −₦810,000,000
──────────────
profit before tax ₦392,000,000
no profit table. no separate reporting database. one query over
the same entries that moved the money, which is the dividend from
round one that has now paid out for the last time.Fee disputes, refunds, and goodwill credits
| Case | Treatment | Booked to |
|---|---|---|
| Fee charged in error | Reverse it fully | Contra against the original income account |
| Fee correct, customer unhappy | Goodwill credit, not a reversal | A separate goodwill expense account |
| Duplicate charge | Reverse the duplicate | Contra, and investigate the cause |
| Service failed, fee charged | Refund automatically | Contra, and it should never have happened |
| Regulatory refund order | Refund, possibly with interest | Contra plus a penalty expense |
- Idempotent, keyed on the original journal, so a support agent clicking twice refunds once.
- Bounded: a refund may never exceed what was charged, enforced server-side.
- Attributed: who approved it and under which reason code, per Part 18's four-eyes rules.
- Rate-limited per agent, because refunds are a classic insider-abuse vector and the Part 9 monitoring should see the pattern.
What finance actually asks for, and why
| The ask | What they need underneath | Where it comes from |
|---|---|---|
| "Daily revenue by product" | Income accounts, grouped by journal kind | Gold table, Part 11 |
| "Why was yesterday down 12%?" | Drill from the aggregate to individual journals | Lineage, Part 15 chapter 188 |
| "Prove the trial balance" | Per currency, per GL class, summing to zero | Part 10 chapter 113 |
| "What do we owe NIBSS today?" | The payable account balance, live | A ledger balance, Part 6 |
| "Are client funds segregated?" | The safeguarding assertion, continuously | Chapter 224 |
| "Cost per transaction by channel" | Expense entries joined on journal metadata | Chapter 227 |
| "Re-run last March's return" | Byte-identical output | Four-way pinning, Part 11 chapter 131 |
- Numbers that do not change when the same report is re-run.
- Drill-down that reaches individual transactions, because an aggregate nobody can explain is useless in an audit.
- Advance warning of changes that will move a number, such as a new fee or a reclassification.
- A named owner for every GL account, so a question has an addressee.
- Self-service, so routine questions do not require an engineer.
Sketch v17: the revenue and books layer
What changed, and the cost accepted
| Change | Driven by | Cost accepted |
|---|---|---|
| Pricing as effective-dated data | Regulatory caps change without notice | Another policy store, and band coverage must be validated |
| Fees inline in the transaction journal | A fee posted separately can be orphaned | The posting call carries more entries |
| Waivers booked as contra | Forgone revenue must be visible | Four entries instead of none for a waived fee |
| Costs posted per transaction | Unit economics is otherwise guesswork | Accrual and true-up machinery for monthly-billed costs |
| Pass-through as liabilities | Collected tax is not revenue | A liability account and a remittance cycle per tax type |
| Deferred income with a release schedule | Cash timing is not revenue timing | A monthly batch that must unwind to exactly zero |
| Continuous safeguarding assertion | A shortfall is an event, not a month-end finding | It joins the correctness dashboard at the same severity |
| Independent fee recomputation | Summing entries cannot detect a pricing bug | A second implementation of pricing, deliberately |