A few things from a live case that might move this thread along, since the last question here is still sitting without an answer.
What CS-Cart_team wrote earlier is correct: PayPal Complete Payments orders start as Incomplete, and a retry creates a new order rather than reusing the old one. But that answer stops one step short, because not every Incomplete order is the same thing, and the difference decides whether you lost a sale or lost nothing at all.
The detail that matters is where the charge actually happens. It is not the PayPal button, and it is not the webhook. The capture call runs when the buyer’s browser comes back to the store, on payment_notification.return. That request is what calls capture() and moves the order from Incomplete to Open. Paid comes later, and only from the PAYMENT.CAPTURE.COMPLETED webhook. Which gives you two very different failures:
Incomplete - the return never reached the store, capture never ran, nothing was charged. Open - the money was taken and only the webhook that sets Paid did not land.
Every thread I have read on this mixes those two, and I think that is the main reason none of them ever conclude. Worth checking which one you actually have before anything else.
One correction while I am at it: Incomplete does not mean the buyer never got as far as PayPal. In the production logs I went through, buyers approved the payment in PayPal and the order still sat in Incomplete.
So how do you separate a real loss from an ordinary abandoned cart? Look in Administration > Logs for a CHECKOUT.ORDER.APPROVED webhook whose reference_id points at an order that is still Incomplete. That combination means PayPal holds an approved order with no capture against it: as far as the buyer is concerned they paid, and the store never took the money. No APPROVED entry for that order and it was just an abandoned checkout, which every shop has plenty of. Two hundred Incomplete rows is not two hundred failures.
Now the variant that I suspect is behind the “the second attempt works” reports. From a 4.20.x store, same session:
10:38:59 place_order → order 13011, PayPal order 60V642244 created 10:41:28 place_order → order 13012, PayPal order 9GW685152 created 10:44:38 webhook CHECKOUT.ORDER.APPROVED for 60V642244 10:44:39 return with order_id=13012 and order_id_in_paypal=60V642244 → HTTP 200 10:45:38 third attempt, order 13013 → return 302 → Paid
The pair is crossed: store order from the second attempt, PayPal order from the first. The core requires those two to match and ends with a plain “Access denied” when they do not, so you get HTTP 200 with a few hundred bytes instead of a 302, no capture, and - this is teh nasty part - no admin log entry whatsoever. From the merchant side it looks like the buyer vanished.
The cause is in the core JS. js/addons/paypal_checkout/checkout.js overwrites the stored store order id on every createOrder, while onApprove builds the return URL from that variable plus the PayPal order id that was actually approved. There is no guard against pressing the button twice either. Between placing the order and approving it, those buyers spent five to six minutes logging in to PayPal, and that is exactly the window in which people click again. Approve in a window belonging to the older attempt and the pair can never match.
And it is not a browser issue. One earlier case did come from the Google app webview on iOS, so that hypothesis was tempting, but the crossed-pair case above was Chrome/Edge 151 on Windows, adn another buyer the same day checked out fine on Chrome.
As for fixing it, there is nothing in the core that reconciles those orders - no cron, no cleanup, no retry. It can be closed from an add-on though. The crossed pair can be resolved by looking the order up by the PayPal order id that is already stored in payment_info before the core comparison runs, which only ever resolves to the order that genuinely belongs to that PayPal order, so no check gets loosened. The approved-but-never-returned ones can be picked up by a cron that asks PayPal for their status and captures the approved ones - though that has to be a switch, since charging for an order the buyer walked away from is the shop owner’s call. We are building both into our authorize/capture add-on, but the mechanism above is worth knowing regardless of what you run.
Happy to answer questions if someone wants to check their own logs against this.
Best regards
Robert