Activate two-person rules

The two-person rule is the single most important compliance control in the MFI Suite. Without it, a single corrupt officer can disburse a fictitious loan, lift a blacklist, and reverse the ledger entry in the same session. With it, every sensitive action needs two independent users — and the audit trail proves it.

What the engine does

The mfi_two_person module registers a generic Python hook that any other module can call via self.env['mfi.two.person'].require(rule_code, record, payload). The hook:

  1. Checks the rule definition in mfi.two.person.rule.
  2. If the action exceeds a threshold or matches a category, blocks the immediate commit and creates a mfi.two.person.request in state pending_concurrence.
  3. Emails / app-notifies the eligible approvers (filtered by group + branch).
  4. On concurrence, runs the original action with the original user as create_uid and the concurring user as concurrence_uid.
  5. Writes both legs to the audit log, immutable.

Shipped rules

Eleven rules ship with the suite. Review them and adjust thresholds to your institution's policy.

CodeTriggerThresholdDefault concurrer
LOAN_APPROVE_B1Loan approval band 1≤ KES 50KSenior Officer (peer)
LOAN_APPROVE_B2Loan approval band 2≤ KES 250KBranch Supervisor
LOAN_APPROVE_B3Loan approval band 3≤ KES 1MBranch Manager
LOAN_APPROVE_B4Loan approval band 4≤ KES 5MCredit Committee (3 of 5)
LOAN_WRITEOFFLoan write-offAnyCredit Committee + Audit
MEMBER_STATUSMember activation / blacklistAnyBranch Manager
CASH_OUTCash out from till> KES 100KSupervisor
VAULT_OPENVault openingAnyBranch Manager
GL_REVERSALJournal entry reversalAnyFinance Manager
FEE_OVERRIDELate fee waiver> KES 5KSenior Officer
RATE_OVERRIDERate change on running loanAnyBranch Manager
Approval rules list
The shipped rules in Configuration → Two-person rules. Toggle active off to disable a rule; click a row to edit it.

Configure a rule

Open MFI → Configuration → Two-person rules → LOAN_APPROVE_B2 → Edit:

  • Threshold expression: a Python expression evaluated on the record. Default for band 2 is record.approved_amount > 50000 and record.approved_amount <= 250000.
  • Concurrer domain: a domain on res.users. Default for band 2: [('groups_id', 'in', ref('mfi_base.group_supervisor').id), ('id', '!=', record.create_uid.id), ('x_allowed_branch_ids', 'in', record.branch_id.id)] — excludes the originator and restricts to same branch.
  • Quorum: 1 for most rules; 3 (of 5) for LOAN_APPROVE_B4 (Credit Committee).
  • Time-to-live: hours before the pending request auto-expires. Default 24.
  • Notification template: mail.template that fires to eligible concurrers.
Approval rule form
The rule form — threshold expression, concurrer domain, quorum, TTL, notification template, and the active switch.
# Sample custom rule — overriding band 2 to require Branch Manager (not Supervisor)
rule = self.env['mfi.two.person.rule'].browse(ref('LOAN_APPROVE_B2'))
rule.write({
    'concurrer_domain': "["
        "('groups_id', 'in', ref('mfi_base.group_branch_manager').id),"
        "('id', '!=', record.create_uid.id),"
        "('x_allowed_branch_ids', 'in', record.branch_id.id)"
    "]",
    'time_to_live_hours': 4,
})

The concurrence flow

  1. Officer clicks Approve on a KES 200K loan.
  2. Hook fires, creates a pending request, blocks the commit.
  3. Officer sees an in-app banner: 'Pending concurrence from Supervisor — request ID 4471'.
  4. Eligible supervisors receive an email and an app notification.
  5. Supervisor opens the request, reviews the loan record, clicks Concur or Reject.
  6. On Concur, the original action runs and the loan moves to state approved.
  7. On Reject, the loan returns to under_appraisal with a comment.
  8. On TTL expiry, the request auto-rejects.
My Pending Approvals queue
Where the concurrer lands — MFI → Operations → Approvals → My Pending Approvals. Each row is a request waiting for their action.
All approval requests
The full queue (manager view) — every pending, partial-quorum, concurred, rejected, vetoed, expired request.
Approval request form
One request opened — shows the original record, the requesting user, eligible concurrers, current state, and the Concur / Reject buttons.
Warning — The concurring supervisor MUST review the actual record before clicking Concur. A rubber-stamp concur is detectable by the audit: average concur-time < 60 seconds for high-value loans triggers a SOC 2 finding.

