Paid, but the order still says unpaid: where do payment status mismatches come from in custom software?
This complaint sounds like a failed payment, but nine times out of ten it is not. The money has already left the customer's card or balance and the channel holds a successful transaction, yet the order in your system still says unpaid — or worse, a scheduled job has already closed it, released the stock and returned the coupon. The same payment is recorded in three states in three places: the channel says collected, the server says unpaid, and the customer's phone shows a third.
There is only one way to judge this: the channel's transaction record is the single authority, the server persists the state after the channel confirms it, and the client only displays it. Any implementation that treats "the front end returned success" or "the customer sent a screenshot" as proof of payment will break on weak networks, mid-payment exits, repeated taps and delayed notifications. Below: where the chain breaks, why, and how to make it verifiable in a custom project.
Payment is not one request; it is a chain of six stages
In many requirement documents payment is written as a single feature: tap a button, open the payment sheet, receive a callback, change the state. The real chain is much longer, and every stage can fail — in different ways.
A complete payment passes at least through: creating the merchant order (and its order number) → calling the channel's order API for the prepay data → the client opening the payment sheet and the customer paying → the channel returning one result to the client → the channel pushing a payment notification to your server → your server verifying, de-duplicating, booking and changing the order state → the customer seeing the final result. There should also be a fallback path — actively querying the order — and a reconciliation path: pulling the channel statement daily and comparing it.
The key point is that the result the customer sees and the result the system believes come from different stages. The client-side return only signals that the customer completed a payment action: it can be forged, lost before reaching your server, or the app may be killed right after paying. The notification pushed to your server is the authoritative signal, but it is not reliable either: it can be delayed, duplicated, or never arrive because your service was restarting in those minutes.
WeChat Pay's documentation says this explicitly: if the merchant's acknowledgement is not a success or times out, the channel treats the notification as failed and resends on a fixed schedule — 15/15/30/180/1800/1800/1800/1800/3600 seconds; it also warns that "the same notification may be sent to the merchant system multiple times; the merchant system must handle duplicate notifications correctly", and recommends checking the state of the corresponding business data before deciding whether to process it. (Source: WeChat Pay merchant documentation, Payment Result Notification; page updated 2026.04.24, checked 2026-11-14.)
That interval table deserves a second look: summed up, the retry window lasts about three hours, not "three retries in a few seconds". Note the other sentence too — the channel "does not guarantee the notification will eventually succeed". Any system relying on notifications alone therefore has an inevitable gap: some orders never receive that message.
Why custom projects trip over this more often than standard products
Standard e-commerce SaaS wraps the payment chain into a fixed module; the integrator only fills in a merchant ID and a key. Custom projects are different: payment has to plug into the customer's own business objects — a work order, a course seat, a deposit for rented equipment, a prepayment for an on-site service. Each state machine differs, and they are usually written by different people at different times.
Hence three structural causes:
First, states are defined before the authority is. Requirements discussions cover "which states does the order have"; few ask "who may change each state, and based on what evidence". By the time development reaches payment, the front end changes state from an API response, the back end from notifications, and operations can still change it by hand in the admin console. Three paths in parallel — conflict is only a matter of time.
Second, unknown is treated as failure. A network timeout means "I do not know the result", not "it did not succeed". Yet a single catch branch marks the order as failed: the customer paid, the system believes they did not. The mini program default timeout is 60 seconds, configurable in app.json's networkTimeout or per request through the timeout parameter of wx.request (milliseconds; request-level settings take precedence). (Source: WeChat Open Documentation, Mini Program · Network · Usage; checked 2026-11-13.) The timeout can be raised, but that only postpones the problem; it does not change the semantics.
Third, only the happy path was tested. In self-testing the network is fine, notifications arrive instantly, the button is tapped once. In production the signal drops on the subway, the customer taps three times, ops restarts the service during a release — and defects surface in clusters. They never appear in a demo, only on real users, usually as a customer complaint plus finance unable to balance the books.
Five questions to judge whether an implementation is reliable
Rather than asking a vendor "have you done payments?", check them against these five questions. Each yields concrete evidence, and the answers decide whether you will be firefighting daily after go-live.
1. Does every state have a single authority? The fact that an order is paid can be produced in one place only. The usual split: channel notifications and active order queries are authoritative; the server database is the authoritative persisted copy; the client and admin pages only display. If the front end flips the order to paid as soon as an API returns success, the authority is not unique.
2. Are state transitions idempotent? Idempotent means running the same action many times produces the same result as running it once. The check is concrete: do all side effects — booking the payment, shipping, issuing coupons, adding points, notifying the warehouse — use "merchant order number + channel transaction number" as the de-duplication key, and look before they write inside one transaction? Without that key, duplicate notifications cause duplicate shipments or a doubled balance.
3. Is there a fallback when notifications are missing? Usually an active order query: once an order enters paying, query the channel at increasing intervals (for example 30 seconds, 2 minutes, 10 minutes, 1 hour); a success is booked normally, a closed order is closed. Without this path, one lost notification leaves one payment hanging.
4. Can you reconcile? Reconciliation is not an after-the-fact finance chore but a daily system capability. At minimum, pull the channel statement daily and compare it three ways against local orders: in the channel but not in the system; in the system but not in the channel; amounts that differ. If you cannot produce that comparison, the data model lacks key fields such as the channel transaction number.
5. Can you explain the problem to the customer? When a customer calls to ask "did I pay?", can support see the whole chain of that order in the back office — when it was created, when the payment sheet opened, how many notifications arrived, what the queries returned, and which record drove the current state? If not, the only answer is "please wait", and waiting magnifies complaints.
Run your current situation through these five and most intermittent payment problems can be traced to a specific stage instead of being blamed on a bad network.
The four most common break points, and the right fix for each
Break point 1: treating the client's return as proof of payment
The client's return only proves the customer completed a payment action; it does not prove the money reached your account. It can be tampered with, lost in transit, or the customer may abandon the password page.
The fix is to move state transitions to the server: after the client gets its result it does one thing — open a "confirming payment result" page where the server queries the order and returns the real state. That page waits a few extra seconds but avoids the worse outcome of "the page says success while the order was never paid". The client result must never directly trigger irreversible actions such as shipping, course activation or coupon issuing.
Break point 2: treating a timeout as failure and closing the order
This is the most common cause of "money paid, order gone". A customer pays on the subway, the request times out, the front end reports failure, so they order again; or a scheduled job closes unpaid orders after 15 minutes while the notification arrives late — leaving an orphan transaction that was collected but whose order is closed.
The fix is an intermediate state. Besides unpaid, the order needs paying / result unknown: a timeout moves it there without closing the order or shipping, and a query job advances it. The auto-close rule must change too — query the channel once and close only after confirming the order really is unpaid. That is a small change, but it is the dividing line for keeping money and goods in sync.
Break point 3: duplicate notifications, duplicate side effects
Duplicate notifications are not a defect; they are the channel's deliberate policy. The documentation says the same notification may be sent multiple times and the merchant must handle duplicates correctly. In practice duplicates also come from gateway retries, message-queue redelivery and manual replays by ops.
The fix has two layers. The first is an idempotency key: make the channel transaction number a unique constraint enforced by a database unique index, so a duplicate write fails and is logged — do not rely on an if check, which loses under concurrency. The second is the processing order: verify the signature, check whether the local order was already processed, and only then change state and write the ledger inside a transaction. Channels expect a fast acknowledgement, so slow actions (SMS, third-party warehouse notices, invoices) should run asynchronously — do not drag the acknowledgement into a timeout.
One more point is often missed: the acknowledgement itself. If it does not mean success, the channel keeps retrying and the same business action is triggered repeatedly; if it returns too fast and swallows exceptions, you lose the troubleshooting clues. Keep the acknowledgement short and deterministic, and keep the logs.
Break point 4: refunds and reverse flows without a state machine
Once forward payment works, many projects stop. But refunds also have notifications, also repeat, also arrive late, and add branches payment does not have: partial refunds, multiple refunds, retries after a failed refund. Without a separate refund order and refund state machine you end up with "the order is refunded but the amount does not match" and "the same refund was paid out twice".
The fix is a dedicated document per refund: the refund number as idempotency key, recording the requested amount, the channel refund number, the refund state and the failure reason, and constraining the cumulative refund to the original transaction amount. Whether a refund succeeded is likewise decided by channel notifications and active queries, not by a synchronous API return — that only means the refund request was accepted.
How to test it at acceptance: five scenarios, demonstrated live
Verbal promises are worthless here, because the question is not whether it was built but whether it was built correctly. Put these five scenarios into the acceptance terms, require a live demonstration in the test environment, and keep screenshots or recordings in the acceptance report.
| Scenario | How to construct it | What you should see |
|---|---|---|
| Network timeout | Use a proxy to delay payment requests past the timeout, or operate on a weak network | The order enters paying / result unknown and is not closed; once the network recovers the query corrects it to paid, booked exactly once |
| Customer exits midway | Open the payment sheet, then go back or kill the process, then reopen the app | The back end still completes the state via notification or query; the customer sees the real state on return |
| Duplicate notification | Replay the same signed notification 3 to 5 times | Booked exactly once, a "duplicate notification ignored" log entry, stock and balance unchanged |
| Missing notification | Temporarily block the notification callback URL, leaving only the query job | The query fills in the state within the agreed window and raises an alert instead of failing silently |
| Reverse flow | Do a partial refund, then request another refund on the same transaction | The refund state is correct, the cumulative refund does not exceed the original amount, the duplicate is rejected |
Beyond the demonstrations, ask for three artefacts: a readable sample of the daily reconciliation report (even with test data), a "what to do when states disagree" runbook (who decides, in what order to fix, how to inform the customer), and the alert configuration (which anomalies trigger, who is notified). Whether these exist says more about operability than any architecture description.
Build it, buy SaaS, or use an aggregator: the trade-offs
Not every project needs every layer above; the choice depends on how central transactions are to your business.
If transactions are occasional collections (an enrolment fee, a deposit), use an existing payment aggregator or industry SaaS and just get state sync and daily reconciliation right — no need to build a payment platform. The cost is that reconciliation granularity and exception handling are limited by the provider, and when something breaks you wait for them.
If transactions are the core business (orders, fulfilment, refunds and split payments all yours), the chain must be controlled down to the state machine, with at least notifications, order queries, idempotency and reconciliation. The cost is continuous maintenance: API version upgrades, certificate rotation and new payment methods are long-term work.
Multiple channels in parallel is the third case: with two or more channels you must also resolve "one order paid through two channels" (a failure on one, a success on another) and unify reconciliation definitions across channels. This is routinely underestimated; the workload often exceeds the single-channel integration itself.
Three things should never be skipped in any design: idempotency keys, the query fallback and daily reconciliation. They map to the three most expensive failures — duplicate booking, money and goods out of sync, and accounts you cannot explain.
How we handle this by default in custom projects
We do not deliver payment as one API but as a state mechanism that has to be maintained over time. It lands in four phases:
Requirements: we start with a state inventory — which states each business object has, who triggers each change, what evidence it rests on, and which changes are irreversible. For most customers this is the first time they see the complete state map of their own business, and it is often where risks such as manual state edits by operations surface.
Solution design: we settle two things — the authority and the idempotency key. Which record is the final fact, and which field de-duplicates, go into the design document rather than a developer's habits. We also agree default parameters for timeouts, retries and alerts.
Development: the query fallback, duplicate-notification handling and reconciliation jobs are reused as standard components instead of being rewritten per project. Logs are organised as one payment, one chain, so support and ops can both follow it.
Acceptance: we demonstrate the five scenarios above and deliver the daily reconciliation report together with the runbook. After go-live, reconciliation runs daily and any mismatch raises an alert.
The boundary matters: channel-side rules (fees, settlement cycles, risk-control blocks, API changes), merchant qualification and identity review, and bank arrival times are not ours to decide. We are responsible for the correct implementation of this state mechanism, its verifiability and its diagnosability when something goes wrong. We do not promise "no incidents ever"; we promise that when one happens, money and orders can be matched, explained and fixed — a difference most customers only appreciate at the first real failure.
What to do next
If your project is in design or already live, start with three small steps; no rewrite needed:
- Have your team or vendor list every order state and every entry point that can change it, and mark the manual ones. The more manual entry points, the more you need an audit log.
- Assign an authority to each kind of state and write it into the design document. Anything that says "the front end said success, so it is paid" goes on the fix list.
- Add the five scenarios to your acceptance terms and require demonstration rather than description. For live systems, run one notification-replay test and one reconciliation and see whether they match.
After these three, your judgement no longer depends on a vendor's verbal assurance; you have reproducible evidence.
Frequently asked follow-ups
The customer paid twice. Can the system merge the two? No. Two different channel transaction numbers are two real collections; the system can identify them but cannot swallow one. Keep one valid order and refund the other. That is exactly why the double key matters: the merchant order number governs business uniqueness, the channel transaction number governs transaction uniqueness.
What if the notification never arrives? Do not wait. Querying the order is a standard action: poll at increasing intervals and escalate to a human past a threshold. The channel documentation explicitly says delivery is not guaranteed, so waiting for the notification is itself a design defect.
Is raising the timeout enough? It only reduces misjudgements; the semantics do not change. On mobile, cut the timeout to 20–30 seconds so the customer sees paying quickly, and let the server's order query deliver the verdict.
How do you investigate a reconciliation mismatch? Classify first: in the channel but not in the system usually means a lost notification or an unavailable service; in the system but not in the channel usually means test data or forged callbacks; differing amounts usually mean duplicate booking or an unsynced partial refund. The three need completely different fixes, and mixing them wastes time.
Sources
- WeChat Pay merchant documentation, Payment Result Notification: resend policy and notification frequencies after a failed notification, the requirement to handle duplicates correctly, and the recommendation to check business data state first. Page updated 2026.04.24, checked 2026-11-14.
- WeChat Open Documentation, Mini Program · Basic capabilities · Network · Usage: default request timeout of 60 seconds, configurable in app.json and per call, with request-level settings taking precedence. Checked 2026-11-13.
- Statements about state-machine design, idempotency key choice, query intervals, acceptance scenarios and reconciliation definitions reflect our own practice in custom projects; they are not channel rules, and parameters should be confirmed against each project's transaction profile and the latest channel documentation.