Why Would an Already-Paid Order Trigger a Refund?
In Dujiao-Next, an order can be paid through an online payment channel or with a wallet balance. After a user initiates an online payment, the server waits for the payment gateway to respond. Meanwhile, the user may submit another request and pay for the same order in full using their wallet.
At that point, the original online payment request no longer has a valid basis for continuing to process the order. Before the fix, however, it still handled the gateway result using stale state, leading to two possible outcomes:
- A late gateway failure: The original request’s failure compensation incorrectly refunds the funds used by the subsequent wallet payment, while the order remains paid.
- A late gateway success: The original request writes its in-memory payment record back to the database, restoring an invalidated record to
pendingand returning a payment link.
The first outcome leaves the merchant with an order that the system considers paid despite receiving no net payment. Fulfilling that order can then cause an actual loss. The second gives a user who has already paid with their wallet another online payment link, creating a risk of duplicate payment. This article verifies the incorrect refund, payment record reactivation, and link return.
The flaw lies in the shared order payment logic, not in any particular gateway’s signature verification. The analysis below follows the interleaving of the two requests to explain why failure compensation refunds the wrong funds and why a successful response overwrites an invalidated record.
Upstream fixed the issue in commit cb61a4d4 and released v1.5.2, which includes the fix, on November 2, 2026.
Dynamic testing for this article was performed in an isolated local environment, not against third-party production sites. The PoC creates orders and actually deducts wallet funds. Use it only in test environments you own or are explicitly authorized to test.
Affected Versions and Configurations
Affected Versions
All 36 tagged versions from v0.0.5-beta through v1.5.1 contain the late-failure path that incorrectly refunds wallet funds. v1.5.2 is the first fixed release.
The unsafe refund logic dates back to commit c21c0bfc, which introduced wallet functionality on February 21, 2026. From v0.0.5-beta through v1.5.1, failure compensation does not revalidate the current payment attempt and order state. v1.5.2 includes the fix in cb61a4d4.
Preconditions and Required Privileges
The full-wallet-payment path examined here requires:
- A regular user account that can log in, place orders, and make payments.
- A wallet balance sufficient to cover the test order.
- An order that supports both an online payment channel and wallet payment.
- An online payment creation request whose local transaction has committed but whose gateway result has not yet been fully processed. The wallet payment must complete within this window.
- For the incorrect-refund branch, the original online payment creation request must fail after the wallet payment completes.
This path does not require administrator privileges, control of the payment gateway, or forged payment-success callbacks. The user only operates on their own order. Authorization checks order ownership, but that does not prevent the same user from issuing interleaved, individually valid requests for that order; see the user payment endpoint.
The difficulty is in the timing: the wallet payment must complete while the online request is waiting for the gateway, and the incorrect refund also requires a subsequent gateway failure. A slower gateway response may widen the window, but it does not guarantee that the flaw will be triggered. Configurations without wallet payments, or without online payments for the same order, do not meet the prerequisites for this reproduction path.
Root Cause: The Transaction Ends, but the Old Request Still Assumes It Owns the Order
The Normal Payment Creation Flow
Both requests enter through POST /api/v1/payments. An online payment specifies a payment channel and sets use_balance: false; a full wallet payment sets use_balance: true without specifying an online channel. The endpoint checks whether the user is allowed to pay for the order. The race occurs later, in the business logic after authorization.
In v1.5.1, the payment creation flow and gateway result handling divide an online payment into three stages:
- Lock the order in a database transaction, confirm that it is still awaiting payment, and create a payment record with the status
initiated. - Commit the transaction, then send a creation request to the external payment gateway.
- When the gateway responds, write the payment link and status, or perform compensation if creation failed.
Keeping external network calls outside a database transaction is not inherently wrong. The problem is that facts checked in the first stage may no longer hold in the third.
While the server waits for the gateway, other requests may change the order and payment records. Subsequent processing must revalidate their current state rather than treating the original in-memory objects as still authoritative. The actual race window runs from the local creation transaction’s commit to the completion of gateway result handling, not across the entire HTTP request. The round-trip time measured by the PoC is only an estimate.
Branch One: Failure Compensation Refunds Another Payment’s Funds
In v1.5.1, the two requests can interleave as follows:
The critical detail is that A started as an online-only payment and never deducted these wallet funds. The wallet-paid amount it reads during failure compensation belongs to B, but A refunds it as though it were rolling back funds reserved by its own attempt.
The pre-fix compensation code does lock and reread the order. However, it does not check whether the order is still awaiting payment or whether the current payment attempt has been superseded. The subsequent call to ReleaseWalletBalance refunds the wallet-paid amount currently recorded on the order and updates its payment allocation fields, but does not change a paid order back to unpaid.
Simply “locking before refunding” is therefore insufficient. The lock serializes operations; it does not establish that this request is still entitled to issue the refund.
Suppose the initial wallet balance is 100.00 and the order costs 50.00:
| Stage | Wallet balance | Order status | wallet_paid_amount | online_paid_amount |
|---|---|---|---|---|
| Initial | 100.00 | Awaiting payment | 0.00 | 50.00 |
| Wallet payment complete | 50.00 | Paid | 50.00 | 0.00 |
| After incorrect compensation by the old request | 100.00 | Still paid | 0.00 | 50.00 |
In the last row, online_paid_amount = 50.00 does not mean the gateway actually received that money. It is merely the payment allocation rewritten by the refund helper. The order retains its payment timestamp, but the wallet has recovered its entire balance, leaving a paid order with no net payment. This can lead to fulfillment losses. Whether fulfillment has actually occurred depends on the fulfillment method and task scheduling; the order status alone does not prove that every deployment will deliver goods.
Branch Two: A Successful Response Reactivates an Invalidated Payment Record
Alternatively, A may eventually obtain a payment link successfully.
In v1.5.1, when B pays the order in full using the wallet, it marks P1 as expired and records supersession metadata such as superseded_at and superseded_by_payment_id. A’s in-memory copy of P1, however, is still the old object from the start of the request and contains none of these updates.
The gateway result handler sets the payment link and pending status directly on that stale object, then calls the repository’s update method. The repository implementation saves the entire object without checking the payment’s current database state at this point.
As a result, the old snapshot overwrites the invalidation and supersession metadata written by B. P1 becomes pending again, and A returns the payment link to the caller.
This branch demonstrates a rollback of local payment state and renewed exposure of an old link, creating a risk of duplicate payment. Returning a payment link, charging the user again through the gateway, and fulfilling the order twice are three distinct outcomes. The local tests directly verify record reactivation and link return, not a second charge through a real gateway or duplicate fulfillment.
Reproduction and Validation
Creating Concurrent Requests with the PoC
The PoC source is located at scripts/poc/payment-create-race/main.go. It uses regular user endpoints for login, order creation, payment channel selection, concurrent payments, and result checks. It does not require administrator endpoints or database write access.
Before testing, prepare a test product that supports both online and wallet payments, along with a dedicated account whose wallet balance covers the purchase. Use the account exclusively for the test so that unrelated transactions do not interfere with balance and ledger checks, and keep the product disconnected from production fulfillment.
From the source directory containing the PoC, run it against the test instance:
| |
Replace base-url with the test instance’s address, set TEST_USER_TOKEN to the test account’s token, and replace product-id with the test product ID. Products with variants also require -sku-id; use -channel-id to select a specific payment channel. This example uses a fixed delay and a single attempt to limit the orders and deductions generated by testing. It does not guarantee that the race will be triggered.
The PoC’s core operation is to issue two overlapping payment requests for the same order:
- Call
POST /api/v1/payments, specifying an online channel and settinguse_balance: false. - Before the first request finishes, call the same endpoint with
use_balance: trueand no online channel. - Wait for both requests to finish, then read the order details, wallet balance, and wallet ledger.
If -delay is omitted, the script first creates a probe order to estimate the online payment request’s duration, attempts to cancel that order, and chooses a delay based on the estimate. Probe cancellation is best-effort, so check for leftover orders after testing. The default attempt count is 6; the example above reduces it to 1.
Even if the race is not triggered, the wallet payment may still complete a real purchase. The PoC does not automatically reverse that purchase.
Interpreting the Results
For the incorrect-refund branch, check all of the following:
- The wallet payment actually succeeded, rather than merely being requested.
- The order retains its payment timestamp and corresponding business status.
- The wallet ledger contains a refund for this order, with a note containing
在线支付创建失败(“online payment creation failed”). - In the absence of other balance changes, the final wallet balance returns to its pre-payment value.
The current PoC compares wallet balances and counts refunds with matching notes among the latest 100 ledger entries.
For the success branch, distinguish “an old link was returned” from “an old record was reactivated.” The script reports a hit when the wallet has paid the order and the online request returns a link. However, the online request could have completed normally first, with the wallet payment invalidating its record afterward. The response read at the end does not by itself establish which sequence occurred on the server.
To confirm this branch, inspect server-side timing and payment records to establish that P1 returned to pending after being invalidated and that its supersession markers were overwritten. The controlled gateway tests below verify this, rather than treating the PoC’s VULNERABLE output as the sole evidence.
Controlling the Interleaving and Inspecting the Final State
To remove real gateway latency and network jitter from the experiment, validation used the regression tests from the fix commit:
v1.5.1: Only the test file from the fix commit was added; the business logic was left unchanged. The test gateway executes the wallet payment before returning, making the interleaving deterministic.v1.5.2: The existing regression tests were run to verify the fixed behavior.
On v1.5.1, both tests initially failed at the assertion that the old request should report an already-paid order rather than an error or a payment link. To inspect the final database state, that early-exit assertion was replaced with logging only in the temporary test copy, while the subsequent balance and payment-state assertions were retained. The results were:
| |
These are field excerpts from local test logs. The two cases used independent test data, each with an initial wallet balance of 100.00 and an order amount of 50.00; they are not transaction receipts from a real payment gateway.
On v1.5.2, all six regression tests passed:
| Regression scenario | Expected behavior |
|---|---|
| Old gateway request fails after the wallet pays the order in full | Wallet remains debited by 50.00; no incorrect refund |
| Old gateway request succeeds after the wallet pays the order in full | Old payment remains invalidated; no old payment link is returned |
| Old request fails after a newer mixed wallet-and-online payment reserves wallet funds | Funds reserved by the newer attempt are not refunded |
| Old request succeeds after a newer payment attempt exists | Old request does not supersede the newer attempt |
| Old payment receives a failure callback while a newer mixed payment is in progress | Funds reserved by the newer attempt are not released |
| Top-up success callback arrives before the creation request reports an error | Credited top-up is not changed back to failed |
Conclusion
This is neither a conventional payment-amount tampering flaw nor a missing payment callback signature check. Each request is valid on its own. The problem arises when their operations interleave in the wrong order across an external gateway call.
A failed payment creation attempt can only compensate for state that still belongs to that attempt. A successful creation result does not mean the request is still entitled to advance the order.
Database transactions make a set of operations atomic, but they do not automatically preserve business preconditions across transactions and network calls. For code that handles money, rereading state is only the first step. The more important question is whether this request still has the right to modify these funds and this order now.
Remediation
Affected users should upgrade to v1.5.2 or a later release that includes the fix.