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:
- Checks the rule definition in
mfi.two.person.rule. - If the action exceeds a threshold or matches a category, blocks the immediate commit and creates a
mfi.two.person.requestin statepending_concurrence. - Emails / app-notifies the eligible approvers (filtered by group + branch).
- On concurrence, runs the original action with the original user as
create_uidand the concurring user asconcurrence_uid. - 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.
| Code | Trigger | Threshold | Default concurrer |
|---|---|---|---|
| LOAN_APPROVE_B1 | Loan approval band 1 | ≤ KES 50K | Senior Officer (peer) |
| LOAN_APPROVE_B2 | Loan approval band 2 | ≤ KES 250K | Branch Supervisor |
| LOAN_APPROVE_B3 | Loan approval band 3 | ≤ KES 1M | Branch Manager |
| LOAN_APPROVE_B4 | Loan approval band 4 | ≤ KES 5M | Credit Committee (3 of 5) |
| LOAN_WRITEOFF | Loan write-off | Any | Credit Committee + Audit |
| MEMBER_STATUS | Member activation / blacklist | Any | Branch Manager |
| CASH_OUT | Cash out from till | > KES 100K | Supervisor |
| VAULT_OPEN | Vault opening | Any | Branch Manager |
| GL_REVERSAL | Journal entry reversal | Any | Finance Manager |
| FEE_OVERRIDE | Late fee waiver | > KES 5K | Senior Officer |
| RATE_OVERRIDE | Rate change on running loan | Any | Branch Manager |

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.

# 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
- Officer clicks Approve on a KES 200K loan.
- Hook fires, creates a pending request, blocks the commit.
- Officer sees an in-app banner: 'Pending concurrence from Supervisor — request ID 4471'.
- Eligible supervisors receive an email and an app notification.
- Supervisor opens the request, reviews the loan record, clicks Concur or Reject.
- On Concur, the original action runs and the loan moves to state
approved. - On Reject, the loan returns to
under_appraisalwith a comment. - On TTL expiry, the request auto-rejects.



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.


Worked scenarios
Scenario — A Credit Committee veto blocks a politically-connected loan
| Character | Role |
|---|---|
| Jane Wambui | Business owner, applicant |
| Peter Otieno | Loan officer originator |
| Aisha Hassan | Branch Manager — first to concur |
| Florence Achieng | Finance representative — second to concur |
| Kimani Mwangi | Internal Audit rep — vetoes |
Timeline
- Day 1, 14:00: Peter completes appraisal, escalates to Band 4 approval. (mfi.loan state = under_appraisal_b4)
- Day 1, 14:05: Two-person engine notifies all 5 committee members. (5 mfi.two.person.request rows)
- Day 1, 14:15: Aisha reviews the appraisal and concurs. (Concurrence 1 of 3)
- Day 1, 14:32: Florence concurs after a 15-minute review. (Concurrence 2 of 3)
- Day 1, 14:48: Kimani reviews and notices Jane is the sister of an MFI board member — undisclosed. (Conflict flag)
- Day 1, 14:50: Kimani vetoes with comment 'undisclosed related-party — refer to insider lending policy'. (mfi.two.person.request state = vetoed)
- Day 1, 14:51: Loan auto-rejects; Peter is notified. (mfi.loan state = rejected)
- Day 1, 15:00: Aisha and Florence both receive the veto comment for their own learning. (Audit trail complete)
- 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
| Field | Type | Notes |
|---|---|---|
| code | Char | Unique upper-case code |
| name | Char | Human-readable |
| model_id | Many2one | ir.model — the record type triggered |
| trigger_method | Char | Method name that fires the hook |
| threshold_expr | Text | Python expression on record |
| concurrer_domain | Text | Domain on res.users |
| quorum | Integer | Number of concurrers required |
| time_to_live_hours | Integer | Default 24 |
| notification_template_id | Many2one | mail.template |
| active | Boolean | Disable without deleting |
States on mfi.two.person.request
| State | Set by | Meaning |
|---|---|---|
| pending_concurrence | Auto on create | Awaiting first concur |
| partial_quorum | Auto | Some concurs, quorum not yet reached |
| concurred | Auto | Quorum reached, original action runs |
| rejected | Concurrer | Concurrer clicked Reject |
| vetoed | Committee member | Hard block — overrides any partial quorum |
| expired | Cron | TTL passed without quorum |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Officer can approve a KES 200K loan with no concurrence | Rule LOAN_APPROVE_B2 archived or threshold wrong | Settings → Configuration → Two-person rules → unarchive and reset threshold. |
| Concur button missing for an eligible supervisor | Concurrer 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 concur | Domain missing the ('id', '!=', record.create_uid.id) clause | Add the clause and save. |
| Credit Committee quorum 3-of-5 reaches but loan doesn't auto-approve | One concurrence posted by a user not in the committee group | Engine ignores it; ask three actual committee members to concur. |
| Two-person request auto-expired before concurrer saw it | TTL too short, or email notification failed | Increase TTL to 24h; check mail.message delivery status; re-trigger the action. |

