pdelabs logo
sun

An order state machine with a timeout on every edge

On a marketplace, an order is a multi-day negotiation between two strangers with money in the middle. Here is how we kept ours from getting stuck.
whale-tale
When most people picture “placing an order,” they picture a checkout: you pay, and a moment later a box is on its way. On a marketplace, that picture is wrong. An order is not a transaction that completes in a second — it is a multi-day exchange between two strangers, either of whom can simply stop replying, with a payment sitting in the middle the entire time.We built demoda, a marketplace where small brands and individuals sell clothing to buyers they have never met. The hardest part was never the catalogue or the checkout. It was answering, precisely, what the system should do when a seller never confirms a sale, when a paid-for package is never shipped, or when a buyer never collects it. Get that wrong and money ends up stranded in a state nobody can explain.

Why a status column isn’t enough

The tempting first model is a single status field on the order — pending, paid, shipped, done. It works in a demo and falls apart in production, because the interesting part of an order is not the states. It is the waits between them. Every wait is a place where a human has to do something, and might not. A string in a column has no idea that time is passing, or that an order has been sitting in paid for nine days while the seller ignores it.
A waiting state has three exits: the action, a timeout, or a dispute.A state where thesystem is waitingon a personThe action happens→ move to the next stateNo one acts in time→ the timeout firesSomething is wrong→ open a dispute
The core idea. Every state where the system waits on a person has exactly three ways out.
So we stopped modelling the order as a status and started modelling it as an explicit state machine: a fixed set of states, and a defined set of transitions between them. The rule we held to was simple — every state where we wait on a person gets all three of the exits above. The action they might take. A timeout, for when they don’t. And a dispute path, for when something is wrong.

The happy path already forks

Even the happy path is not a straight line, because demoda lets a buyer either have an item mailed or collect it in person, and those are genuinely different flows. Once payment clears, a mailed order waits for the seller to ship and then sits in shipping; an in-person order goes straight to waiting for pickup. Both converge on fulfilled, at which point the seller gets paid.
The happy path forks after payment: a mailed order goes waiting-for-shipment then shipping; an in-person order goes waiting-for-pickup. Both end at fulfilled.mailedin personseller shipsreceives · or autopicks up · or autoWaiting for paymentWaiting forshipmentWaiting forpickupShippingFulfilled
The path everyone hopes for — and it already branches by how the buyer chose to receive the item. “Or auto” is the timeout quietly completing an order the buyer never got around to confirming.

A timeout on every edge

This is the discipline that holds the whole thing together, and it is worth stating plainly: nothing waits forever. Every state above that waits on a person has its own timeout, and they do not fire blind — they nudge first. A seller who hasn’t shipped gets reminded, twice; a buyer who hasn’t picked up or confirmed receipt gets reminded too. Only if the reminders are ignored does the timeout actually fire.And when it does, most states resolve forward. A shipped order the buyer never confirms auto-completes so the seller still gets paid; the same is true for a pickup that is never confirmed. The one state that resolves backward is payment itself: if the buyer never pays inside the window, the order is cancelled and the reserved stock goes back on sale. None of these are edge cases we bolted on after the first bad week — on a marketplace the unhappy paths are the product.

When it goes wrong: judging

Two things break the happy path outright. A seller can reject an order they can’t fulfil — if it was paid by card, the buyer is refunded automatically; if it was a bank transfer, which can’t be clawed back with an API call, it goes to a human. And a buyer can report a problem, which moves the order into judging — a hold where the dispute is actually decided.From judging there are exactly two ways out: favour the seller and the order fulfils and funds release, or favour the buyer and it refunds. Judging itself has a time limit, so even a contested order can’t sit forever — if no one resolves it in time, it defaults to fulfilled.
Exceptions: an unpaid order is cancelled; a rejected order is refunded, or held in judging for a bank transfer; a reported problem goes to judging, which resolves to fulfilled for the seller or refunded for the buyer.no payment · rejectedreports a problemseller rejectsfavours sellerfavours buyerWaiting for paymentCancelledAn active order(any wait above)Judging(dispute hold)FulfilledRefunded
timeout / hold dispute / refund success
Everything that isn’t the happy path. Judging is the arbitration hold; every order still ends in exactly one of three terminal states — fulfilled, refunded, or cancelled.

The app is never the source of truth

One rule sits underneath all of this: the client never decides anything that involves money. The app can request — start a checkout, mark a package sent — but the states that matter are moved by the backend, reacting to a payment provider’s webhook or to one of those timers. When a buyer pays, we do not trust the app to tell us so; we wait for MercadoPago to confirm it, and only then does the order become paid.
The buyer pays MercadoPago; MercadoPago confirms to our backend by webhook, and only then does the order change state. The app never sets it.BuyerMercadoPagoOur backendPays in thecheckoutTakes thepaymentOrder still“pending”paysConfirms bywebhookMarks it paid→ next statewebhookthe app can never set this
The client can kick off a payment, but it can never set the order’s state. That comes from the provider’s webhook, server-side.
Order totals work the same way: they are calculated on the server, never read back from the client, so the price a buyer is charged can never be something the app made up. It is a small rule that removes an entire category of “how did this order end up like that?” questions.

Every transition is a testable handler

Because each transition is its own handler rather than a branch buried in a giant function, each one can be tested in isolation — including the ones that are annoying to reproduce for real. You can test “seller accepts but never ships” without standing up a marketplace, waiting three days, and bribing someone not to ship a jacket. That is the quiet payoff of an explicit machine: the scary parts become ordinary unit tests.

What it bought us

Orders resolve themselves. Support is not chasing packages that have been “shipping” for two weeks. Money is never sitting in an ambiguous place with no owner. And when someone does ask what happened to an order, the answer is a path through a diagram, not an archaeology dig through logs. The pattern is not specific to fashion, or even to marketplaces — it applies anywhere two parties transact over time and either one can go quiet.
Building something where the unhappy paths matter as much as the happy one? That is the part we like.

Schedule a call now

This describes an architectural pattern from our work on demoda, abstracted to the design level. Read the full project write-up in the demoda case study.