Credit Committee quorum

Band 4 (KES 1M–5M) requires 3-of-5 concurrence from the Credit Committee group. The engine waits until three independent users have concurred — partial concurs are visible to all committee members so they can see the running tally.

  • All five members receive the notification simultaneously.
  • Each member must log in independently — no shared sessions.
  • Each member can attach a comment (visible to others) before concurring.
  • Once the third concur posts, the loan auto-approves and the remaining two members see 'closed — quorum reached'.
  • Any committee member can issue a Veto, which blocks the loan regardless of partial concurs.
Credit Committee list
Credit Committees — the configured groups (Branch / Regional / Head Office), each with its members and quorum size.
Credit Committee meeting
A committee meeting — agenda, attendees, the loans under review, individual concur / veto votes, minutes.

Worked scenarios

Scenario — A Credit Committee veto blocks a politically-connected loan

Setting: Thursday afternoon. A KES 3M business loan application has reached Band 4 approval. Three of five committee members have concurred, but the fifth — Internal Audit's representative — vetoes.

CharacterRole
Jane WambuiBusiness owner, applicant
Peter OtienoLoan officer originator
Aisha HassanBranch Manager — first to concur
Florence AchiengFinance representative — second to concur
Kimani MwangiInternal Audit rep — vetoes

Timeline

  1. Day 1, 14:00: Peter completes appraisal, escalates to Band 4 approval. (mfi.loan state = under_appraisal_b4)
  2. Day 1, 14:05: Two-person engine notifies all 5 committee members. (5 mfi.two.person.request rows)
  3. Day 1, 14:15: Aisha reviews the appraisal and concurs. (Concurrence 1 of 3)
  4. Day 1, 14:32: Florence concurs after a 15-minute review. (Concurrence 2 of 3)
  5. Day 1, 14:48: Kimani reviews and notices Jane is the sister of an MFI board member — undisclosed. (Conflict flag)
  6. Day 1, 14:50: Kimani vetoes with comment 'undisclosed related-party — refer to insider lending policy'. (mfi.two.person.request state = vetoed)
  7. Day 1, 14:51: Loan auto-rejects; Peter is notified. (mfi.loan state = rejected)
  8. Day 1, 15:00: Aisha and Florence both receive the veto comment for their own learning. (Audit trail complete)
  9. Day 2, 09:00: Audit opens a related-party investigation; Jane is asked to disclose. (External)

Outcome — Insider lending caught by the two-person veto; reputational risk avoided; CBK DTM-7 return remains clean.

Reference

Fields on mfi.two.person.rule

FieldTypeNotes
codeCharUnique upper-case code
nameCharHuman-readable
model_idMany2oneir.model — the record type triggered
trigger_methodCharMethod name that fires the hook
threshold_exprTextPython expression on record
concurrer_domainTextDomain on res.users
quorumIntegerNumber of concurrers required
time_to_live_hoursIntegerDefault 24
notification_template_idMany2onemail.template
activeBooleanDisable without deleting

States on mfi.two.person.request

StateSet byMeaning
pending_concurrenceAuto on createAwaiting first concur
partial_quorumAutoSome concurs, quorum not yet reached
concurredAutoQuorum reached, original action runs
rejectedConcurrerConcurrer clicked Reject
vetoedCommittee memberHard block — overrides any partial quorum
expiredCronTTL passed without quorum

Troubleshooting

SymptomLikely causeFix
Officer can approve a KES 200K loan with no concurrenceRule LOAN_APPROVE_B2 archived or threshold wrongSettings → Configuration → Two-person rules → unarchive and reset threshold.
Concur button missing for an eligible supervisorConcurrer domain excludes the user (wrong branch or group)Edit rule's concurrer_domain; test with self.env['mfi.two.person.rule'].browse(X).get_eligible_concurrers(record).
Same user can originate AND concurDomain missing the ('id', '!=', record.create_uid.id) clauseAdd the clause and save.
Credit Committee quorum 3-of-5 reaches but loan doesn't auto-approveOne concurrence posted by a user not in the committee groupEngine ignores it; ask three actual committee members to concur.
Two-person request auto-expired before concurrer saw itTTL too short, or email notification failedIncrease TTL to 24h; check mail.message delivery status; re-trigger the action.

See also

Was this page helpful?