Migration Sources
A Migration Source is the contract between the legacy system and MFI Suite — it says 'this file shape, from this system, mapped to these production fields, is acceptable.' Sources are registered once per tenant migration and edited freely until the first Commit, after which they become immutable.

Anatomy of a source
Every Source has a header (who, what, where, file format) and a Mapping Studio (legacy field -> MFI Suite field, with an optional Python transform). The Mapping Studio is where the bulk of the work happens.
- Header: Name, Legacy System, Template, File Format (CSV/XLSX/Fixed-width/SQL), Encoding (UTF-8/Windows-1252/Latin-1), Date format, Decimal separator, Owner (Migration Lead), Reviewer (Data Steward).
- Mapping Studio: a table of target_field, legacy_field, transform_python, default_value, required, sample_value. The sample_value column is auto-populated from the first 100 rows of a test upload.
- Business rules: per-source toggles such as 'Treat negative balance as overdraft', 'Snap deposit dates to month-end', 'Bucket loans older than 5 years into Historical'.
- Test runner: upload a 100-row sample. The Source shows green only after a sample passes validation.
Registering a Loan Performer source — worked example

Loan Performer 8.x is the most common legacy system in Kenyan SACCO migrations. Here is the exact recipe.
- Open MFI Suite -> Migration -> Sources -> New.
- Name: 'Tujikomboe SACCO — Members extract — Nov 2025'.
- Legacy System: 'Loan Performer 8.x'.
- Template: 'src.loanperformer.v8' (loads the default field map).
- File Format: CSV, Encoding: Windows-1252 (Loan Performer's default), Date format: dd/mm/yyyy, Decimal separator: comma.
- Owner: Jane Wambui (Migration Lead). Reviewer: Peter Otieno (Data Steward).
- Open Mapping Studio. The template prefills 41 fields. Verify these manually because Loan Performer installations are customised:
- MemberNo -> source_key (required), Surname + ' ' + FirstName -> full_name (concat transform), IDNo -> id_number, Tel1 -> phone (strip spaces and prefix +254), DOB -> date_of_birth, JoinDate -> joined_on, Branch -> branch_code, Status -> status (transform: 'A' -> active, 'D' -> dormant, 'C' -> closed).
- Upload a 100-row test extract. The Test Runner shows 98 green, 2 red — the 2 reds are members with a Tel1 of 'NIL'.
- Add a transform to coerce 'NIL' to empty string. Re-run test. 100 green. Source is now 'Verified' (green badge).
File formats and their gotchas
MFI Suite accepts five file formats. Each has Kenya-specific gotchas worth flagging at source-registration time, not at validation time.
| Format | Max size | Common gotchas in Kenya |
|---|---|---|
| CSV (UTF-8) | 500 MB per file, unlimited rows | Loan Performer exports as Windows-1252; Co-op exports as UTF-8 with BOM. Pick the right encoding or member names with accents (Wanjirũ, Akinyi) will corrupt. |
| CSV (Windows-1252) | 500 MB | Default for Loan Performer. Date format almost always dd/mm/yyyy. |
| XLSX | 120 MB, 1,048,576 rows per sheet | Excel silently coerces 10-digit member numbers to scientific notation; always import as text. Multiple sheets allowed — one source per sheet. |
| Fixed-width TXT | 500 MB | Used by some older Bankers Realm exports. Specify column offsets in the Mapping Studio. |
| SQL extract (via SFTP drop) | 2 GB compressed | For tenants where IT can run SELECT INTO OUTFILE; importer reads the file from /opt/staging/migration/inbox/ |
Mapping Studio transforms
Each mapping line accepts an optional Python transform. The expression has access to value (the raw legacy value) and row (the entire legacy row as a dict). The result must be a string for text targets, a date for date targets, or a float for monetary targets.
# Normalise Kenyan phone numbers to +254 format
value = (value or '').strip().replace(' ', '').replace('-', '')
if value.startswith('0'):
value = '+254' + value[1:]
elif value.startswith('254'):
value = '+' + value
elif value.startswith('7') and len(value) == 9:
value = '+254' + value
result = value or False
# ----
# Concatenate Loan Performer surname + first name into full_name
result = (row.get('Surname', '') + ' ' + row.get('FirstName', '')).strip().title()
# ----
# Coerce Loan Performer status flag to MFI Suite enum
map_ = {'A': 'active', 'D': 'dormant', 'C': 'closed', 'B': 'blacklisted'}
result = map_.get(value, 'active')
Freezing and versioning a source
Once the Data Steward signs off on a source mapping, click 'Freeze Mapping'. The mapping becomes read-only and a version number (v1, v2, ...) is stamped on every staging row that uses it. If the legacy system later produces a slightly different extract, the operator does not edit the frozen source — they clone it ('Copy as new version'), tweak, and freeze a v2. This keeps lineage clean.
Worked scenarios
Scenario — Tujikomboe SACCO registers six sources for go-live
| Character | Role |
|---|---|
| Jane Wambui | Migration Lead (BridgeERP consultant) |
| Peter Otieno | Data Steward (Tujikomboe Operations Manager) |
| Aisha Hassan | Tujikomboe IT (extracts the CSVs from Loan Performer) |
| Florence Achieng | Finance Reviewer (Tujikomboe Accountant) |
Timeline
- Day 1, 09:00: Jane creates Source 'Tujikomboe — Members — v1' using template src.loanperformer.v8. (Source state: draft.)
- Day 1, 11:30: Aisha drops a 100-row sample CSV via the Test Runner. (Test report: 87 green, 13 red — all 13 reds are members with Tel1='NIL'.)
- Day 1, 14:00: Jane adds a Python transform on phone: coerce 'NIL' to False. (Sample re-runs: 100 green.)
- Day 2, 10:00: Jane registers five more sources: Savings Accounts, Loans, Loan Schedules, Historical Transactions (savings), GL Opening Balances. (Six sources, all draft.)
- Day 3, 09:30: Peter spot-checks the Mapping Studio for each source and adds two more transforms (date-of-birth NULL coerced to 1900-01-01 sentinel, branch code 'HQ' coerced to 'NAIROBI-CBD'). (All six sources reach 100% green on samples.)
- Day 3, 16:00: Peter signs off; Jane clicks 'Freeze Mapping' on all six. (Sources move to state 'frozen-v1'. Staging tables ready to receive.)
- Day 5, 08:00: Aisha discovers Loan Performer was upgraded mid-week and now exports loan_outstanding as a separate column. (Jane clones to v2, adds the new column, freezes. v1 remains for audit.)
Outcome — Six frozen sources, fully mapped, with v1 and v2 lineage. Staging uploads can now begin. Total elapsed: 5 working days, well inside the 2-week budget.
Reference
Source states
A Source moves through six states. Only Frozen sources can feed a Migration Run.
| State | Meaning | Edit allowed? | Can feed a Run? |
|---|---|---|---|
| draft | Initial state, mapping in progress | Yes (Lead) | No |
| sample_pending | Awaiting test upload | Yes (Lead) | No |
| sample_failed | Test upload has unresolved errors | Yes (Lead) | No |
| sample_passed | 100% of test rows validate | Yes (Lead) | No |
| frozen | Mapping locked by Lead after Steward sign-off | No | Yes |
| superseded | Cloned into a newer version | No | No (read-only audit copy) |
Default mapping for src.loanperformer.v8 (Members)
Every line is editable. Required lines cannot be removed without breaking validation.
| Target field | Legacy field | Transform | Required |
|---|---|---|---|
| source_key | MemberNo | value.strip() | Yes |
| full_name | Surname + FirstName | concat & title-case | Yes |
| id_number | IDNo | value.strip().lstrip('0') | Yes |
| phone | Tel1 | +254 normaliser | No |
| date_of_birth | DOB | parse dd/mm/yyyy | No |
| joined_on | JoinDate | parse dd/mm/yyyy | Yes |
| branch_code | Branch | value.upper() | Yes |
| status | Status | enum map A/D/C/B | Yes |
| kra_pin | KRA_PIN | value.upper().strip() | No |
| gender | Sex | M->male, F->female | No |
| next_of_kin_name | NokName | value.title() | No |
| next_of_kin_phone | NokTel | +254 normaliser | No |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Test Runner says 'Encoding mismatch' on every row. | Loan Performer wrote the file as Windows-1252 but the Source is set to UTF-8. | Open the Source, change Encoding to Windows-1252, re-run test. |
| Transform fires successfully on the sample but a real upload shows TRANSFORM_FAIL. | Real data contains an edge case the 100-row sample did not (e.g. a member with no DOB). | Wrap the transform body in a try/except that returns False on failure, re-freeze as v2. |
| Cannot freeze the Source — button stays disabled. | At least one required mapping line is empty, or no Data Steward has been assigned. | Open the Mapping Studio and check that every Required=Yes row has a legacy_field set; ensure Reviewer is filled. |
| Member names with diacritics (Wanjirũ, Akinyi) appear as 'Wanjir~' after upload. | Wrong encoding chosen at Source registration. | Reopen the source CSV in a text editor that displays the encoding; usually Loan Performer is Windows-1252 and Co-op is UTF-8. Pick correctly. |

