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
3 changes: 3 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ services:
PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface:
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiOperationStatusFetcher

PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface:
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiRefundCreator

payplug_sylius_payplug_plugin.action.capture:
class: PayPlug\SyliusPayPlugPlugin\Action\CaptureAction

Expand Down
226 changes: 221 additions & 5 deletions src/Handler/HostedFieldsWebhookNotificationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PayPlug\SyliusPayPlugPlugin\Upc\CardDataFromPaymentMethodExtractor;
use PayPlug\SyliusPayPlugPlugin\Upc\PaymentOrderIdResolver;
use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister;
use PayPlug\SyliusPayPlugPlugin\Upc\RefundDetailsLockKey;
use PayPlug\SyliusPayPlugPlugin\Upc\ResourceIdentifier;
use PayplugUnifiedCore\Contracts\IConfigurationRepository;
use PayplugUnifiedCore\Contracts\ILock;
Expand Down Expand Up @@ -74,10 +75,84 @@ public function treat(PaymentInterface $payment, string $rawBody, array $headers
$expectedHeader = $this->configurationRepository->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? '';
$operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader);

if (!$this->matchesPayment($payment, $operationData)) {
if (PaymentOutcome::THREE_DS_PENDING === $operationData->outcome) {
// Not a final outcome — leave the payment as-is and, crucially, do not touch
// isTreated()/markTreated(): a later, final notification for this same operation
// must still be free to apply once it arrives. The 0001-is-pending knowledge itself
// now lives in payplug/unified-plugin-core's ExecCodeMapper (see its docblock for the
// full execcode-catalog reasoning), not duplicated here. Must run before any refund
// matching below: a 3DS-pending notification is never a refund confirmation, and
// classifying it as one this early would be wrong regardless of whether its
// operationId also happens to match a recorded refund.
return;
}

// A notification whose operation id matches one RefundPaymentProcessor already recorded
// under $details['refunds'] (for both full and partial UHF refunds) confirms a refund
// operation, not the payment's own outcome — ExecCodeMapper's "0000" => PAID mapping is
// payment-shaped and would otherwise misreport a successful refund as the payment being
// paid. UPC has no "refund" concept in its execCode/outcome vocabulary to lean on here
// (see ExecCodeMapper), so this classification is made locally, from ids this plugin
// itself generated and already knows the meaning of.
$refundAmount = self::findMatchingRefundAmount($payment, $operationData->operationId);
$expectedAmount = $refundAmount ?? $payment->getAmount();

if (!$this->matchesPayment($payment, $operationData, $expectedAmount)) {
return;
}

if (null !== $refundAmount) {
if (PaymentOutcome::PAID !== $operationData->outcome) {
// The refund itself failed (or is still pending) per its own execCode — this must
// never be forced into REFUNDED (the money never moved), nor forwarded as-is to
// the payment's own state machine: PaymentOutcome::FAILED maps to
// TRANSITION_FAIL (see SyliusOrderStateMutator), which means "this PAYMENT
// failed," not "this refund attempt failed" — the underlying payment already
// succeeded, only the refund didn't. Track/log only, so this notification stops
// being redelivered without ever touching the Payment's own state.
$this->logger->error('[PayPlug][UPC] Refund confirmation reports a non-success outcome.', [
'sylius_payment_id' => $payment->getId(),
'operation_id' => $operationData->operationId,
'outcome' => $operationData->outcome,
'exec_code' => $operationData->execCode,
]);

if (!$this->markMatchedRefundAsFailedLocked($payment, $operationData->operationId)) {
// Couldn't acquire the lock guarding this payment's $details['refunds'] —
// RefundPaymentProcessor is creating a refund for it right now (see
// markMatchedRefundAsFailedLocked()'s own docblock). Return without calling
// applyLocked(): isTreated()/markTreated() are never touched, so this
// notification stays free to be redelivered and retried once that refund
// creation has released the lock, instead of being marked treated without its
// 'failed' flag ever actually being recorded.
return;
}

$this->applyLocked($payment, $rawBody, $operationData, applyOutcome: false);

return;
}

$operationData->outcome = PaymentOutcome::REFUNDED;
}

$this->applyLocked($payment, $rawBody, $operationData);
}

