Skip to content

Commit 7606f29

Browse files
committed
Change order: Resolve the shop order BEFORE calling the PayPal capture API.
1 parent f0094a9 commit 7606f29

3 files changed

Lines changed: 41 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1414
- [0007916](https://bugs.oxid-esales.com/view.php?id=7916): Fix orphaned oscpaypal_order rows with OSCPAYPALSTATUS=COMPLETED and empty OXORDERID. When `sess_challenge` was cleared before `captureOrder()` completed (e.g. by a concurrent cancel request), the capture flow proceeded and created tracking records that could not be matched by webhook processing. Added defense-in-depth validation at four levels: `AjaxPaymentController::captureOrder()` now aborts if the shop order cannot be resolved, `PayPalOrderCompletedSubscriber` rejects events with empty shopOrderId, `Payment::trackPayPalOrder()` throws on empty shopOrderId, and `OrderRepository::paypalOrderByOrderIdAndPayPalId()` refuses to create new records with empty shopOrderId.
1515
- [0007916](https://bugs.oxid-esales.com/view.php?id=7916): Fix root cause of `sess_challenge` being cleared during active capture. `Payment::removeTemporaryOrder()` unconditionally deleted `sess_challenge` even when the cancel was blocked because PayPal had already approved/captured the payment. Now `sess_challenge` is only deleted when the cancel actually succeeds, keeping the session intact for the concurrent capture flow.
1616
- [0007916](https://bugs.oxid-esales.com/view.php?id=7916): Add database fallback for shop order resolution in `AjaxPaymentController::captureOrder()`. If `sess_challenge` is missing, the shop order is now resolved via the persisted `oscpaypal_order` relationship using the PayPal order ID. Additionally rejects cancelled (storno) orders to prevent tracking against invalidated shop orders.
17+
- [0007916](https://bugs.oxid-esales.com/view.php?id=7916): Fix PayPal capture executed before storno check in `AjaxPaymentController::captureOrder()`. The PayPal capture API call was made before validating whether the shop order had been cancelled, allowing funds to be captured for stornoed orders. Moved order resolution and storno check before the `capturePaymentForOrder()` call so that cancelled orders are rejected without contacting the PayPal API.
1718
- Fix duplicate order creation for stock-1 articles during PayPal checkout. After the AJAX `captureOrder()` flow completed, `PayPalOrderCompletedSubscriber` cleaned up the PayPal session (`PayPalSession::unsetPayPalSession()`). When the browser then redirected to the order confirmation page, `Order::finalizeOrder()` was called again. Because the PayPal session was already cleared, `isOrderExecutionInProgress()` returned false and the webhook-wait guard did not trigger — causing `parent::finalizeOrder()` to create a second shop order that failed stock validation. Added a guard in `Order::finalizeOrder()` that detects already-paid orders (via `isOrderPaid()` + `oxtransid`) and returns early without creating a duplicate.
1819
- [0007917](https://bugs.oxid-esales.com/view.php?id=7917): Fix tracking carrier country not restored on page reload. When a carrier from the "global" country group was saved, the country dropdown was reset to the order's shipping/billing country instead. Added `getCountryCodeByCarrierKey()` to `PayPalTrackingCarrierList` and `getEffectiveTrackingCountryCode()` to `OrderMain` to resolve the saved carrier's country and pre-select it in the dropdown.
1920

metadata.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
'en' => 'Use of the online payment service from PayPal. Documentation: <a href="https://docs.oxid-esales.com/modules/paypal-checkout/en/latest/" target="_blank">PayPal Checkout</a>'
6969
],
7070
'thumbnail' => 'img/paypal.png',
71-
'version' => '3.7.2-rc.3',
71+
'version' => '3.7.2-rc.4',
7272
'author' => 'OXID eSales AG',
7373
'url' => 'https://www.oxid-esales.com',
7474
'email' => 'info@oxid-esales.com',

src/Controller/AjaxPaymentController.php

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -240,36 +240,9 @@ public function captureOrder(): void
240240
'paymentStatus' => 'error'
241241
];
242242

243-
try {
244-
//Verify 3D result if ACDC payment
245-
if (!$scaValidator->verify3D($paymentId)) {
246-
$this->outputJson([
247-
'status' => 'error',
248-
'message' => $language->translateString('OSC_PAYPAL_3DSECURITY_ERROR')
249-
]);
250-
}
251-
252-
$capturePaymentForOrder = $orderService->capturePaymentForOrder(
253-
'',
254-
$payPalOrderId,
255-
$request,
256-
'',
257-
Constants::PAYPAL_PARTNER_ATTRIBUTION_ID_PPCP
258-
);
259-
$capturePaymentForOrder->intent = OrderRequest::INTENT_CAPTURE;
260-
} catch (ApiException $exception) {
261-
$issue = $exception->getErrorIssue();
262-
$translatedErrorMessage = $language->translateString(
263-
'OSC_PAYPAL_' . $issue,
264-
(int)$language->getBaseLanguage(),
265-
false
266-
);
267-
268-
$this->outputJson([
269-
'status' => 'error',
270-
'message' => $translatedErrorMessage
271-
]);
272-
}
243+
// Resolve the shop order BEFORE calling the PayPal capture API.
244+
// This prevents capturing funds for an order that was cancelled
245+
// between createOrder and onApprove (race condition).
273246
$shopOrderId = $this->orderRepository->fetchCurrentShopOrderId();
274247
$order = $this->orderRepository->fetchCurrentShopOrder();
275248

@@ -290,10 +263,9 @@ public function captureOrder(): void
290263
}
291264
}
292265

293-
$basket = Registry::getSession()->getBasket();
294-
$user = $basket->getUser();
295-
296266
// Validate that a valid, non-cancelled shop order could be resolved.
267+
// This check MUST happen before the PayPal capture API call to prevent
268+
// capturing funds for a cancelled order.
297269
if (empty($shopOrderId) || !$order->isLoaded() || $order->getFieldData('oxstorno') == 1) {
298270
$this->logger->log(
299271
'error',
@@ -307,6 +279,40 @@ public function captureOrder(): void
307279
return;
308280
}
309281

282+
try {
283+
//Verify 3D result if ACDC payment
284+
if (!$scaValidator->verify3D($paymentId)) {
285+
$this->outputJson([
286+
'status' => 'error',
287+
'message' => $language->translateString('OSC_PAYPAL_3DSECURITY_ERROR')
288+
]);
289+
}
290+
291+
$capturePaymentForOrder = $orderService->capturePaymentForOrder(
292+
'',
293+
$payPalOrderId,
294+
$request,
295+
'',
296+
Constants::PAYPAL_PARTNER_ATTRIBUTION_ID_PPCP
297+
);
298+
$capturePaymentForOrder->intent = OrderRequest::INTENT_CAPTURE;
299+
} catch (ApiException $exception) {
300+
$issue = $exception->getErrorIssue();
301+
$translatedErrorMessage = $language->translateString(
302+
'OSC_PAYPAL_' . $issue,
303+
(int)$language->getBaseLanguage(),
304+
false
305+
);
306+
307+
$this->outputJson([
308+
'status' => 'error',
309+
'message' => $translatedErrorMessage
310+
]);
311+
}
312+
313+
$basket = Registry::getSession()->getBasket();
314+
$user = $basket->getUser();
315+
310316
$payPalCustomerId = null;
311317
if ($vaultPayment) {
312318
if (isset($capturePaymentForOrder->payment_source->paypal->attributes->vault->customer["id"])) {

0 commit comments

Comments
 (0)