View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0007940 | module PayPal Checkout | module PayPal checkout - sub | public | 2026-04-30 15:38 | 2026-04-30 15:48 |
| Reporter | mario_lorenz | Assigned To | |||
| Priority | normal | Severity | minor | Reproducibility | have not tried |
| Status | assigned | Resolution | open | ||
| Product Version | 2.8.2 / 3.7.2 | ||||
| Target Version | 2.8.4 / 3.7.4 | ||||
| Summary | 0007940: Case here with two orders from one customer that I unfortunately can't trace | ||||
| Description | We have a case here with two orders from one customer that I unfortunately can't trace. The customer claims she only placed one order, but the shop has received two orders. The first, presumably unintentional, was placed via PayPal Express at 5:39:33 AM (order number 106552), and the second was placed via regular PayPal at 5:40:06 AM (order number 106553). The access log shows two POST requests to /index.php? (likely to cl=order, because they were preceded by a GET request to /index.php?cl=order) at 5:39:25 AM and 5:39:27 AM. It seems as if the customer somehow placed the order twice. The PayPal log also contains two (or rather three, since the last one is the second order at 5:40 AM) entries for "PayPal Payment Logger.DEBUG: finalizeOrder [] []" at 5:39:30 AM and 5:39:34 AM. These times are slightly different from the POST requests in the access log, but are likely due to the delay of the XHR calls in the frontend. The second finalizeOrder entry in the access log also redirects back to the payment methods with payerror=5. However, it seems that the unintended order was triggered precisely at this point (or shortly before). No corresponding entries are found in the OXID exception or PHP error logs at that time. The question now is how the customer unintentionally placed an order despite a payment error being triggered. | ||||
| Additional Information | Logs provided by Stefan Hahn for Spielewelle | ||||
| Tags | No tags attached. | ||||
|
|
The answer to your question: The customer double-clicked the Order button-two seconds apart. This isnt an accidental double-click; its a case of why isnt anything happening, Ill click again. Proof is in the access log: two consecutive POST requests to /index.php? on Step 5 on the same IP address/UA. The first submit was successful (order XXX1, capture COMPLETED). The second submit then triggered the cancel path because the PayPal session was temporarily destroyed by the first submit (PayPalSession::unsetPayPalSession() in paymentGateway::doExecutePayPalExpressPayment after successful capture). The result in the second submit: 1. getCheckoutOrderId() -> null -> the entire ExpressPayment path is skipped -> executePayment() returns false. 2. _executePayment interprets this as payment failed -> cancelPayPalOrder() -> the protection path cancel skipped - payment already processed is triggered (exactly the point we previously raised to a warning). 3. However, cancelPayPalOrder only returns false and does nothing else - _executePayment continues to return self::ORDER_STATE_PAYMENTERROR. In the OXID core, this is visible as payerror=5 (ORDER_STATE_INVALIDPAYMENT) - the same text as payerror=2 in the Wave theme: Authorization of payment failed. Please check your entry! This is precisely the error message the customer saw - even though order XXX1 had already been successfully paid for at that point. She doesnt see that the money has already been credited to PayPal. Plausible response: select a different payment method -> ??Order XXX2. Why the existing protection layers failed The current module has three idempotence layers that should have intercepted this double submit: 1. Frontend double-click protection (introduced in version 2.8.1, CHANGELOG entry Added frontend double-click protection (button disabled after first submit)) - this apparently failed with a 2-second interval. Possible causes: mobile browser quirk (Chrome iOS), faulty disable reset, or the disable was not set early enough. 2. Module Order::finalizeOrder idempotence check (line 925, checks isOrderPaid() + oxtransid -> return ORDER_STATE_OK) - this check is in place and should have redirected the second call directly to thank-you. However, the info log for this check is missing from the log. Possible explanation: At the time of the second finalizeOrder (05:39:34), oxpaid is indeed in the database, but markOrderPaid() only runs in the first submit after executePayment returns. If the second submit receives the session lock precisely in the window where executePayment is already running (Capture API is complete), but markOrderPaid has not yet been reached by the first request, the isOrderPaid() check still shows false. Hypothesis - this could be verified by additional logging before the check. 3. PaymentGateway::doExecutePayPalExpressPayment duplicate guard (getShopOrderByPayPalOrderId + oxtransid) - only applies if getCheckoutOrderId() is true. In the second submit, it is no longer present -> the guard is skipped. Layer 1 would have held if the disabled value were persistent until the server response. Layer 2 would have held if the markOrderPaid() race hadnt hit that exact moment. Both apparently failed-probably precisely this combination. What to do-three levels Immediately with the customer: - Cancel order XXX1 + refund to PayPal-she never wanted it; the trigger was a double-click due to a lack of response + misleading error message. In the module: The correct response to cancel skipped-already paid in the Express Flow is not payerror=5, but a redirect to the thank-you page. The money is there, the order is there-the customer has paid successfully, and she should see the corresponding success page. Additionally/alternatively, in Order::finalizeOrder, add a second idempotence check after the parent::finalizeOrder call: if the parent returns ORDER_STATE_PAYMENTERROR and the order is still isOrderPaid -> ORDER_STATE_OK. This would provide double protection. Check and strengthen double-click protection: - Disable the button not only via class, but also via the disabled attribute (to prevent CSS overrides) - Disable until server response or timeout (15 seconds) - not just 1 second - While disabled: Display the message Please wait - order is being transmitted… so the customer knows something is happening - Mobile browser: Check if pointer-events: none is also necessary (some iOS touch events override disabled) |