// Split out of treat() to keep its own return count within SonarCloud's limit (php:S1142) —
// same rationale as matchesPayment() below: this is its own self-contained "acquire, check
// idempotency, apply" unit, not a fragment that needs to share treat()'s return budget.
// $applyOutcome false skips the orderStateMutator call while still tracking the notification
// as treated — used when the resolved $operationData->outcome must not reach the Payment's
// own state machine at all (see treat()'s own non-success-refund branch above); a refund
// confirmation never reaches maybeSaveCard() either way, since that only ever runs alongside
// a genuine PAID outcome being applied.
private function applyLocked(
PaymentInterface $payment,
string $rawBody,
OperationData $operationData,
bool $applyOutcome = true,
): void {
$lockKey = self::LOCK_KEY_PREFIX . $operationData->operationId;
if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) {
// Another delivery/poll for the same operation is already being processed — whichever
Expand All @@ -91,7 +166,9 @@ public function treat(PaymentInterface $payment, string $rawBody, array $headers
}

$this->paymentRepository->save($operationData);
$this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), $operationData->outcome);
if ($applyOutcome) {
$this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), $operationData->outcome);
}
$this->paymentRepository->markTreated($operationData->operationId);

if (PaymentOutcome::PAID === $operationData->outcome) {
Expand Down Expand Up @@ -136,7 +213,7 @@ private function maybeSaveCard(PaymentInterface $payment, string $rawBody): void
// Split out of treat() to keep its own return count within SonarCloud's limit (php:S1142) —
// both branches here mean "nothing to apply," they just differ in whether that's expected
// (still-pending) or a problem worth logging over (mismatch).
private function matchesPayment(PaymentInterface $payment, OperationData $operationData): bool
private function matchesPayment(PaymentInterface $payment, OperationData $operationData, ?int $expectedAmount): bool
{
if (PaymentOutcome::THREE_DS_PENDING === $operationData->outcome) {
// Not a final outcome — leave the payment as-is and, crucially, do not touch
Expand All @@ -148,12 +225,12 @@ private function matchesPayment(PaymentInterface $payment, OperationData $operat
}

$expectedOrderId = PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId());
if ($operationData->orderId !== $expectedOrderId || $operationData->amount !== $payment->getAmount()) {
if ($operationData->orderId !== $expectedOrderId || $operationData->amount !== $expectedAmount) {
$this->logger->error('[PayPlug][UPC] Hosted Fields webhook notification does not match the payment it was resolved against.', [
'sylius_payment_id' => $payment->getId(),
'expected_order_id' => $expectedOrderId,
'received_order_id' => $operationData->orderId,
'expected_amount' => $payment->getAmount(),
'expected_amount' => $expectedAmount,
'received_amount' => $operationData->amount,
]);

Expand All @@ -162,4 +239,143 @@ private function matchesPayment(PaymentInterface $payment, OperationData $operat

return true;
}

// $details['refunds'] entries are RefundPaymentProcessor's own — see
// processHostedFields()/processHostedFieldsWithAmount() — {internal_id, id, amount}, id being
// the refund operation's own id (from createRefund()'s response operationIds[0]).
private static function findMatchingRefundAmount(PaymentInterface $payment, string $operationId): ?int
{
$refunds = self::resolveOwnRefunds($payment, $operationId);
if (null === $refunds) {
return null;
}

$index = self::findMatchingRefundIndex($refunds, $operationId);
if (null === $index) {
return null;
}

$entry = $refunds[$index];
$amount = \is_array($entry) ? ($entry['amount'] ?? null) : null;

return \is_int($amount) ? $amount : null;
}

/**
* Acquires RefundDetailsLockKey before calling markMatchedRefundAsFailed() below, so this
* read-modify-write of $details['refunds'] can't interleave with
* RefundPaymentProcessor::processHostedFields()/processHostedFieldsWithAmount()'s own — which
* acquire the very same key around their (network-call-spanning) read-modify-write of that
* same array — and silently lose one of the two writes. Returns false, without calling
* markMatchedRefundAsFailed() at all, when the lock is already held (a refund creation for
* this payment is in progress right now): the caller must not proceed to mark this
* notification treated in that case, so it stays free to be redelivered and retried once the
* lock is free.
*/
private function markMatchedRefundAsFailedLocked(PaymentInterface $payment, string $operationId): bool
{
$lockKey = RefundDetailsLockKey::forPaymentId($payment->getId());
if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) {
$this->logger->error('[PayPlug][UPC] Could not acquire the refund-details lock to flag a failed refund; a refund creation is likely in progress for this payment.', [
'sylius_payment_id' => $payment->getId(),
'operation_id' => $operationId,
]);

return false;
}

try {
self::markMatchedRefundAsFailed($payment, $operationId);
} finally {
$this->lock->release($lockKey);
}

return true;
}

/**
* Neutralizes the matched refund entry's 'amount' contribution — flags it 'failed' => true —
* so a later RefundPaymentProcessor::processHostedFields() full-refund call (which sums every
* $details['refunds'] entry to derive the remaining balance still owed) doesn't count money
* that was accepted synchronously by createRefund() but never actually moved, per this same
* notification's own non-success outcome. The entry itself (id/amount) is kept, not removed,
* as an audit trail of the failed attempt. Only ever called while holding RefundDetailsLockKey
* — see markMatchedRefundAsFailedLocked() above, its only caller.
*/
private static function markMatchedRefundAsFailed(PaymentInterface $payment, string $operationId): void
{
$refunds = self::resolveOwnRefunds($payment, $operationId);
if (null === $refunds) {
return;
}

$index = self::findMatchingRefundIndex($refunds, $operationId);
if (null === $index || !\is_array($refunds[$index])) {
return;
}

$refunds[$index]['failed'] = true;
$details = $payment->getDetails();
$details['refunds'] = $refunds;
$payment->setDetails($details);
}

/**
* @return mixed[]|null $details['refunds'] as an array, or null when $operationId is either
* the known payment-creation operation id (never a refund — see the inline comment
* below) or $details['refunds'] itself isn't a usable array.
*/
private static function resolveOwnRefunds(PaymentInterface $payment, string $operationId): ?array
{
$details = $payment->getDetails();

// The original payment-creation notification always carries the exact operation id
// CaptureHostedPaymentRequestHandler recorded under hosted_fields_operation_id at
// creation time — never a refund. The unresolved-entry fallback in
// findMatchingRefundIndex() must not misclassify a delayed/redelivered copy of THAT
// notification as an unrelated refund just because a refund with no captured id also
// happens to exist on this payment.
$paymentOperationId = $details['hosted_fields_operation_id'] ?? null;
if (\is_string($paymentOperationId) && $paymentOperationId === $operationId) {
return null;
}

$refunds = $details['refunds'] ?? null;

return \is_array($refunds) ? $refunds : null;
}

/**
* @param mixed[] $refunds
*
* A refund entry with a null id means RefundPaymentProcessor's own createRefund() call
* returned a 2xx response whose body carried no operationIds (logged there as an error at
* the time) — this confirmation is the only remaining way to learn which refund it belongs
* to, so fall back to the most recent such unresolved entry rather than dropping the
* notification entirely (or, worse, letting it fall through unmatched and get misapplied as
* a plain payment confirmation). Ambiguous only if more than one refund for the same payment
* independently hit that same malformed-response edge case, which the upstream error log
* already flags as needing manual attention.
*/
private static function findMatchingRefundIndex(array $refunds, string $operationId): ?int
{
$unresolvedIndex = null;

foreach ($refunds as $index => $refund) {
if (!\is_int($index) || !\is_array($refund) || !\is_int($refund['amount'] ?? null)) {
continue;
}

$refundOperationId = $refund['id'] ?? null;
if ($refundOperationId === $operationId) {
return $index;
}

if (null === $refundOperationId) {
$unresolvedIndex = $index;
}
}

return $unresolvedIndex;
}
}
Loading
Loading