I built Simuno, a service that sells temporary phone numbers for receiving SMS verification codes. This post is about the one part I found hardest: making the refund automatic, so a customer never has to ask for their money back.
The problem
Codes don't always arrive. The number may already be used on that app, or the app may refuse that number range. On most services like this you then argue with support. I wanted the opposite: if the number expires with no code, the full amount returns to the wallet by itself.
That sounds simple. It isn't, because three things can try to settle the same order at the same moment: a background worker that expires orders, a webhook from the supplier, and the user pressing cancel.
Decision 1: a ledger, not a balance
Every balance change is an entry in an append-only ledger. The cached balance on the user is updated in the same database transaction as the entry and the order status change. That needs MongoDB to run as a replica set, even on one node, because transactions are not available on a standalone server.
Decision 2: one refund per order, enforced by the database
Each refund entry carries a unique key built from the order id. If the worker, the webhook and the cancel button all fire together, only one insert succeeds. The others see a duplicate key error and stop. The order status change is also a compare-and-set, so an order that is already refunded cannot move again.
Decision 3: ask the supplier before you refund
A code can arrive in the last seconds. Before cancelling or refunding, the system asks the supplier for the latest state once more. If a code just arrived, the number is kept and the code is shown, instead of refunded and lost. This was a bug in my first version: a late code could be lost because the refund won the race.
Decision 4: fall through suppliers at the same price limit
Numbers come from more than one upstream supplier. If the first one has no stock or rejects the price, the order tries the next with the same maximum price, so the customer never pays more because of a fallback.
What I would do again
- Write the refund path before the happy path. It touches the wallet, the order state machine and the supplier calls, so it shapes everything.
- Make the failure case a test: duplicate refunds, a code arriving during cancel, a supplier timeout.
- Keep the ledger append-only. Debugging money is much easier when nothing is ever edited.
Stack: Next.js, MongoDB (replica set), Redis, a Node worker for polling and expiry, and a Telegram bot and Mini App on the same backend.
If you have run a similar system, I would like to hear how you handle the race between a late success and an expiry.

