The Mercury Settlement platform processes the complete lifecycle of merchant payouts — from acquiring raw switch dump files via SFTP, through reconciliation, MIS generation, dual-level Finance approval, payout transmission to banks, and finally merchant notification via Settlement Advice and VAT Invoice.
Each of these steps involves real money movement and regulatory accountability. Currently the outcome of each step is captured in isolated domain tables (sync_log, dump_files, settlement_mis, payout_batches, etc.) with no unified view across the full lifecycle. When an issue arises — a batch mismatch, a delayed payout, a missing VAT invoice — operators must query multiple tables or search through application logs to reconstruct what happened, when, and by whom.
The Settlement Event Logger solves this by maintaining a single, auditable timeline of every significant action across the entire settlement pipeline.
The event logger tracks 18 distinct events across 7 pipeline stages. The principle is to log only significant business milestones — not every function call or database row update.
| Stage | # | Event Type | Trigger | Actor Type |
|---|---|---|---|---|
| Transaction Sync | 1 | core_txn_sync | POS/QR sync API → Oban worker | Worker |
| File Ingestion | 2 | sftp_fetch | Daily scheduler 03:00 AM or manual trigger | Scheduler |
| File Ingestion | 3 | dump_file_processing | After SFTP fetch, per file | Worker |
| Reconciliation | 4 | reconciliation_run | After dump file processed | Worker |
| Reconciliation | 5 | recon_exception_created | During recon engine run | System |
| Reconciliation | 6 | recon_exception_released | Manual — user releases exception | User |
| Adjustments | 7 | adjustment_created | Manual — user creates adjustment | User |
| MIS & Approval | 8 | mis_generated | MIS generation worker at 07:00 AM | Scheduler |
| MIS & Approval | 9 | mis_l1_approved | Manual — Finance L1 user action | User |
| MIS & Approval | 10 | mis_l2_approved | Manual — Finance L2 user action | User |
| MIS & Approval | 11 | mis_rejected | Manual — any level rejection | User |
| Payout | 12 | payout_generated | After L2 approval → Oban worker | Worker |
| Payout | 13 | payout_transmitted | After payout generated → bank SFTP | Worker |
| Bank Confirmation | 14 | bank_confirmation_uploaded | Manual — Finance Ops CSV upload | User |
| Bank Confirmation | 15 | bank_confirmation_approved | Manual — Finance Ops approves batch | User |
| Bank Confirmation | 16 | core_transactions_updated | Cascade inside bank confirmation approval | System |
| Notification | 17 | settlement_advice_sent | Oban — SettlementAdviceWorker | Worker |
| Notification | 18 | vat_invoice_dispatched | Oban — VatInvoiceWorker | Worker |
settlement_event_log| Field | Purpose | Example |
|---|---|---|
event_type |
Machine-readable event identifier | mis_l2_approved |
entity_type |
Which domain table this event belongs to | settlement_mis, dump_file |
entity_id |
ID of the domain record (soft link — no FK constraint) | 42 |
actor_id |
User ID if human action; NULL for system events | 7 (Finance user), NULL (System) |
actor_name |
Display name — avoids JOIN to users table on every UI query | Sunny, Finance L2, Ysp.Scheduler |
actor_type |
Distinguishes manual vs automated origin — enables clean filtering | User, System, Scheduler, Worker, API |
status |
Outcome of the event | success, failed, partial, pending |
summary |
Human-readable one-line description — displayed directly in UI with no translation | MIS for 18-Jun-2026 approved by Finance L2 |
settlement_date |
Business date the event relates to — primary filter in UI | 2026-06-18 |
metadata |
JSONB payload with event-specific details — no schema change needed for new event types | {"total_merchants": 47, "net_payable": 58420.50} |
occurred_at |
Exact timestamp when the event happened | 2026-06-19 09:15:02 |
entity_id has no foreign key constraint. If a domain record is cleaned up, the event log row survives. The event log is an immutable history, not a relational reference.pending as a status value — Used when a user uploads a bank confirmation CSV. The upload event is logged as pending at that moment. The follow-up approval event is logged as success. This captures the full lifecycle including the gap between upload and approval.| Event | Metadata Payload |
|---|---|
mis_generated |
{"channel": "COMBINED", "total_merchants": 47, "total_transactions": 1320, "net_payable": 58420.50} |
mis_l1_approved |
{"notes": "Verified reconciliation", "previous_status": "pending", "new_status": "l1_approved"} |
sftp_fetch |
{"dump_date": "2026-06-18", "files_found": 2, "files_downloaded": 2, "files_skipped": 0} |
reconciliation_run |
{"dump_file_id": 42, "matched": 1197, "unmatched": 7, "exceptions_created": 7} |
payout_transmitted |
{"batch_id": 28, "merchant_count": 47, "total_amount": 58420.50, "bank": "ENBD", "method": "SFTP"} |
bank_confirmation_approved |
{"batch_id": 11, "filename": "bank_ack_20260619.csv", "successful": 47, "failed": 0} |
vat_invoice_dispatched |
{"invoice_id": 441, "invoice_number": "INV-2026-0441", "merchant_mid": "419926360000000", "amount": 62.00} |
| Any failure | {"reason": "SFTP connection timeout", "retry_attempt": 2, "oban_job_id": 1038} |
There are two types of triggers — system (Oban Workers) and user (Context Functions). In both cases the rule is the same: write to the event log after the operation completes, not before.
These events happen automatically with no user involved — SFTP fetch at 3am, MIS generation at 7am, payout transmission after L2 approval. The event log is updated inside the Oban worker's perform/1 function, after the operation result is known.
Example — MIS Generation Worker
Current code (unchanged):
With event logger added (only new lines added after existing logic):
Logger.info, the existing return values (:ok, {:error, reason}), and the existing business logic are all completely untouched. EventLogger.log/1 is added after the result — nothing that currently works can break.
These are events where a real user took an action in the UI. The event log is updated inside the context function, after the database update succeeds.
Example — MIS L1 Approval
Current code in context.ex:
With event logger added:
Repo.update() returns {:ok, ...}. A failed database update never creates a false success event in the log.
Many processes in the pipeline have both an automated path and a manual fallback. The event logger handles this naturally — every attempt, success or failure, auto or manual, gets its own row. Both the failure and the recovery are visible in the same timeline.
| Time | Event | Actor | Actor Type | Status | Summary |
|---|---|---|---|---|---|
| 03:00:11 | sftp_fetch |
Ysp.Scheduler | Scheduler | failed | SFTP fetch failed for 2026-06-18 — connection timeout |
| 09:14:33 | dump_file_upload |
Finance Ops | User | success | Manual upload: switch_settled_20260618.csv — 1,204 records |
| 09:16:01 | reconciliation_run |
ReconciliationWorker | Worker | success | Recon complete — 1,197 matched, 7 exceptions created |
Anyone looking at this date's event log immediately sees: SFTP auto-fetch failed at 3am, a human intervened at 9am, and the pipeline recovered. Without the event log, this 6-hour gap would only be visible by digging through Oban job history or server logs.
| Time | Event | Actor | Status | Summary |
|---|---|---|---|---|
| 03:15:22 | reconciliation_run |
ReconciliationWorker | failed | Recon failed for dump_file #42 — DB timeout |
| 03:16:45 | reconciliation_run |
ReconciliationWorker | success | Recon complete — 1,197 matched, 7 exceptions |
Oban retried automatically after 83 seconds. Both the failure and the success are logged. No manual intervention was needed, and the timeline shows the full picture.
This is the most important scenario because one user action (clicking Approve on a bank confirmation batch) triggers a chain of downstream updates across multiple tables and workers.
| Event | When | What It Records |
|---|---|---|
bank_confirmation_approved |
Immediately on approval click | batch_id, approved_by, successful_count, failed_count, total_amount |
core_transactions_updated |
Inside mark_transactions_paid per merchant |
merchant_mid, how many txns marked paid, bank_trnx_date set |
settlement_advice_sent |
When SettlementAdviceWorker completes | payout_item_id, merchant, amount, email recipient |
vat_invoice_dispatched |
When VatInvoiceWorker completes | invoice_number, merchant, amount |
| Time | Event | Actor | Type | Status | Summary |
|---|---|---|---|---|---|
| 14:18:44 | bank_confirmation_approved |
Finance Ops | User | success | Batch #11: 47 merchants confirmed, AED 58,420 |
| 14:18:44 | core_transactions_updated |
System | System | success | 342 txns marked paid — MID 419926360000000 |
| 14:18:44 | core_transactions_updated |
System | System | success | 218 txns marked paid — MID Mercury_CB6F6A5 |
| 14:18:45 | settlement_advice_sent |
SettlementAdviceWorker | Worker | success | Advice sent → MID 419926360000000, AED 1,240 |
| 14:18:45 | settlement_advice_sent |
SettlementAdviceWorker | Worker | success | Advice sent → MID Mercury_CB6F6A5, AED 890 |
| 14:18:46 | vat_invoice_dispatched |
VatInvoiceWorker | Worker | success | INV-2026-0441 → MID 419926360000000, AED 62 |
| 14:18:46 | vat_invoice_dispatched |
VatInvoiceWorker | Worker | success | INV-2026-0442 → MID Mercury_CB6F6A5, AED 44.50 |
This level of granularity means if a settlement advice email failed for one specific merchant, you see exactly which merchant and when — without searching Oban job tables or email logs.
The EventLogger.log/1 function is intentionally simple — it does one database insert and never raises an exception. A logging failure never crashes a worker or blocks a user action.
Oban emits a telemetry event [:oban, :job, :stop] every time any worker finishes — with the job struct (worker name, args) and outcome (:success or :failure). A single handler module intercepts all of these and maps them to event log entries.
Attached once in application.ex — zero changes to any worker file.
| Pros | Cons |
|---|---|
|
|
Add EventLogger.log(...) calls directly inside each worker's perform/1 function and each context function, immediately after the operation result is known.
| Pros | Cons |
|---|---|
|
|
Two design proposals were reviewed side by side. The table below compares them and gives a verdict on each field.
| Field | Proposal 1 | Proposal 2 | Verdict |
|---|---|---|---|
event_type |
string | VARCHAR | Same — adopt |
entity_type |
string | VARCHAR | Same — adopt |
entity_id |
integer | BIGINT | BIGINT preferred — safer for large tables |
actor_id |
integer, nullable | BIGINT NULL | Same — adopt |
actor_label |
Single field combining name + type e.g. "System (Scheduler)" | Split into actor_name + actor_type |
Proposal 2 wins — split is better for filtering |
actor_type |
Not present as separate field | User | System | Scheduler | Worker | API | Proposal 2 addition — adopt it |
status |
success | failed | partial | pending | success | failed | partial | Keep pending — needed for bank confirmation upload lifecycle |
summary |
string | TEXT — human-readable prose | Same principle — store readable prose, not event codes |
settlement_date |
date, nullable | DATE NULL | Same — adopt |
metadata |
jsonb | JSONB | Same — adopt |
occurred_at |
naive_datetime | TIMESTAMP | Same — adopt |
| Soft entity link | Explicit — no FK constraint on entity_id | Not mentioned | Keep — event log must survive domain record deletion |
Do not log every function call or database row update. Log only milestones that Finance, Operations, or Compliance would recognise as meaningful steps in the settlement lifecycle. The 18 events defined in Section 2 represent these milestones. This keeps the timeline clean and searchable.
Every log entry must answer:
event_type + summaryoccurred_atactor_name + actor_typestatus| Field | Example 1 | Example 2 |
|---|---|---|
| What | MIS for 18-Jun-2026 approved by Finance L2 | SFTP fetch failed for 2026-06-18 |
| When | 09:15:02 | 03:00:08 |
| Who | Finance L2 (User) | Ysp.Scheduler (Scheduler) |
| Outcome | Success | Failed — SFTP timeout |
The summary field stores readable prose, not codes. The UI displays it directly without any translation layer.
| Wrong | Correct |
|---|---|
mis_l2_approved | MIS for 18-Jun-2026 approved by Finance L2 |
payout_generated | Payout batch generated for 47 merchants, AED 58,420 |
sftp_fetch failed | SFTP fetch failed for 2026-06-18 — connection timeout |
Keep common information in columns (event_type, status, actor_name) and event-specific information in metadata JSONB. No schema change is required when a new event type is introduced. Examples of what goes in metadata: file names, transaction counts, amounts, notes, rejection reasons, exception IDs, bank references, invoice numbers.
A failure in EventLogger.log/1 is caught silently and written to the application log. It never propagates as an error to the calling worker or context function. The settlement pipeline must continue regardless of whether event logging succeeds.
A new "Event Log" menu item in the Settlement section of the admin panel, built as a Phoenix LiveView page.
| Time | Event | Actor | Type | Status | Summary |
|---|---|---|---|---|---|
| 03:00:11 | sftp_fetch |
Ysp.Scheduler | Scheduler | success | SFTP: 2 files downloaded for 2026-06-18 |
| 03:02:45 | dump_file_processing |
SwitchDumpProcessing | Worker | success | switch_settled_20260618.csv — 1,204 records processed |
| 03:04:12 | reconciliation_run |
ReconciliationWorker | Worker | success | Recon complete — 1,197 matched, 7 exceptions created |
| 03:04:13 | recon_exception_created |
System | System | partial | 7 reconciliation exceptions created for dump_file #42 |
| 07:00:43 | mis_generated |
Ysp.Scheduler | Scheduler | success | MIS generated for 2026-06-18 — 342 txns, AED 58,420 |
| 09:14:22 | mis_l1_approved |
Finance L1 | User | success | MIS for 2026-06-18 reviewed and approved at L1 |
| 11:32:05 | mis_l2_approved |
Finance L2 | User | success | MIS for 2026-06-18 approved at L2 — payout initiated |
| 11:32:06 | payout_generated |
PayoutTransmissionWorker | Worker | success | Payout batch #28 generated — 47 merchants, AED 58,420 |
| 11:32:09 | payout_transmitted |
PayoutTransmitter | Worker | success | Batch #28 transmitted to bank via SFTP |
| 14:05:31 | bank_confirmation_uploaded |
Finance Ops | User | pending | bank_ack_20260619.csv uploaded — 47 records, awaiting approval |
| 14:18:44 | bank_confirmation_approved |
Finance Ops | User | success | Batch #11: 47 merchants confirmed as paid, AED 58,420 |
| 14:18:44 | core_transactions_updated |
System | System | success | 342 core transactions marked paid — MID 419926360000000 |
| 14:18:45 | settlement_advice_sent |
SettlementAdviceWorker | Worker | success | Settlement advice sent → MID 419926360000000, AED 1,240 |
| 14:18:46 | vat_invoice_dispatched |
VatInvoiceWorker | Worker | success | INV-2026-0441 dispatched → MID 419926360000000, AED 62 |
mis_l2_approved event opens that MIS record)| # | Step | Work | Impact on Existing Code |
|---|---|---|---|
| 1 | Database migration | Create settlement_event_log table and indexes |
None |
| 2 | Ecto schema | Create SettlementCore.SettlementEventLog schema and changeset |
None — new file |
| 3 | EventLogger module | Create SettlementCore.EventLogger with log/1 |
None — new file |
| 4 | Wire system events | Add EventLogger.log/1 inside 13 Oban worker perform/1 functions |
Additive only — new lines after existing result handling |
| 5 | Wire user events | Add EventLogger.log/1 inside 4 context functions (l1_approve, l2_approve, reject, create_adjustment) |
Additive only |
| 6 | Wire cascade events | Add EventLogger.log/1 inside mark_transactions_paid in BankConfirmationService |
Additive only |
| 7 | LiveView page | Create EventLogLive.Index with filters, timeline, detail expand |
None — new file |
| 8 | Menu item | Add "Event Log" entry to MenuProvider |
Additive only — one new item in list |
| 9 | Permissions | Add settlement.event_log.view permission to roles seed |
Additive only |
The event log is a financial audit trail, not just an engineering debug tool. Finance, Compliance, and Audit teams will use it to verify amounts, counts, and timings for every settlement cycle. An event that says "MIS generated for 2026-06-18" without the transaction count, merchant count, and net payable amount is significantly less useful than one that says "MIS generated for 2026-06-18 — 342 txns across 47 merchants, AED 58,420 net payable."
That richer data only exists in the result struct (mis.total_transactions, mis.total_net_payable) which is available inside the worker after the operation completes — but not available to an external telemetry hook without an additional database query.
Why not Approach A (Oban Telemetry)?
Why Approach B is safe:
EventLogger.log/1 never raises — a logging failure is caught silently and never propagates to the worker