Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/Events/OrderStateChangeEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,6 @@ public function onOrderDeliveryStateShipped(OrderStateMachineStateChangeEvent $e
* - onOrderDeliveryStateShipped (state-machine path via state_enter event)
* - OrderDeliveryWrittenSubscriber (direct DAL write path via order_delivery.written)
*
* Deduplication between the two paths is handled by the customFields['captured']
* flag which CaptureService sets synchronously after a successful capture; the
* canCapture* guards short-circuit when it is present.
*
* @param array<int, string>|null $onlyPaymentMethods When given, the trigger is
* restricted to these Buckaroo payment methods (lowercase `brqPaymentMethod`
* values). The direct DAL write path uses this to opt in one method at a time
Expand Down Expand Up @@ -441,6 +437,7 @@ private function canCaptureAfterpay(

return $customFields['brqPaymentMethod'] === 'afterpay' &&
!isset($customFields['captured']) &&
!CaptureService::isCaptureInFlight($customFields) &&
$this->settingsService->getSetting('afterpayCaptureonshippent', $salesChannelId) &&
isset($orderCustomFields[CaptureService::ORDER_IS_AUTHORIZED]) &&
$orderCustomFields[CaptureService::ORDER_IS_AUTHORIZED] === true;
Expand All @@ -457,8 +454,15 @@ private function canCaptureKlarna(array $customFields, ?string $salesChannelId):
return false;
}

// Klarna MoR: capture-on-shipment is mandatory, but only ONE Pay may be sent
// per reservation. `captured` records a confirmed capture (synchronous success
// or the success push), the in-flight marker covers the window in which the
// engine is still processing an earlier Pay (791 Pending) — during a single
// ship action both the order_delivery.written and the
// state_enter.order_delivery.state.shipped paths fire this guard.
return $customFields['brqPaymentMethod'] === 'klarna'
&& !isset($customFields['captured'])
&& !CaptureService::isCaptureInFlight($customFields)
&& isset($customFields['dataRequestKey'])
&& (bool)$this->settingsService->getSetting('klarnaCaptureonshipment', $salesChannelId);
}
Expand All @@ -476,6 +480,7 @@ private function canCaptureKlarnaKp(array $customFields, ?string $salesChannelId

return strtolower($customFields['brqPaymentMethod']) === 'klarnakp'
&& !isset($customFields['captured'])
&& !CaptureService::isCaptureInFlight($customFields)
&& isset($customFields['reservationNumber'])
&& (bool)$this->settingsService->getSetting('klarnakpCaptureonshipment', $salesChannelId);
}
Expand All @@ -501,6 +506,13 @@ private function createNotifications(?array $result, Context $context): void
if ($result === null) {
return;
}

