Akshay Mohan is a Senior Software Engineer at PayGlocal, where he works on building reliable, scalable technology for modern payment experiences. He writes about software engineering, payments infrastructure, product development, and the technologies powering seamless digital commerce.
A merchant should be able to look at any transaction and see exactly what happened to it: the rate it was converted at, every fee that was taken out and when, the point at which it settled. Not a final number with a status label next to it. The actual sequence of events, in order, unaltered.
Most payment systems cannot promise that, and it is usually not for lack of trying. It is a consequence of how they store state. If a transaction's record is a row that gets updated in place as it moves through its lifecycle, by the time it reaches "settled," everything that happened along the way has been overwritten. The system knows where the transaction ended up. It cannot tell you how it got there.
This is the problem we built our transaction timeline to solve, and the fix is not a new dashboard. It is a different idea of what a transaction record is supposed to be.
📌TL;DR
•An append-only transaction log records every event - FX rate locks, fees, settlements, retries, and corrections - without overwriting previous information.
•Instead of storing only the latest status, the system derives the current state from the complete event history, preserving exactly how a transaction reached that point.
•Using the same event log for merchant visibility and internal debugging creates a single source of truth, making every step of a transaction traceable and transparent.
A conventional payment record is a single row that gets overwritten as the transaction moves through its lifecycle, so once it settles, the history of how it got there is gone. We instead treat a transaction as an append-only log of events: the FX rate lock, each fee deduction, the settlement, each written once and never changed. Current state is computed by folding the events together, but the full ordered history is always there. That is what lets a merchant see every step, in true order, with corrections shown as new events rather than silent rewrites.
Why a status field cannot give you a timeline
Picture a typical multi-currency transaction. The buyer pays in one currency. Somewhere in the pipeline, that amount gets converted at a specific rate, locked at a specific moment. Fees get deducted, sometimes more than once, at different stages. Eventually the transaction settles and a payout is triggered. Along the way there may be a retry, a delay, a rate that had to be re-checked.
Each of those is a distinct event with its own timestamp, its own inputs, its own outcome. A conventional design stores this as a single row per transaction, with fields like status, amount, fx_rate, settlement_date. Every time something happens, the row gets updated: the status flips from pending to converted to settled, the fields get overwritten with their latest values.
That works fine as long as nobody asks what happened in between. The moment a merchant asks why the settled amount is lower than expected, or a support engineer needs to know whether the FX rate was locked before or after a retry, the row cannot answer. It only remembers the last thing that happened to it. Everything before that has been quietly discarded by the act of updating it.
You cannot build a timeline out of a record that only keeps its current state. A timeline needs history, and an updated-in-place row has none.
The insight: Store what happened, not what is true right now
The fix is to stop treating a transaction as a row that changes and start treating it as a sequence of things that happened to it.
Every meaningful step, the FX rate lock, each fee deduction, the settlement event, gets written as its own record: what happened, when, and with what inputs. None of these records are ever updated or deleted. Once the FX conversion event is written, it stays exactly as it was written, forever. If something later needs correcting, that correction is written as a new event, not as an edit to the old one.
This is an append-only log. The current state of a transaction, the thing a status field used to store directly, becomes something you compute by reading the log from the start: fold all the events for a transaction together and you get where it stands right now. But the log itself, the full ordered history, never goes away and never changes shape underneath you.
The shift is small to describe and large in effect. A mutable row can only tell you what is true at this instant. An append-only log can tell you what was true at every instant, including this one.
One transaction, six append-only events, in order
The trap: Logging events is not the same as making them trustworthy
The obvious next move, once you decide to log events instead of overwriting a row, is to just start writing log entries whenever something happens in the pipeline. This is where a well-intentioned design quietly stops being a timeline and goes back to being a pile of loosely related records.
A few things have to be true for an append-only log to actually earn the word "timeline," and they are easy to get wrong.
Ordering has to be real, not assumed. FX conversion, fee deduction, and settlement can be handled by different services, on different schedules, sometimes with retries that fire out of arrival order. If events are ordered only by the time they happen to land in a database, a retried FX lock can appear to happen after the settlement it actually preceded. Every event needs a position in its transaction's sequence that reflects what genuinely happened first, not what was written first.
Append-only has to be enforced, not just intended. If the storage layer still allows an update or a delete on an existing event, someone eventually will use it, whether to "fix" a bad record or clean up a retry, and the moment that happens the log is no longer a reliable history. The guarantee has to be structural: the only operation available on a past event is reading it.
A correction is a new event, never a patched one. If the settlement amount was calculated wrong and needs fixing, the fix is a new event that says so, timestamped after the original, not a rewrite of the original settlement record. The timeline should show the mistake and the correction, both, in order. That is more honest than a timeline that shows only the corrected number and pretends the first calculation never happened.
The architecture
Every service in the pipeline, FX, fees, settlement, writes to the same append-only log rather than to a shared mutable transaction row. The log is the source of truth. A transaction's "current status" is a read-time computation over its events, not a field anyone writes to directly. This sounds like a small inversion, but it removes an entire category of bug: there is no code path anywhere that can silently lose history, because there is no path that writes to anything other than the end of the log.
The write path only appends, the read path only derives
Because both the merchant timeline and our own internal debugging view are built by reading the exact same log, there is no second, separate "explanation" that a merchant sees while engineers look at something else. Everyone is looking at the same history.
What Merchants Actually Get
The FX rate is not a mystery. The timeline shows precisely when the rate was locked, which removes most of the "why is my settled amount different from what I expected" conversations before they start.
Fees are itemized as events, not folded into a final number. If a fee is deducted at two different points in the lifecycle, both show up individually, in order, rather than arriving as a single line labeled "fees."
Settlement and payout are traceable, not just eventual. A merchant can see the settlement event and, if a payout retried or was delayed, see that too, rather than experiencing an unexplained gap between "settled" and "money arrived."
Nothing gets rewritten after the fact. If we correct something, the correction is visible as its own step. There is no version of the timeline where a past mistake quietly disappears.
Where this is honestly still hard
We would rather say this plainly than let the design sound tidier than it is.
The log only grows: Nothing is ever deleted, which is the whole point, but it means storage cost is a straight line up and to the right, and reconstructing the current state of a very long-lived or very active transaction means folding over more and more events. We manage this with periodic snapshots so we are not replaying from the very first event every time, but a snapshot is itself just an optimization on top of the log, never a replacement for it.
Ordering across independent services is a genuinely hard problem: FX, fees, and settlement do not share a clock. Getting a sequence position that reflects true causal order, not just arrival order, took real design work, and it is the kind of thing that is easy to get subtly wrong and not notice until a retry exposes it.
A correction-as-new-event model changes how "current state" is defined: If an event and its correction can both be in the log, the read path has to know how to fold them into a single coherent current state, not just the latest raw event. That logic lives in the read path, and it has to be kept in step with every new kind of event we introduce, or the aggregated view can drift from what the raw log actually shows.
Key points to keep in mind if you are building something like this
Decide early that a transaction is a sequence of events, not a row with a status. Retrofitting this onto an existing mutable-row design is far harder than starting from it.
Make append-only a property of the storage layer, not a convention among the engineers writing to it. If an update or delete is technically possible, it will eventually happen.
Give every event a true sequence position, not just a write timestamp. Out-of-order writes from retries and distributed services are the normal case, not the exception.
Model corrections as events, not edits. A timeline that hides its own mistakes is not more trustworthy for looking cleaner.
Build the merchant-facing view and the internal debugging view off the same log. Two separate representations of the same transaction is how they quietly drift apart.
It is related, but the difference is what the system treats as authoritative. In many designs the audit trail is a side effect, kept for compliance, while a separate mutable row is what the application actually reads to decide what to do next. Here, the log is not a side effect. It is the only thing that is written, and current state is derived from it, not tracked separately alongside it.
That pattern usually drifts. Two representations of the same transaction, updated by different code paths, eventually disagree, and when they disagree nobody can say which one is right. Deriving current state from the log removes the second representation entirely, so there is nothing for it to drift from.
Naively, replaying every event every time would. In practice, periodic snapshots mean most reads fold over a small number of recent events on top of a snapshot rather than the full history, so it stays fast without giving up the underlying guarantee.
Both are simply additional events in the same log, ordered by true sequence rather than arrival time. A retry does not overwrite the earlier attempt; it appears alongside it, so the timeline shows exactly what was tried, when, and what ultimately succeeded.