UPI payments
Not climbed yetModel answer
The app never holds the money. Its backend sends each payment through a partner bank to the UPI network run by NPCI, which asks the payer's bank to debit and the payee's bank to credit. The app's job is the request, the record and the status.
API:
POST /paymentswith the payee's UPI ID, the amount and a note, which returns a payment inPENDING;GET /payments/{id}for its status; and a callback endpoint where the bank reports the final result. The UPI PIN is typed into a secure input library that NPCI provides, encrypted on the phone, and never reaches your servers in readable form.Main table:
transactions(id, payer_vpa, payee_vpa, amount_paise, status, network_txn_id, created_at, updated_at). Store money as whole paise in an integer, never as a float. Status moves fromINITIATEDtoPENDINGtoSUCCESSorFAILED, and every change is kept with its time, because support and disputes need the history.Model answer
The app creates a request id (a UUID) when the payment screen opens and sends it with every attempt at that payment. The server stores it under a unique constraint on
(user_id, request_id).The first request inserts the row and starts the payment. A repeat with the same id hits the constraint, so the server looks up that payment and returns its current result instead of starting another. A genuinely new payment gets a new id.
The payment gets one transaction id for the network, generated once and reused on every retry, so the banks also see a retry as the same transaction. Greying out the button helps, but the server is the real guard: to it, a double tap, a slow network and an automatic retry all look the same.
Model answer
A timeout means unknown. The debit may have happened, or may still happen. If you call it failed, the user pays again, and may have paid twice.
- Keep it PENDING, and never send a second debit for the same payment.
- Ask the network for the status of the original transaction, with backoff and within the limits NPCI sets on how often you may ask.
- Accept the callback with the final result whenever it arrives. Handle it idempotently, since the status check and the callback can both report the same result.
Tell the user the truth: "Payment pending. Do not pay again; we will update you." If it ends as failed after the money left their account, the bank reverses the debit automatically, and RBI rules give banks a deadline for that, with compensation if they miss it.
A sweeper job picks up payments stuck in
PENDINGfor too long and escalates them, so none stays unknown.Model answer
Use a double-entry ledger: every movement is two entries that cancel out, a debit on one account and a credit on another (say
bank_escrowandmerchant_payable:42). The totals always balance, so a missing or doubled entry shows up as a difference.Keep it append-only. Balances are derived by summing entries, never overwritten. A mistake is fixed with a new reversing entry, so the history shows what happened and when, which audits and disputes need. An overwritten balance cannot tell you why it is wrong.
A reconciliation job matches the settlement file to the ledger by transaction id and sorts every row: matched; in the bank's file but not yours (a success callback you missed, so record it); in yours but not the bank's (check its status before paying anyone); or amounts that differ.
Known cases are fixed automatically, such as recording the auto reversal of a debit that never reached its payee. The rest go to a person, with every action logged. Merchants are paid from reconciled entries only.