// Deduplicated captures (already captured / capture in flight) are the guards
// working as intended - never surface them as an admin warning next to the
// success notification of the capture that did run.
if (!empty($result['silent'])) {
return;
}
$status = 'warning';
if (isset($result['status']) && $result['status'] === true) {
$status = 'success';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Shows the order-detail page loader while an order / payment / delivery state
* transition request is running.
*
* The Buckaroo plugin performs work synchronously inside these state-transition
* requests - capture-on-shipment (Klarna MoR, Klarna KP, Riverty) when a
* delivery is shipped, and a Klarna MoR CancelReservation when an order is
* cancelled - so the request can take a few seconds. The Shopware admin fires
* the transition with no loading indicator (the confirmation modal closes
* immediately), which makes the order page look frozen and invites a second
* click. This decorator flips the swOrderDetail store's `order` loading flag
* around every transition call; the order detail page already renders its
* skeleton loader from that flag, and all three state cards
* (sw-order-general-info, sw-order-details-state-card,
* sw-order-state-history-card) go through this one service.
*/
const { Application } = Shopware;

const TRANSITION_METHODS = [
'transitionOrderState',
'transitionOrderTransactionState',
'transitionOrderDeliveryState',
];

function getOrderDetailStore() {
try {
return Shopware.Store.get('swOrderDetail');
} catch (e) {
// Store not registered (transition triggered outside the order detail
// page) - nothing to indicate, run the transition unchanged.
return null;
}
}

Application.addServiceProviderDecorator('orderStateMachineService', (orderStateMachineService) => {
TRANSITION_METHODS.forEach((methodName) => {
const original = orderStateMachineService[methodName];

if (typeof original !== 'function') {
return;
}

orderStateMachineService[methodName] = function decoratedTransition(...args) {
const store = getOrderDetailStore();

if (store) {
store.setLoading(['order', true]);
}

let result;
try {
result = original.apply(this, args);
} catch (error) {
if (store) {
store.setLoading(['order', false]);
}
throw error;
}

if (store && result && typeof result.finally === 'function') {
// `finally` passes the value/rejection through unchanged, so the
// calling component's own then/catch handling keeps working.
return result.finally(() => {
store.setLoading(['order', false]);
});
}

if (store) {
store.setLoading(['order', false]);
}

return result;
};
});

return orderStateMachineService;
});
3 changes: 2 additions & 1 deletion src/Resources/app/administration/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import './components/buckaroo-main-config';
import './components/buckaroo-config-card';
import './components/buckaroo-payment-list';
import './components/buckaroo-test-credentials';
import './components/buckaroo-toggle-status';
import './components/buckaroo-toggle-status';
import './decorator/order-state-machine-loader.decorator';
117 changes: 113 additions & 4 deletions src/Service/CaptureService.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ class CaptureService

public const ORDER_IS_AUTHORIZED = 'buckaroo_is_authorize';

public const CAPTURE_INITIATED = 'captureInitiated';

public const CAPTURE_IN_FLIGHT_SECONDS = 600;

/**
* Order ids for which this request already sent (or is currently sending) a
* capture request. Deduplicates the two capture-on-shipment trigger paths that
* fire within one PHP request (order_delivery.written and
* state_enter.order_delivery.state.shipped) without any database write - see the
* deadlock note on CAPTURE_INITIATED for why this must not be a DB flag.
*
* @var array<string, bool>
*/
private array $inFlightOrderIds = [];

protected TransactionService $transactionService;

protected TranslatorInterface $translator;
Expand Down Expand Up @@ -91,6 +106,21 @@ public function capture(
return $validationErrors;
}

// In-memory guard: the ship action fires both order_delivery.written and
// state_enter.order_delivery.state.shipped in the same request. Never send a
// second capture for an order this request already captured. Deliberately NOT
// a database write - see the deadlock note on CAPTURE_INITIATED.
// `silent`: this is the dedup working as intended, not a problem a merchant
// needs to see - the capture triggers must not raise a notification for it.
if (isset($this->inFlightOrderIds[$order->getId()])) {
return [
'status' => false,
'silent' => true,
'message' => $this->translator->trans("buckaroo.capture.capture_in_progress")
];
}
$this->inFlightOrderIds[$order->getId()] = true;

$client = $this->getClient(
$paymentCode,
$order->getSalesChannelId()
Expand All @@ -111,8 +141,28 @@ public function capture(
),
);

try {
$response = $client->execute();
} catch (\Throwable $th) {
// The connection failed but the engine may still have accepted and
// processed the capture (e.g. a timeout while the engine waited on its
// own result push). Persist the in-flight marker - safe now, the
// outbound call is over - so no retry fires before the push has had the
// chance to record `captured`; the marker expires after
// CAPTURE_IN_FLIGHT_SECONDS.
$transactionId = $this->getLastTransactionIdOrNull($order);
if ($transactionId !== null) {
$this->transactionService->saveTransactionData(
$transactionId,
$context,
[self::CAPTURE_INITIATED => time()]
);
}
throw $th;
}

return $this->handleResponse(
$client->execute(),
$response,
$order,
$context,
$paymentCode
Expand All @@ -136,6 +186,8 @@ private function handleResponse(
Context $context,
string $paymentCode
): array {
$transactionId = $this->getLastTransactionIdOrNull($order);

if ($response->isSuccess()) {
if (
!$this->invoiceService->isInvoiced($order->getId(), $context) &&
Expand All @@ -148,13 +200,11 @@ private function handleResponse(
$this->invoiceService->generateInvoice($order, $context);
}

$transactionId = $order->getTransactions()?->last()?->getId();

if ($transactionId !== null) {
$this->transactionService->saveTransactionData(
$transactionId,
$context,
['captured' => 1]
['captured' => 1, self::CAPTURE_INITIATED => 0]
);
}

Expand All @@ -170,13 +220,59 @@ private function handleResponse(
];
}

// The engine accepted the request but processes it asynchronously (791). This
// is the normal flow for a Klarna MoR "Pay on reservation": the definitive
// result arrives via push. Persist the in-flight marker (safe post-call) so
// no second capture is sent in the meantime, and report it as initiated
// instead of failed.
if ($response->isPendingProcessing()) {
if ($transactionId !== null) {
$this->transactionService->saveTransactionData(
$transactionId,
$context,
[self::CAPTURE_INITIATED => time()]
);
}

return [
'status' => true,
'message' => $this->translator->trans("buckaroo.capture.capture_pending"),
];
}

// Definitive failure: nothing was persisted before the call, so a later
// shipment event or a manual capture can simply retry.
return [
'status' => false,
'message' => $response->getSomeError(),
'code' => $response->getStatusCode(),
];
}

/**
* Whether a capture request for this transaction is currently in flight: it was
* handed to the Buckaroo engine less than CAPTURE_IN_FLIGHT_SECONDS ago and no
* definitive result has been recorded yet. Shared by the capture-on-shipment
* triggers (OrderStateChangeEvent) and validate().
*
* @param array<mixed> $customFields
*/
public static function isCaptureInFlight(array $customFields): bool
{
$initiatedAt = $customFields[self::CAPTURE_INITIATED] ?? null;

if (!is_numeric($initiatedAt) || (int)$initiatedAt <= 0) {
return false;
}

return (time() - (int)$initiatedAt) < self::CAPTURE_IN_FLIGHT_SECONDS;
}

private function getLastTransactionIdOrNull(OrderEntity $order): ?string
{
return $order->getTransactions()?->last()?->getId();
}

private function getCurrencyIso(OrderEntity $order): string
{
$currency = $order->getCurrency();
Expand Down Expand Up @@ -300,12 +396,25 @@ private function validate(OrderEntity $order, array $customFields, string $payme
];
}

// `silent`: an already handled capture is the dedup guards doing their job.
// The automatic capture-on-shipment triggers skip the admin notification for
// silent results; a manual capture (CaptureController) still returns the
// message to the caller as its response.
if (!empty($customFields['captured']) && ($customFields['captured'] == 1)) {
return [
'status' => false,
'silent' => true,
'message' => $this->translator->trans("buckaroo.capture.already_captured")
];
}

if (self::isCaptureInFlight($customFields)) {
return [
'status' => false,
'silent' => true,
'message' => $this->translator->trans("buckaroo.capture.capture_in_progress")
];
}
return null;
}

Expand Down
8 changes: 8 additions & 0 deletions src/Storefront/Controller/PushController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Buckaroo\Shopware6\Service\StateTransitionService;
use Buckaroo\Shopware6\Helpers\Constants\ResponseStatus;
use Buckaroo\Shopware6\Helpers\KlarnaKpCaptureDetector;
use Buckaroo\Shopware6\Service\CaptureService;
use Shopware\Storefront\Controller\StorefrontController;
use Buckaroo\Shopware6\Service\SignatureValidationService;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
Expand Down Expand Up @@ -376,6 +377,13 @@ public function pushBuckaroo(Request $request, SalesChannelContext $salesChannel
// already in authorized state, meaning this is a capture (pay) push.
if (!$this->stateTransitionService->canTransitionStatus('authorize', $orderTransactionId, $context)) {
$paymentState = $paymentSuccesStatus;
// This success push confirms the Klarna MoR capture (Pay on the
// reservation). Record it durably and release the in-flight
// marker, so no capture-on-shipment trigger (state_enter, DAL
// write, or a later shipment) can ever send a second Pay —
// Buckaroo rejects those with OrderService_Capture_InvalidOrderStatus.
$data['captured'] = 1;
$data[CaptureService::CAPTURE_INITIATED] = 0;
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/translations/messages.de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"capture_not_supported": "Erfassen wird nicht unterstützt",
"already_captured": "Diese Bestellung wurde bereits erfasst",
"captured_amount": "Betrag erfolgreich erfasst: %amount% %currency%",
"general_capture_error": "Leider ist ein Fehler bei der Verarbeitung Ihrer Erfassung aufgetreten. Bitte versuchen Sie es erneut."
"general_capture_error": "Leider ist ein Fehler bei der Verarbeitung Ihrer Erfassung aufgetreten. Bitte versuchen Sie es erneut.",
"capture_in_progress": "Für diese Bestellung läuft bereits eine Capture-Anfrage",
"capture_pending": "Capture-Anfrage an Buckaroo gesendet, Bestätigung ausstehend"
},
"cannotRefundMissingPayment": "Cannot refund order, no payment transaction is available",
"payperemail": {
Expand Down
4 changes: 3 additions & 1 deletion src/translations/messages.en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"capture_not_supported": "Capture is not supported",
"already_captured": "This order is already captured",
"captured_amount": "Successfully captured amount %amount% %currency%",
"general_capture_error": "Unfortunately an error occurred while processing your capture. Please try again."
"general_capture_error": "Unfortunately an error occurred while processing your capture. Please try again.",
"capture_in_progress": "A capture request for this order is already in progress",
"capture_pending": "Capture request sent to Buckaroo, awaiting confirmation"
},
"cannotRefundMissingPayment": "Cannot refund order, no payment transaction is available",
"payperemail": {
Expand Down
4 changes: 3 additions & 1 deletion src/translations/messages.fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"capture_not_supported": "La capture n'est pas prise en charge",
"already_captured": "Cette commande a déjà été capturée",
"captured_amount": "Montant capturé avec succès de %amount% %currency%",
"general_capture_error": "Malheureusement, une erreur s'est produite lors du traitement de votre capture. Veuillez réessayer."
"general_capture_error": "Malheureusement, une erreur s'est produite lors du traitement de votre capture. Veuillez réessayer.",
"capture_in_progress": "Une demande de capture est déjà en cours pour cette commande",
"capture_pending": "Demande de capture envoyée à Buckaroo, en attente de confirmation"
},
"cannotRefundMissingPayment": "Cannot refund order, no payment transaction is available",
"payperemail": {
Expand Down
4 changes: 3 additions & 1 deletion src/translations/messages.nl-NL.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"capture_not_supported": "Capture wordt niet ondersteund.",
"already_captured": "Deze bestelling is al gecaptured.",
"captured_amount": "Succesvol gecaptured bedrag van %amount% %currency%",
"general_capture_error": "Helaas is er een fout opgetreden bij het verwerken van uw capture. Probeer het opnieuw."
"general_capture_error": "Helaas is er een fout opgetreden bij het verwerken van uw capture. Probeer het opnieuw.",
"capture_in_progress": "Er loopt al een capture-verzoek voor deze bestelling",
"capture_pending": "Capture-verzoek naar Buckaroo verzonden, wachten op bevestiging"
},
"cannotRefundMissingPayment": "Cannot refund order, no payment transaction is available",
"payperemail": {
Expand Down
Loading
Loading