| bank_account:gtb_main | −10000000 | the cash is real and has arrived |
| suspense:tier_cap_held | +10000000 | held pending upgrade or return |
| Σ | 0 | the invariant holds; the customer is told what to do |
Round sixteen: “who is this customer, and what may they do?”
Every round so far has treated an account as an account. Real banks do not: a tier-1 wallet and a corporate current account are different objects with different limits, different permissions and different rules about what may be taken from them and by whom. This round builds the entitlement layer, and then spends half its length on the most misunderstood mechanism in Nigerian banking, the post-no-debit.
The pressure: not every account is the same account
“A customer signs up with just a phone number. Another uploads a BVN and a utility bill. A third is a registered company with three signatories. All of them have accounts. What is different?”
Two independent dimensions, and conflating them is the most common modelling error in this area.
KYC tier how much we know about who they are
→ decides limits and permitted activity
→ a property of the customer
account type what the account is for
→ decides features, interest, charges, mandates
→ a property of the account
one customer, one tier, many accounts, several types.
modelling tier on the account is the mistake that produces
a customer who is tier 3 on one account and tier 1 on another.- Every customer has a KYC tier, and every account has a type.
- Limits are resolved from both, deterministically, with the stricter winning.
- A tier can be upgraded, and rarely downgraded, with the effect on existing balances defined.
- Business accounts support mandates: who may instruct, and how many are required.
- An account can be placed on post-no-debit while still receiving credits.
- A service can register a claim on future credits, and have it satisfied automatically.
- Limit resolution adds under 3 ms to the posting path.
- A restriction takes effect within 2 seconds of being applied, everywhere.
- A credit that satisfies a claim is applied within 5 seconds of landing.
- No restriction may ever cause the ledger to disagree with itself.
KYC tiers: what each tier buys the customer
Tiering exists because regulators want financial inclusion and anti-money-laundering controls at the same time. The compromise is graduated: less identity, less capability.
| Tier | Requires | Single txn | Daily | Max balance |
|---|---|---|---|---|
| Tier 1 minimal | Phone number, name, photo. No document verification | ₦50,000 | ₦50,000 | ₦300,000 |
| Tier 2 simplified | + BVN, address, valid ID | ₦200,000 | ₦500,000 | ₦500,000 |
| Tier 3 full | + verified address, reference, full documentation | No cap | No cap | No cap |
Account types: personal, business, savings, current
| Type | Interest | Overdraft | Withdrawals | Distinctive rule |
|---|---|---|---|---|
| Savings | Yes, credit interest | No | Limited free per month | Exceeding the free count forfeits that period's interest in many banks |
| Current | Usually none | Yes | Unlimited | Attracts account maintenance charges |
| Wallet | None | No | Unlimited | Often tier-capped, and not a bank account in the legal sense |
| Fixed deposit | Higher, fixed term | No | Locked | Early withdrawal forfeits accrued interest |
| Domiciliary | Low or none | Rarely | FX rules apply | Foreign currency, with its own regulatory reporting |
| Corporate current | None | Yes, larger | Unlimited | Mandates and signatories. Chapter 205 |
| Escrow / trust | Varies | No | Conditional | Funds released only on a defined condition |
-- the account type is a reference to POLICY, not a column of booleans. -- adding a product later must not mean an ALTER TABLE. CREATE TABLE account_types ( code TEXT PRIMARY KEY, -- savings | current | wallet | fd … display_name TEXT NOT NULL, -- capabilities, as data earns_interest BOOLEAN NOT NULL, interest_bps INT, allows_overdraft BOOLEAN NOT NULL, free_withdrawals INT, -- null = unlimited maintenance_fee_minor BIGINT, requires_mandate BOOLEAN NOT NULL DEFAULT FALSE, -- which KYC tiers may hold this type at all min_kyc_tier INT NOT NULL DEFAULT 1, effective_from TIMESTAMPTZ NOT NULL, effective_to TIMESTAMPTZ );
The tier × type matrix, and why it is data
Three tiers and seven types is twenty-one combinations, and not all of them are legal. Encoding that in conditionals is how a codebase becomes unmaintainable.
| Wallet | Savings | Current | Corporate | Domiciliary | |
|---|---|---|---|---|---|
| Tier 1 | ✓ capped | ✓ capped | ✗ | ✗ | ✗ |
| Tier 2 | ✓ capped | ✓ capped | ✓ | ✗ | ✓ limited |
| Tier 3 | ✓ | ✓ | ✓ | ✓ if incorporated | ✓ |
- Regulators change it. A circular can move a tier-1 cap overnight, and it must not need a release.
- It is time-varying. A dispute about a 2024 transaction must be evaluated against the 2024 matrix, exactly like Part 7's compliance policies.
- Compliance staff own it, and they should be able to read and prepare changes without engineering.
- It is auditable. "Why was this allowed?" resolves to a specific row with a specific validity period.
CREATE TABLE tier_type_policy ( kyc_tier INT NOT NULL, account_type TEXT NOT NULL REFERENCES account_types(code), permitted BOOLEAN NOT NULL, -- nulls mean "no limit from this dimension" max_single_minor BIGINT, max_daily_minor BIGINT, max_monthly_minor BIGINT, max_balance_minor BIGINT, -- which transaction kinds this combination may perform at all permitted_kinds TEXT[], -- the regulatory instrument this row implements, for audit source_ref TEXT, effective_from TIMESTAMPTZ NOT NULL, effective_to TIMESTAMPTZ, PRIMARY KEY (kyc_tier, account_type, effective_from) );
Resolving limits when both apply
A tier-2 customer with a current account has limits from the tier, from the type, from their own self-imposed settings, and possibly from a fraud restriction. They must resolve to one number, deterministically.
// the rule: the MOST RESTRICTIVE wins, always, and we record WHICH.
// "declined" is unhelpful. "declined: your tier-2 daily limit" is actionable.
interface ResolvedLimit {
effectiveMinor: bigint;
bindingSource: 'kyc_tier' | 'account_type' | 'customer' | 'risk';
allSources: { source: string; limitMinor: bigint | null }[];
}
function resolveLimit(ctx: Ctx, dim: LimitDimension): ResolvedLimit {
const candidates = [
{ source: 'kyc_tier', limitMinor: ctx.tierPolicy[dim] },
{ source: 'account_type', limitMinor: ctx.typePolicy[dim] },
{ source: 'customer', limitMinor: ctx.customerPrefs[dim] },
{ source: 'risk', limitMinor: ctx.riskRestriction?.[dim] }
].filter(c => c.limitMinor != null);
if (!candidates.length) return { effectiveMinor: UNLIMITED, ... };
// minimum, not the last one set, not a precedence order.
const binding = candidates.reduce((a, b) =>
b.limitMinor! < a.limitMinor! ? b : a);
return { effectiveMinor: binding.limitMinor!,
bindingSource: binding.source as any, allSources: candidates };
}Tier upgrades, downgrades, and grandfathering
- Documents submitted and verified, and the tier changes.
- Limits widen immediately, which needs no special handling.
- Anything held in tier-cap suspense is released to the customer's account.
- The change is event-sourced, so the limit cache and the card authoriser both learn within seconds.
| suspense:tier_cap_held | −10000000 | suspense clears |
| wallet A | +10000000 | customer can finally spend it |
| Σ | 0 | the money was never lost, only parked |
- Triggered by document expiry, a failed re-verification, or a regulatory instruction.
- The customer may already hold more than the new tier's cap allows.
- You cannot confiscate the excess, and you cannot let them exceed the cap indefinitely.
- The resolution is grandfathering with a runway: the balance is permitted, new credits are capped, and the customer is given a defined period to re-verify.
- Only at the end of that period does the account become credit-restricted, which is the Part 9 state doing useful work.
Business accounts: mandates, signatories, dual control
A corporate account is not owned by a person, so "is this the account holder?" is the wrong question. The right one is "is this instruction properly authorised under the mandate?"
CREATE TABLE mandates ( id UUID PRIMARY KEY, account_id UUID NOT NULL, -- "any one", "any two", "A and either B or C", or by amount band rule_type TEXT NOT NULL, -- any_one | any_n | specific_combination required_count INT, -- larger amounts need more signatures. the common real-world case. threshold_minor BIGINT, -- this rule applies above this amount effective_from TIMESTAMPTZ NOT NULL, effective_to TIMESTAMPTZ ); CREATE TABLE signatories ( mandate_id UUID NOT NULL REFERENCES mandates(id), customer_id UUID NOT NULL, -- class A and class B signatories, for "one of each" rules signatory_class TEXT, can_initiate BOOLEAN NOT NULL DEFAULT TRUE, can_approve BOOLEAN NOT NULL DEFAULT TRUE, PRIMARY KEY (mandate_id, customer_id) );
a mandate turns one instruction into a multi-step approval flow:
initiated by signatory A → state: pending_approval
approved by signatory B → state: authorised
posted → state: completed
the funds are HELD at initiation, not at approval.
otherwise the balance can be spent while an approval is pending,
and the approved instruction then fails for insufficient funds.- Initiator cannot self-approve. A signatory who can do both must still not satisfy a two-signature mandate alone.
- Mandate changes are themselves mandated, usually requiring more signatures than a payment.
- Bulk payment files, where one approval covers hundreds of lines, with the file hash as the thing approved.
- A visible audit trail, because corporate customers are audited too and will ask for it.
PND: post-no-debit, and its four varieties
The instruction that stops money leaving while allowing it to arrive. Part 9
introduced debit_restricted as a state; here is what it actually
means operationally, because "PND" covers four different things.
blocked
rest free
kind
review
-- PND is a restriction with a DIRECTION and a SCOPE, extending the -- Part 9 restriction model rather than replacing it. ALTER TABLE account_restrictions ADD COLUMN direction TEXT NOT NULL DEFAULT 'debit', -- debit|credit|both ADD COLUMN amount_minor BIGINT, -- null = the whole balance ADD COLUMN scoped_kinds TEXT[], -- null = all kinds ADD COLUMN mode TEXT NOT NULL DEFAULT 'block'; -- block | review -- and the check on the posting path is direction-aware: -- a CREDIT entry is unaffected by a debit-direction restriction. -- a DEBIT entry is refused, or queued, per mode.
Receiving while restricted: credit-in, debit-out
The asymmetry that makes PND useful, and the one that surprises people implementing it for the first time.
account on full PND, balance ₦40,000
salary of ₦300,000 arrives → ACCEPTED
balance becomes ₦340,000
customer attempts to send ₦5,000 → REFUSED
the account grows and nothing can leave.
why accept at all?
· the sender has already parted with the money
· rejecting creates a return leg and a second reconciliation problem
· for a fraud case, letting funds accumulate is often the point:
it preserves them for recovery to victims
· the customer's obligations, such as a salary arriving, are not
the thing being restricted
- Applied when the account is a suspected mule collection point, so every incoming credit is a further victim.
- The incoming payment is returned to the sender rather than refused silently, which requires a return leg on the originating rail.
- Almost never combined with debit restriction on a live customer, because a fully frozen account with returned credits strands a person completely.
- Usually accompanies a closure process rather than an investigation.
// the check, direction-aware, on the posting path. note it inspects
// the SIGN of the entry rather than the operation's name, so a fee
// leg and a transfer leg are treated identically and correctly.
function checkRestrictions(e: EntrySpec, r: Restriction[]): Decision {
const isDebit = e.amountMinor < 0n;
for (const x of r) {
if (x.direction === 'credit' && isDebit) continue; // not our concern
if (x.direction === 'debit' && !isDebit) continue; // credits pass a PND
if (x.scopedKinds && !x.scopedKinds.includes(e.kind)) continue;
return x.mode === 'review'
? { action: 'queue_for_review', restrictionId: x.id }
: { action: 'refuse', reasonCode: x.reasonCode, restrictionId: x.id };
}
return { action: 'allow' };
}Auto-debit on credit: the standing sweep instruction
A service needs money from an account that does not currently have it. Rather than polling, it registers an instruction that fires the moment a credit lands. Part 14 built this for loan sweeps; here it is generalised.
| Approach | Latency | Cost | Verdict |
|---|---|---|---|
| Poll the balance | Polling interval | Enormous. 20M accounts × frequency | Rejected. It does not scale and it is always late |
| Scheduled retry | Hours | Low | Useful as a backstop, useless as a primary |
| Event-driven on credit | Seconds | Proportional to actual credits | Chosen. The Part 4 event log already carries every credit |
| Synchronous hook in the posting path | Immediate | Adds latency to every credit | Rejected. It couples an unrelated service to the money path |
CREATE TABLE credit_claims ( id UUID PRIMARY KEY, account_id UUID NOT NULL, claimant TEXT NOT NULL, -- which service registered it amount_minor BIGINT NOT NULL, -- what is owed collected_minor BIGINT NOT NULL DEFAULT 0, -- lower number = collected first. statutory claims outrank commercial ones. priority INT NOT NULL, -- never take the customer below this. the line between recovery and harm. protected_floor_minor BIGINT NOT NULL DEFAULT 0, -- partial collection allowed, or all-or-nothing? allow_partial BOOLEAN NOT NULL DEFAULT TRUE, -- the customer agreed to this, and when consent_ref TEXT NOT NULL, expires_at TIMESTAMPTZ, state TEXT NOT NULL -- active | satisfied | cancelled | expired ); CREATE INDEX claims_active ON credit_claims (account_id, priority) WHERE state = 'active';
consent_ref should be impossible to create.
This is one of those cases where the schema encodes a legal requirement, and
making the column NOT NULL is the cheapest compliance control you will
ever ship.Posting ahead: claims against future credits
The variant you described: a service posts ahead, declaring it needs an amount before the money exists. There are two ways to implement it, and they have very different accounting consequences.
| Claim registry | Negative posting | |
|---|---|---|
| Mechanism | A row in credit_claims. No entries written | Post the debit immediately, letting the balance go negative |
| Ledger effect before funds arrive | None. The invariant is untouched | The account is overdrawn, so the bank has lent money |
| Shows in the balance | Only as reduced available balance | As a negative ledger balance |
| Accounting | Nothing to recognise until collected | A receivable exists, and may need provisioning |
| Regulatory | Clean | An unauthorised overdraft unless a facility exists |
| Verdict | Default. Use unless there is a reason not to | Only with an explicit overdraft facility, per Part 5 |
| wallet A (balance 0) | −500000 | balance is now −₦5,000 |
| service_receivable | +500000 | the service has been paid from money that does not exist |
| Σ | 0 | the invariant holds and the bank has lent ₦5,000 it never agreed to |
- An overdraft facility exists and this transaction kind may draw it, per Part 5 chapter 58.
- A card clearing arrives for more than was authorised, which we must post regardless, per Part 8 chapter 92.
- A reversal of a credit that has already been spent, which is the ACH return case from Part 6 chapter 71.
- In all three, the negative balance is a known, accounted-for exposure rather than an accident of ordering.
Ordering when several claims compete for one credit
₦50,000 arrives against ₦180,000 of claims from four different services. Who gets paid, and in what order? This is a policy question that must be encoded, because the alternative is whichever consumer happened to process the event first.
credit: ₦50,000 protected floor: ₦5,000
allocatable: ₦45,000
p10 court-ordered garnishment ₦30,000 → ₦30,000 collected
p20 loan repayment due ₦80,000 → ₦15,000 partial
p30 card repayment ₦40,000 → nothing
p40 subscription ₦30,000 → nothing
customer keeps: ₦5,000- p0-p9 statutory. Court orders, tax garnishment. Legally must be first, and no commercial claim may outrank them.
- p10-p19 secured. Claims against specific pledged funds.
- p20-p29 lending. Loan and overdraft repayment, where non-payment damages the customer's credit standing.
- p30-p39 bank fees. Our own charges. Deliberately below lending, because taking our fee before their loan repayment worsens their position and ours.
- p40+ discretionary. Subscriptions, savings plans, anything the customer opted into for convenience.
// allocation must be ATOMIC across all claims, or two consumers
// processing the same credit event could each allocate the full amount.
await db.transaction(async tx => {
// one idempotency key per credit event, so a redelivery is a no-op
const key = `claims-${creditEntryId}`;
const claims = await tx.query(`
SELECT * FROM credit_claims
WHERE account_id = $1 AND state = 'active'
ORDER BY priority, id
FOR UPDATE -- lock them all, in a stable order
`, [accountId]);
let available = creditAmount - protectedFloor;
const entries: EntrySpec[] = [];
for (const c of claims) {
if (available <= 0n) break;
const outstanding = c.amount_minor - c.collected_minor;
const take = c.allow_partial ? min(available, outstanding)
: (available >= outstanding ? outstanding : 0n);
if (take === 0n) continue;
entries.push(...claimEntries(c, take));
available -= take;
}
if (entries.length) await ledger.post({ idempotencyKey: key, entries });
});ORDER BY priority, id FOR UPDATE locks claims in a stable, total
order. Without the id tiebreaker, two credits arriving
simultaneously on accounts sharing claims could acquire locks in different orders
and deadlock, which is exactly the Part 2 chapter 27 problem appearing in a new
place.Backlog clearing, and why it is not a queue
When a restriction is lifted, or funds finally arrive, there is a backlog of things that could not happen. Processing it naively causes a second incident.
- Some items expired. A card authorisation held for three days should not suddenly execute when the PND lifts.
- Some items are now invalid. The beneficiary account may have closed, or the customer may have cancelled.
- Order by arrival is not order by priority. A statutory garnishment queued after a subscription must still be collected first.
- The funds may not cover everything, so it is an allocation problem rather than a replay.
- Replaying all at once creates a thundering herd against the ledger, and against the notification system, which then texts the customer forty times.
wrong: on restriction lift → replay the queue in arrival order
right: on restriction lift →
1. expire everything past its validity
2. revalidate the rest against current state
3. re-prioritise, because arrival order is not priority order
4. allocate against the actual available balance
5. rate limit execution, so the ledger and the customer survive it
6. summarise notifications into one digest, per Part 12
- It has its own state machine:
pending → validating → allocating → executing → complete. - It is idempotent, keyed on the restriction-lift event, so a retry does not double-execute.
- It is observable: backlog depth and age per account are metrics, because a backlog that never clears is a stuck customer.
- It is interruptible, because a new restriction may be applied mid-clear and must take effect immediately.
Restriction precedence: who wins when rules conflict
An account can simultaneously carry a court lien, a fraud hold, a tier cap, a customer-requested freeze and an expired document flag. They must resolve deterministically.
| Level | Source | May be lifted by | Overrides |
|---|---|---|---|
| 1 | Court order / regulator | The issuing authority only | Everything. No internal override exists |
| 2 | Sanctions match | Compliance, after clearing | Everything below |
| 3 | Confirmed fraud | Fraud analyst, four-eyes | Everything below |
| 4 | Automated risk | Expires automatically, or an analyst | Below only |
| 5 | KYC / document expiry | The customer, by re-verifying | Below only |
| 6 | Customer self-imposed | The customer, with a cooling-off period | Nothing |
// the resolution rule: ANY blocking restriction blocks. precedence
// decides who may LIFT it, not whether it applies.
function effectiveRestriction(all: Restriction[]): Effective {
const active = all.filter(isActive);
if (!active.length) return { blocked: false };
// the MOST restrictive applies; the LOWEST level number decides
// who is allowed to remove it.
const governing = active.reduce((a, b) => b.level < a.level ? b : a);
return {
blocked: true,
reasonCode: governing.reasonCode,
liftableBy: governing.liftableBy,
// ALL of them, because lifting one does not unblock the account
// and support needs to see the full picture.
allActive: active
};
}Support tooling and the four-eyes boundary
Everything in this part creates support actions, and support tooling is the largest insider-risk surface in a bank, because its whole purpose is to let staff change things about customer money.
| Action | Who | Control |
|---|---|---|
| View account state and history | Any agent | Logged, and rate-limited per agent to detect bulk browsing |
| Apply a restriction | Agent | Logged with a reason code. Restricting is low risk |
| Lift a restriction | Senior agent | Four-eyes above a threshold. This is the dangerous direction |
| Raise a limit | Supervisor | Four-eyes, time-boxed, and it expires automatically |
| Cancel a claim | Supervisor | Four-eyes, because it forgives money owed |
| Post a manual adjustment | Finance only | Four-eyes always, capped in value, reconciled daily |
| Upgrade a KYC tier | Compliance | Requires document evidence attached to the action |
- Who, by verified identity rather than a shared login.
- What, as a structured action rather than free text.
- Why, as a reason code plus a ticket reference.
- Approved by whom, where four-eyes applied.
- Into the append-only audit log from Part 13, which is itself monitored for anomalies by Part 9.
Sketch v16: the entitlement layer
What changed, and the cost accepted
| Change | Driven by | Cost accepted |
|---|---|---|
| Tier on the customer, type on the account | They are genuinely independent dimensions | Two lookups on the posting path, cached |
| Tier × type matrix as versioned data | Regulators change it without notice | A policy store to govern, like Part 7's |
| Limits resolve to the minimum | A precedence order lets a wide policy override a narrow one | Must report the binding source, or declines are unactionable |
| Tier-cap suspense account | A credit that breaches a cap cannot simply be refused | Another suspense balance to age and monitor |
| PND with direction, scope and mode | Four varieties, and a full freeze is usually disproportionate | Restriction checks become direction-aware on every entry |
| Claims registry, not negative posting | A negative balance without a facility is an unauthorised loan | Claims must be allocated atomically on every credit |
| Priority bands with a protected floor | Statutory claims outrank commercial ones, and customers must eat | A policy decision that has to be owned by someone senior |
| Backlog clearing as a state machine | Replaying a queue causes a second incident | Expiry, revalidation, re-prioritisation and rate limiting |
| Asymmetric four-eyes | Lifting a restriction is far riskier than applying one | Slower support resolution on the lifting path, deliberately |