diff --git a/config/services.yaml b/config/services.yaml index 8d8b846d..6fc5f465 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/src/Handler/HostedFieldsWebhookNotificationHandler.php b/src/Handler/HostedFieldsWebhookNotificationHandler.php index 1605a23b..659231ff 100644 --- a/src/Handler/HostedFieldsWebhookNotificationHandler.php +++ b/src/Handler/HostedFieldsWebhookNotificationHandler.php @@ -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; @@ -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 @@ -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) { @@ -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 @@ -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, ]); @@ -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; + } } diff --git a/src/PaymentProcessing/RefundPaymentProcessor.php b/src/PaymentProcessing/RefundPaymentProcessor.php index 4f9bb13b..da51fa72 100644 --- a/src/PaymentProcessing/RefundPaymentProcessor.php +++ b/src/PaymentProcessing/RefundPaymentProcessor.php @@ -16,6 +16,10 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\PaymentOrderIdResolver; +use PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\RefundDetailsLockKey; +use PayplugUnifiedCore\Contracts\ILock; use Psr\Log\LoggerInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; @@ -34,6 +38,8 @@ #[Autoconfigure(public: true)] final class RefundPaymentProcessor implements PaymentProcessorInterface { + private const REFUND_LOCK_TTL_SECONDS = 30; + private PayPlugApiClientInterface $payPlugApiClient; public function __construct( @@ -43,6 +49,8 @@ public function __construct( private RepositoryInterface $refundPaymentRepository, private RefundHistoryRepositoryInterface $payplugRefundHistoryRepository, private PayPlugApiClientFactoryInterface $apiClientFactory, + private RefundCreatorInterface $refundCreator, + private ILock $lock, ) { } @@ -60,6 +68,13 @@ public function onRefundCompleteTransitionEvent(CompletedEvent $event): void public function process(PaymentInterface $payment): void { $this->prepare($payment); + + if (self::isHostedFields($payment)) { + $this->processHostedFields($payment); + + return; + } + $details = $payment->getDetails(); Assert::string($details['payment_id']); @@ -77,6 +92,13 @@ public function process(PaymentInterface $payment): void public function processWithAmount(PaymentInterface $payment, int $amount, int $refundId): void { $this->prepare($payment); + + if (self::isHostedFields($payment)) { + $this->processHostedFieldsWithAmount($payment, $amount, $refundId); + + return; + } + $details = $payment->getDetails(); Assert::string($details['payment_id']); @@ -112,6 +134,290 @@ public function processWithAmount(PaymentInterface $payment, int $amount, int $r } } + /** + * UHF counterpart of process() — same full-refund shape, but via UPC's createRefund() rather + * than the legacy PayPlugApiClient. No RefundHistory bookkeeping here, matching process()'s + * own behavior (only processWithAmount() persists one) — but the refund's own operation id is + * still recorded under $details['refunds'] (internal_id null: there's no Sylius + * RefundPayment/$refundId in this flow), since HostedFieldsWebhookNotificationHandler resolves + * the payment for a refund's async webhook confirmation by matching against ids present + * somewhere in Payment::details — without this, that confirmation could never be resolved. + * + * Guarded by $lock (same ILock contract HostedFieldsWebhookNotificationHandler already uses), + * keyed by RefundDetailsLockKey — the same key processHostedFieldsWithAmount() uses for this + * same payment, so a full and a partial refund triggered concurrently on it serialize against + * each other too, not just two calls of the same kind; it's also the same key + * HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() acquires before its own + * write to this same $details['refunds'] array, so a refund creation here (which spans the + * createRefund() network call) and a webhook concurrently flagging an earlier refund failed + * can't interleave their read-modify-write and silently drop one of the two writes. Unlike the + * legacy flow, which forwards Sylius's own refund id to PayPlug as a de-facto idempotency key, + * UPC's createRefund() has no idempotency-key parameter at all — a concurrent second call for + * this same payment (e.g. a double form submission, or a full refund racing a partial one) + * would otherwise be free to trigger a second, real refund on the account. There's no + * RefundHistory/refundId to check-then-act on here (full refunds don't create one, mirroring + * process()'s legacy behavior), so the lock is the only guard available for this path. + */ + private function processHostedFields(PaymentInterface $payment): void + { + // Deliberately outside the try/catch below, mirroring the legacy path's own + // Assert::string($details['payment_id']) — a payment reaching here without this detail + // set raises a raw InvalidArgumentException rather than UpdateHandlingException. + Assert::string($payment->getDetails()['hosted_fields_payment_id']); + $originalAmount = $payment->getAmount(); + if (null === $originalAmount) { + throw new \LogicException('Payment amount is not set.'); + } + + /** @var PaymentMethodInterface $method */ + $method = $payment->getMethod(); + $lockKey = RefundDetailsLockKey::forPaymentId($payment->getId()); + + $this->runLockedRefund($lockKey, ['sylius_payment_id' => $payment->getId()], function () use ($payment, $method, $originalAmount): void { + // Re-read now that the lock is held, not a snapshot taken before it: the lock is what + // actually keeps this read-modify-write from racing + // HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed()'s own — see this + // method's own docblock above. + $details = $payment->getDetails(); + $externalId = $this->createRefundOperation( + $method, + $details['hosted_fields_payment_id'], + PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), + null, + ['sylius_payment_id' => $payment->getId()], + ); + + $refunds = self::normalizeRefunds($details); + // Omitting $amount to createRefund() above refunds the payment's full REMAINING + // amount (per UnifiedApiPaymentService::createRefund()'s own docblock), not + // $originalAmount — those two only coincide when no prior refund exists yet. + // Subtracting whatever this plugin already recorded as refunded (computed BEFORE + // appending the new entry below) keeps this one accurate, which matchesPayment() then + // relies on to ever match this refund's own webhook confirmation. + $refundedAmount = $originalAmount - self::sumRecordedRefunds($refunds); + self::appendRefundEntry($payment, $details, $refunds, null, $externalId, $refundedAmount); + }); + } + + /** + * Shared by processHostedFields()/processHostedFieldsWithAmount(): both acquire $lockKey (see + * each method's own docblock for why a lock is needed at all here), run $refund inside it, and + * convert any \Exception it throws — including one from $refundCreator->createRefund() itself + * — into the same logged UpdateHandlingException, releasing the lock either way. + * + * @param mixed[] $lockFailureContext + */ + private function runLockedRefund(string $lockKey, array $lockFailureContext, \Closure $refund): void + { + if (!$this->lock->acquire($lockKey, self::REFUND_LOCK_TTL_SECONDS)) { + $this->logger->error('[PayPlug][UPC] Refund already in progress for this payment, refusing a concurrent call.', $lockFailureContext); + + throw new UpdateHandlingException(); + } + + try { + $refund(); + } catch (Exception $exception) { + $this->logger->error('[PayPlug][UPC] Refund Payment', ['error' => $exception->getMessage()]); + + throw new UpdateHandlingException(); + } finally { + $this->lock->release($lockKey); + } + } + + /** + * Actionable, not just informational: without an operation id, the eventual async webhook + * confirmation for this refund can never be matched back to it (see + * HostedFieldsWebhookNotificationHandler::findMatchingRefundAmount()) and either gets dropped + * or, worse, misapplied as a plain payment confirmation. + * + * @param mixed[] $context + */ + private function logIfOperationIdMissing(?string $externalId, string $responseBody, array $context): void + { + if (null !== $externalId) { + return; + } + + $this->logger->error('[PayPlug][UPC] Refund succeeded but the response carried no operationIds.', [ + ...$context, + 'response_body' => $responseBody, + ]); + } + + /** + * Shared by processHostedFields()/processHostedFieldsWithAmount(): calls + * $refundCreator->createRefund() and extracts the refund's own operation id from the response + * (logging via logIfOperationIdMissing() when the response carried none) — the one piece + * genuinely identical between the two callers, $amount aside (null here means a full refund; + * a given value means a partial one). + * + * @param mixed[] $logContext + */ + private function createRefundOperation( + PaymentMethodInterface $method, + string $hostedFieldsPaymentId, + string $orderId, + ?int $amount, + array $logContext, + ): ?string { + $response = $this->refundCreator->createRefund($method, $hostedFieldsPaymentId, $orderId, $amount); + + $externalId = self::extractFirstOperationId($response['body']); + $this->logIfOperationIdMissing($externalId, $response['body'], $logContext); + + return $externalId; + } + + /** + * @param mixed[] $details + * + * @return mixed[] + */ + private static function normalizeRefunds(array $details): array + { + $refunds = $details['refunds'] ?? []; + + return \is_array($refunds) ? $refunds : []; + } + + /** + * Shared by processHostedFields()/processHostedFieldsWithAmount(): appends one entry to + * $refunds (already normalized via normalizeRefunds()) and persists the resulting + * $details['refunds'] via setDetails() — the bookkeeping + * HostedFieldsWebhookNotificationHandler later matches a refund's async webhook confirmation + * against (see its own findMatchingRefundAmount()). + * + * @param mixed[] $details + * @param mixed[] $refunds + */ + private static function appendRefundEntry( + PaymentInterface $payment, + array $details, + array $refunds, + ?int $internalId, + ?string $externalId, + int $amount, + ): void { + $refunds[] = [ + 'internal_id' => $internalId, + 'id' => $externalId, + 'amount' => $amount, + ]; + $details['refunds'] = $refunds; + $payment->setDetails($details); + } + + /** + * @param mixed[] $refunds + * + * Skips any entry HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() flagged + * 'failed' => true — its synchronous createRefund() call was accepted (2xx), but the async + * confirmation later reported the refund itself never actually completed, so that money was + * never moved and must not count against the remaining balance a subsequent full refund + * derives from this sum. + */ + private static function sumRecordedRefunds(array $refunds): int + { + $total = 0; + foreach ($refunds as $refund) { + if (!\is_array($refund) || true === ($refund['failed'] ?? false)) { + continue; + } + + $amount = $refund['amount'] ?? null; + $total += \is_int($amount) ? $amount : 0; + } + + return $total; + } + + /** + * UHF counterpart of processWithAmount(). The refund's own operation id — extracted from the + * createRefund() response's operationIds[0], same convention resolveHostedFieldsIds() uses + * for a payment's operationIds[0] at creation time — takes the place of the legacy SDK + * Refund object's ->id in $details['refunds']. RefundHistory::externalId stays null, exactly + * like the legacy flow: that field is reserved for the id an async webhook confirmation would + * carry, not the id already known synchronously here. + * + * Unlike the legacy flow (which forwards Sylius's own $refundId to the API as a de-facto + * idempotency key), UPC's createRefund() has no idempotency-key parameter at all — so, on top + * of a RefundHistory already recorded for this $refundId being checked for up front (before + * calling createRefund() again), the whole check-then-act sequence is now also guarded by + * $lock, keyed by RefundDetailsLockKey (same key processHostedFields() uses for this same + * payment, so a partial and a full refund racing each other also serialize, not just two + * partial refunds — and the same key HostedFieldsWebhookNotificationHandler acquires around + * its own write to this $details['refunds'] array, see processHostedFields()'s own docblock): + * without it, two concurrent calls could both pass the RefundHistory check before either one + * persists, and both would go on to call createRefund() — double-refunding money that a plain + * "check first" can't prevent, only a lock actually serializing the attempts can. + */ + private function processHostedFieldsWithAmount(PaymentInterface $payment, int $amount, int $refundId): void + { + // Deliberately outside the try/catch below — see processHostedFields()'s own comment. + Assert::string($payment->getDetails()['hosted_fields_payment_id']); + + /** @var PaymentMethodInterface $method */ + $method = $payment->getMethod(); + $lockKey = RefundDetailsLockKey::forPaymentId($payment->getId()); + + $this->runLockedRefund( + $lockKey, + ['sylius_payment_id' => $payment->getId(), 'refund_id' => $refundId], + function () use ($payment, $method, $amount, $refundId): void { + /** @var RefundPayment $refundPayment */ + $refundPayment = $this->refundPaymentRepository->findOneBy(['id' => $refundId]); + + if ($this->payplugRefundHistoryRepository->findOneBy(['refundPayment' => $refundPayment]) instanceof RefundHistory) { + $this->logger->info('[PayPlug][UPC] Refund already recorded for this refund id, skipping duplicate call.', ['refund_id' => $refundId]); + + return; + } + + // Re-read now that the lock is held — see processHostedFields()'s own comment on + // its equivalent re-read. + $details = $payment->getDetails(); + $externalId = $this->createRefundOperation( + $method, + $details['hosted_fields_payment_id'], + PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), + $amount, + ['refund_id' => $refundId], + ); + + self::appendRefundEntry($payment, $details, self::normalizeRefunds($details), $refundId, $externalId, $amount); + + $refundHistory = new RefundHistory(); + $refundHistory + ->setExternalId(null) + ->setPayment($payment) + ->setRefundPayment($refundPayment) + ->setValue($amount) + ->setProcessed(true) + ; + $this->payplugRefundHistoryRepository->add($refundHistory); + }, + ); + } + + private static function extractFirstOperationId(string $body): ?string + { + $decoded = \json_decode($body, true); + $operationIds = \is_array($decoded) ? ($decoded['operationIds'] ?? null) : null; + $operationId = \is_array($operationIds) ? ($operationIds[0] ?? null) : null; + + return \is_string($operationId) && '' !== $operationId ? $operationId : null; + } + + private static function isHostedFields(PaymentInterface $payment): bool + { + /** @var PaymentMethodInterface $paymentMethod */ + $paymentMethod = $payment->getMethod(); + + return PayPlugGatewayFactory::isHostedFieldsConfig($paymentMethod->getGatewayConfig()); + } + private function prepare(PaymentInterface $payment): void { /** @var PaymentMethodInterface $paymentMethod */ @@ -133,6 +439,16 @@ private function prepare(PaymentInterface $payment): void return; } + // UHF has no "payment_id" detail (see resolveHostedFieldsIds() in + // CaptureHostedPaymentRequestHandler — it stores hosted_fields_payment_id instead), so the + // check below would otherwise misfire the "refunded locally only" flash message on every + // UHF refund even though process()/processWithAmount() now genuinely call the Unified API + // for it. The legacy $payPlugApiClient this method would otherwise build below is unused + // by the UHF path, so skipping it here also avoids an unnecessary token mint. + if (self::isHostedFields($payment)) { + return; + } + if (!isset($details['payment_id'])) { $this->requestStack->getSession()->getFlashBag()->add( 'info', diff --git a/src/Upc/GatewayCredentialsResolver.php b/src/Upc/GatewayCredentialsResolver.php new file mode 100644 index 00000000..392f9624 --- /dev/null +++ b/src/Upc/GatewayCredentialsResolver.php @@ -0,0 +1,47 @@ +getGatewayConfig()?->getConfig() ?? []; + $accountId = $gatewayConfig[PayPlugGatewayFactory::HF_IDENTIFIER] ?? null; + $submerchantExternalId = $gatewayConfig[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? null; + if (!\is_string($accountId) || '' === $accountId || !\is_string($submerchantExternalId) || '' === $submerchantExternalId) { + throw new \LogicException('Hosted Fields account id or submerchant id is not configured for this payment method.'); + } + + return [$accountId, $submerchantExternalId]; + } +} diff --git a/src/Upc/PaymentCaptureContextBuilder.php b/src/Upc/PaymentCaptureContextBuilder.php index 1716ae41..d7d1da70 100644 --- a/src/Upc/PaymentCaptureContextBuilder.php +++ b/src/Upc/PaymentCaptureContextBuilder.php @@ -4,7 +4,6 @@ namespace PayPlug\SyliusPayPlugPlugin\Upc; -use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayplugUnifiedCore\Dto\BrowserDto; use PayplugUnifiedCore\Dto\CommonFieldsDto; use PayplugUnifiedCore\Dto\CustomerDto; @@ -41,14 +40,7 @@ public function __construct( */ public function resolveGatewayCredentials(PaymentMethodInterface $method): array { - $gatewayConfig = $method->getGatewayConfig()?->getConfig() ?? []; - $accountId = $gatewayConfig[PayPlugGatewayFactory::HF_IDENTIFIER] ?? null; - $submerchantExternalId = $gatewayConfig[PayPlugGatewayFactory::HF_SUB_MERCHANT_ID] ?? null; - if (!\is_string($accountId) || '' === $accountId || !\is_string($submerchantExternalId) || '' === $submerchantExternalId) { - throw new \LogicException('Hosted Fields account id or submerchant id is not configured for this payment method.'); - } - - return [$accountId, $submerchantExternalId]; + return GatewayCredentialsResolver::resolve($method); } public function buildCommonFields( diff --git a/src/Upc/RefundCreatorInterface.php b/src/Upc/RefundCreatorInterface.php new file mode 100644 index 00000000..e692005d --- /dev/null +++ b/src/Upc/RefundCreatorInterface.php @@ -0,0 +1,31 @@ +httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $this->configurationRepository->getClientId(), + $this->configurationRepository->getClientSecret(), + ); + + return $service->createRefund( + $operationId, + $accountId, + $orderId, + \sprintf('Refund for order %s', $orderId), + $subMerchantExternalId, + $amount, + ); + } +} diff --git a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php index f1c0338d..6a222db4 100644 --- a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php +++ b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php @@ -409,4 +409,231 @@ public function testTreat_onPaidOutcomeWithSaveCardRequestedAndCardAlreadySaved_ $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); } + + /** + * When the notification's operation id matches one recorded under $details['refunds'] + * (RefundPaymentProcessor stores it there for both full and partial UHF refunds), this is a + * refund confirmation, not the payment's own outcome — ExecCodeMapper's "0000" => PAID mapping + * would otherwise misreport a successful refund as the payment being paid. The amount check + * must use the refund's own recorded amount (500), not the payment's full amount (1000). + */ + public function testTreat_onNotificationMatchingAKnownRefundId_appliesRefundedInsteadOfThePaymentOutcome(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_1'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * A refund confirmation is matched against its OWN recorded amount (500), not the payment's + * full amount (1000) — the pre-fix behavior (comparing against the payment's full amount) + * would reject every partial-refund confirmation, which is exactly the bug this feature fixes. + */ + public function testTreat_onRefundNotificationAmountMismatch_logsAndSkipsWithoutApplyingTheOutcome(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 400]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->logger->expects(self::once())->method('error'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * A full UHF refund records its operation id with internal_id: null (RefundPaymentProcessor:: + * processHostedFields() has no Sylius $refundId to attach) — still resolvable and REFUNDED. + */ + public function testTreat_onFullRefundNotification_appliesRefundedUsingTheFullRefundAmount(): void + { + $body = \json_encode(['id' => 'op_refund_full', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + $details = ['refunds' => [['internal_id' => null, 'id' => 'op_refund_full', 'amount' => 1000]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_full')->willReturn(false); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * RefundPaymentProcessor's createRefund() call can return a 2xx response with no operationIds + * (logged as an error there) — the refund entry it records then has id: null, so this + * confirmation's own operationId ("op_refund_unresolved") can never match it by id. Falling + * back to the unresolved entry's own recorded amount (500) is what still lets this be + * classified as REFUNDED instead of being dropped or misapplied as a plain payment + * confirmation. + */ + public function testTreat_onNotificationForARefundWithNoCapturedOperationId_fallsBackToTheUnresolvedRefundEntry(): void + { + $body = \json_encode(['id' => 'op_refund_unresolved', 'execCode' => '0000', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => null, 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_unresolved')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_unresolved'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * The refund's own execCode indicates failure (anything other than "0000") — the outcome + * must never be forced to REFUNDED (money never moved), nor passed through 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. Only logging + + * idempotency tracking happen; orderStateMutator must never be called. The matched refund + * entry is also flagged 'failed' => true on the Payment itself — see + * RefundPaymentProcessor::sumRecordedRefunds(), which relies on this flag to exclude money + * that was accepted synchronously but never actually moved from a later full refund's + * remaining-balance calculation. + */ + public function testTreat_onRefundNotificationWithFailureExecCode_neverTouchesThePaymentStateButStillMarksTreated(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false); + + $payment = $this->payment(42, 1000, null, $details); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details): bool { + return [[ + 'internal_id' => 77, + 'id' => 'op_refund_1', + 'amount' => 500, + 'failed' => true, + ]] === $details['refunds']; + }, + )); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_1'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->logger->expects(self::once())->method('error'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * The 'failed' flag write goes through RefundDetailsLockKey — the same lock key + * RefundPaymentProcessor::processHostedFields()/processHostedFieldsWithAmount() acquire + * around their own (network-call-spanning) read-modify-write of this same + * $details['refunds'] array — not the per-operation 'payplug_upc_treat_' lock applyLocked() + * uses afterwards. Both locks are acquired/released here: the refund-details one first + * (guarding the setDetails() write below), the treat one second (guarding + * isTreated()/markTreated()/save()). + */ + public function testTreat_onRefundNotificationWithFailureExecCode_acquiresTheSharedRefundDetailsLockKeyBeforeWritingTheFailedFlag(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false); + + $acquiredKeys = []; + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturnCallback(static function (string $key, int $ttl) use (&$acquiredKeys): bool { + $acquiredKeys[] = [$key, $ttl]; + + return true; + }); + $this->handler = new HostedFieldsWebhookNotificationHandler( + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + $this->lock, + $this->logger, + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), + ); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + + self::assertSame( + [['payplug_upc_refund_details_42', 30], ['payplug_upc_treat_op_refund_1', 30]], + $acquiredKeys, + ); + } + + /** + * A refund creation (RefundPaymentProcessor::processHostedFields()/ + * processHostedFieldsWithAmount()) is in progress for this payment right now, holding + * RefundDetailsLockKey. The notification must NOT be marked treated in that case — returning + * without ever calling applyLocked() leaves isTreated()/markTreated() untouched, so a later + * redelivery of the same notification gets a fresh chance to record the 'failed' flag once + * that refund creation has released the lock — instead of the flag being silently lost forever + * because this delivery was marked treated without ever recording it. + */ + public function testTreat_onRefundNotificationWithFailureExecCode_whenRefundDetailsLockCannotBeAcquired_doesNotMarkTreated(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturnCallback( + static fn (string $key): bool => 'payplug_upc_refund_details_42' !== $key, + ); + $this->handler = new HostedFieldsWebhookNotificationHandler( + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + $this->lock, + $this->logger, + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), + ); + + $payment = $this->payment(42, 1000, null, $details); + $payment->expects(self::never())->method('setDetails'); + $this->paymentRepository->expects(self::never())->method('isTreated'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + $this->orderStateMutator->expects(self::never())->method('apply'); + // Once for the "non-success outcome" log, once for the lock-contention log. + $this->logger->expects(self::exactly(2))->method('error'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * A delayed/redelivered copy of the ORIGINAL payment-creation notification must never be + * misclassified as a refund confirmation just because this payment also has an unresolved + * (id: null) refund entry sitting in $details['refunds'] — the known payment-creation + * operation id (hosted_fields_operation_id) excludes it from the unresolved-entry fallback. + */ + public function testTreat_onRedeliveredPaymentNotification_isNotMisclassifiedAsTheUnresolvedRefund(): void + { + $body = \json_encode(['id' => 'op_payment_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + $details = [ + 'hosted_fields_operation_id' => 'op_payment_1', + 'refunds' => [['internal_id' => 77, 'id' => null, 'amount' => 500]], + ]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_payment_1')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_payment_1'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } } diff --git a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php index 3a2d3763..1b05e29f 100644 --- a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php +++ b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php @@ -14,9 +14,13 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\RefundPaymentProcessor; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface; +use PayplugUnifiedCore\Contracts\ILock; +use PayplugUnifiedCore\Exceptions\ApiException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; @@ -42,6 +46,10 @@ final class RefundPaymentProcessorTest extends TestCase private PayPlugApiClientInterface&MockObject $apiClient; + private RefundCreatorInterface&MockObject $refundCreator; + + private ILock&MockObject $lock; + private RefundPaymentProcessor $processor; protected function setUp(): void @@ -53,6 +61,9 @@ protected function setUp(): void $this->payplugRefundHistoryRepository = $this->createMock(RefundHistoryRepositoryInterface::class); $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); $this->apiClient = $this->createMock(PayPlugApiClientInterface::class); + $this->refundCreator = $this->createMock(RefundCreatorInterface::class); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); $this->apiClientFactory->method('createForPaymentMethod')->willReturn($this->apiClient); @@ -63,6 +74,8 @@ protected function setUp(): void $this->refundPaymentRepository, $this->payplugRefundHistoryRepository, $this->apiClientFactory, + $this->refundCreator, + $this->lock, ); } @@ -238,6 +251,481 @@ public function testProcessWithAmount_apiThrowsException_throwsUpdateHandlingExc $this->processor->processWithAmount($payment, 300, 42); } + // ------------------------------------------------------------------------- + // process() — Hosted Fields (UHF) full refund → calls RefundCreatorInterface + // ------------------------------------------------------------------------- + + /** + * Calls process() with a Hosted-Fields-configured payment. Verifies the UHF refund creator + * is called with the payment's hosted_fields_payment_id and no amount (full refund), and the + * legacy PayPlugApiClient is never touched. + */ + public function testProcess_hostedFields_callsRefundCreatorWithoutAmount(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']); + + $this->refundCreator->expects(self::once()) + ->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => '{}']); + $this->apiClient->expects(self::never())->method('refundPayment'); + + $this->processor->process($payment); + } + + /** + * A full refund now records the refund's own operation id under $details['refunds'] (with a + * null internal_id, since there's no Sylius RefundPayment/$refundId in this flow) — otherwise + * HostedFieldsWebhookNotificationHandler could never resolve the payment for the async webhook + * confirming this refund, since PaymentRepository::findOneByPayPlugPaymentId() matches on + * ids present somewhere in Payment::details. + */ + public function testProcess_hostedFields_recordsTheRefundOperationIdInDetails(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details): bool { + return [[ + 'internal_id' => null, + 'id' => 'op_ref_full', + 'amount' => 2400, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * A full refund (process()) triggered after an earlier partial refund must record the + * REMAINING amount actually refunded by omitting $amount to createRefund() — not + * $payment->getAmount() (the original total, 2400 here) — per + * UnifiedApiPaymentService::createRefund()'s own documented "omitting $amount refunds the + * full remaining amount" behavior. Recording the original total instead would make + * matchesPayment() reject this refund's own webhook confirmation (500 already refunded, 1900 + * really remaining) forever. + */ + public function testProcess_hostedFields_afterAPriorPartialRefund_recordsTheRemainingAmountNotTheOriginalTotal(): void + { + $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]]; + $payment = $this->buildHostedFieldsPayment([ + 'hosted_fields_payment_id' => 'pay_hf_123', + 'refunds' => $existingRefunds, + ]); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details) use ($existingRefunds): bool { + return [...$existingRefunds, [ + 'internal_id' => null, + 'id' => 'op_ref_full', + 'amount' => 1900, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * An earlier refund attempt flagged 'failed' => true by + * HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() (its createRefund() call + * was accepted synchronously, but the async confirmation later reported it never actually + * completed) must not count against the remaining balance — a full refund triggered after it + * still records the ORIGINAL total (2400), not 2400 minus the failed attempt's amount. + */ + public function testProcess_hostedFields_afterAFailedPriorRefund_ignoresItInTheRemainingAmountCalculation(): void + { + $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500, 'failed' => true]]; + $payment = $this->buildHostedFieldsPayment([ + 'hosted_fields_payment_id' => 'pay_hf_123', + 'refunds' => $existingRefunds, + ]); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details) use ($existingRefunds): bool { + return [...$existingRefunds, [ + 'internal_id' => null, + 'id' => 'op_ref_full', + 'amount' => 2400, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * processHostedFields() must compute its remaining-balance sum from Payment::details read + * AFTER acquiring RefundDetailsLockKey, not from a snapshot taken before it — otherwise a + * concurrent HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() call (run + * while this refund creation's own network call to createRefund() is in flight, both holding + * the same lock key at different times) would have its 'failed' flag silently dropped when + * this method's own stale pre-lock $details gets written back. Simulated here via + * willReturnOnConsecutiveCalls: the first two getDetails() calls (prepare()'s own read, then + * the pre-lock Assert::string(hosted_fields_payment_id) check) see the refund as NOT failed + * yet; the third (taken once the lock is held, per processHostedFields()'s own re-read) sees + * it flagged failed — exactly as if the webhook's write landed in between. Only that third + * snapshot may ever reach setDetails(). + */ + public function testProcess_hostedFields_reReadsDetailsAfterAcquiringTheLock_soAConcurrentlyFlaggedFailedRefundIsNotLost(): void + { + $beforeLock = [ + 'hosted_fields_payment_id' => 'pay_hf_123', + 'refunds' => [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]], + ]; + $afterLock = [ + 'hosted_fields_payment_id' => 'pay_hf_123', + 'refunds' => [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500, 'failed' => true]], + ]; + + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('000000042'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturnOnConsecutiveCalls($beforeLock, $beforeLock, $afterLock); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(2400); + $payment->method('getId')->willReturn(42); + + // The failed entry (500) must NOT be subtracted from the original total (2400): had the + // pre-lock snapshot been used instead, this would incorrectly come out to 1900. + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details) use ($afterLock): bool { + return [...$afterLock['refunds'], [ + 'internal_id' => null, + 'id' => 'op_ref_full', + 'amount' => 2400, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * The lock key for a full refund and a partial refund on the SAME payment must be identical — + * otherwise the two can run concurrently and both succeed, double-refunding money, exactly + * the scenario the lock exists to prevent. + */ + public function testProcess_andProcessWithAmount_useTheSameLockKeyForTheSamePayment(): void + { + $acquiredKeys = []; + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturnCallback(static function (string $key) use (&$acquiredKeys): bool { + $acquiredKeys[] = $key; + + return true; + }); + $this->processor = new RefundPaymentProcessor( + $this->requestStack, + $this->logger, + $this->translator, + $this->refundPaymentRepository, + $this->payplugRefundHistoryRepository, + $this->apiClientFactory, + $this->refundCreator, + $this->lock, + ); + + $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']); + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment); + $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null); + + $this->processor->process($this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123'])); + $this->processor->processWithAmount($this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']), 500, 77); + + self::assertCount(2, $acquiredKeys); + self::assertSame($acquiredKeys[0], $acquiredKeys[1]); + } + + /** + * A full refund has no RefundHistory/refundId to check-then-act on (mirrors the legacy + * process()'s own lack of one), so the ILock guard is its only protection against a + * concurrent second call for the same payment double-refunding. + */ + public function testProcess_hostedFields_whenLockCannotBeAcquired_throwsUpdateHandlingExceptionWithoutCallingRefundCreator(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(false); + $this->processor = new RefundPaymentProcessor( + $this->requestStack, + $this->logger, + $this->translator, + $this->refundPaymentRepository, + $this->payplugRefundHistoryRepository, + $this->apiClientFactory, + $this->refundCreator, + $this->lock, + ); + + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->logger->expects(self::once())->method('error'); + + $this->processor->process($payment); + } + + /** + * The UHF refund creator throws an ApiException (a UPC exception, always a subtype of the + * base \Exception). Verifies the processor catches it the same way as the legacy client's + * exceptions, logs an error, and re-throws UpdateHandlingException. + */ + public function testProcess_hostedFields_apiExceptionThrowsUpdateHandlingException(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_fail']); + + $this->refundCreator->method('createRefund')->willThrowException(new ApiException('API error')); + $this->logger->expects(self::once())->method('error'); + + $this->processor->process($payment); + } + + // ------------------------------------------------------------------------- + // processWithAmount() — Hosted Fields (UHF) partial refund → RefundCreatorInterface + + // RefundHistory bookkeeping + // ------------------------------------------------------------------------- + + /** + * Calls processWithAmount() on a Hosted-Fields payment. Verifies the UHF refund creator is + * called with the amount, setDetails() records the refund's own operation id (from the + * response's operationIds[0]) under $details['refunds'], and a RefundHistory entry is + * persisted — externalId stays null, mirroring the legacy flow's own convention that this + * field is reserved for the async webhook-confirmed refund, not the synchronous BO one. + */ + public function testProcessWithAmount_hostedFields_createsRefundHistoryEntryFromOperationIds(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details): bool { + return [[ + 'internal_id' => 77, + 'id' => 'op_ref_1', + 'amount' => 500, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator + ->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_partial', '000000042', 500) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_1']])]); + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->with(['id' => 77])->willReturn($refundPayment); + + $this->payplugRefundHistoryRepository->expects(self::once())->method('add')->with(self::callback( + static function (RefundHistory $refundHistory): bool { + return null === $refundHistory->getExternalId() && + 500 === $refundHistory->getValue() && + $refundHistory->isProcessed(); + }, + )); + + $this->processor->processWithAmount($payment, 500, 77); + } + + public function testProcessWithAmount_hostedFields_apiExceptionThrowsUpdateHandlingException(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial_fail']); + + $this->refundCreator->method('createRefund')->willThrowException(new ApiException('fail')); + $this->logger->expects(self::once())->method('error'); + + $this->processor->processWithAmount($payment, 300, 42); + } + + /** + * The whole check-then-act sequence (RefundHistory lookup + createRefund() call) is also + * guarded by ILock, keyed by $refundId: without it, two concurrent calls for the same + * $refundId could both pass the RefundHistory check below before either persists one, and + * both would go on to call createRefund() — a lock is what actually serializes the two + * attempts, a plain check-then-act on its own cannot. + */ + public function testProcessWithAmount_hostedFields_whenLockCannotBeAcquired_throwsUpdateHandlingExceptionWithoutCallingRefundCreator(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(false); + $this->processor = new RefundPaymentProcessor( + $this->requestStack, + $this->logger, + $this->translator, + $this->refundPaymentRepository, + $this->payplugRefundHistoryRepository, + $this->apiClientFactory, + $this->refundCreator, + $this->lock, + ); + + $this->refundPaymentRepository->expects(self::never())->method('findOneBy'); + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->logger->expects(self::once())->method('error'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + /** + * Unlike the legacy flow (which forwards Sylius's own $refundId to the API as a de-facto + * idempotency key), UPC's createRefund() has no idempotency-key parameter at all. A retried + * delivery of the same RefundPaymentGenerated message (e.g. after a transient failure once + * the RefundHistory for this $refundId was already persisted) must not call createRefund() + * again — this is the local guard closing that window. + */ + public function testProcessWithAmount_hostedFields_alreadyProcessed_skipsDuplicateRefundCall(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + $payment->expects(self::never())->method('setDetails'); + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->with(['id' => 77])->willReturn($refundPayment); + + $existingRefundHistory = $this->createMock(RefundHistory::class); + $this->payplugRefundHistoryRepository + ->method('findOneBy') + ->with(['refundPayment' => $refundPayment]) + ->willReturn($existingRefundHistory); + + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->payplugRefundHistoryRepository->expects(self::never())->method('add'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + /** + * createRefund() returns a 2xx response whose body has no operationIds (malformed/unexpected + * shape). The refund still succeeded (money moved) and is still recorded, but with no + * tracking id — this must not pass silently, so an error is logged (actionable: without an + * operation id, HostedFieldsWebhookNotificationHandler can never match the eventual webhook + * confirmation back to this refund). + */ + public function testProcessWithAmount_hostedFields_onMissingOperationIds_logsAnError(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + + $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']); + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment); + $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null); + + $this->logger->expects(self::once())->method('error'); + $this->payplugRefundHistoryRepository->expects(self::once())->method('add'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + /** + * Two sequential partial refunds against the same Hosted-Fields payment must accumulate in + * $details['refunds'] rather than the second call overwriting the first — mirrors the + * legacy gateway's own Behat coverage for this exact scenario ("Two Partial refund of one + * product"), which UHF otherwise has no equivalent for at any test level. + */ + public function testProcessWithAmount_hostedFields_secondPartialRefund_appendsToExistingRefunds(): void + { + $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]]; + $payment = $this->buildHostedFieldsPayment([ + 'hosted_fields_payment_id' => 'pay_hf_partial', + 'refunds' => $existingRefunds, + ]); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details) use ($existingRefunds): bool { + return [...$existingRefunds, [ + 'internal_id' => 78, + 'id' => 'op_ref_2', + 'amount' => 300, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator + ->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_partial', '000000042', 300) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_2']])]); + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->with(['id' => 78])->willReturn($refundPayment); + $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null); + + $this->processor->processWithAmount($payment, 300, 78); + } + + /** + * prepare() must skip the legacy PayPlugApiClientFactory entirely for a Hosted-Fields + * payment — building it would mint an OAuth2 token that's never used. + */ + public function testProcessWithAmount_hostedFields_neverCreatesTheLegacyApiClient(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + + $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']); + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment); + $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + /** + * When the payment has no order (edge case), orderId falls back to the payment's own id — + * same convention CaptureHostedPaymentRequestHandler already uses at creation time. + */ + public function testProcess_hostedFields_withNoOrder_fallsBackToThePaymentIdAsOrderId(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturn(['hosted_fields_payment_id' => 'pay_hf_no_order']); + $payment->method('getOrder')->willReturn(null); + $payment->method('getId')->willReturn(99); + $payment->method('getAmount')->willReturn(2400); + + $this->refundCreator->expects(self::once()) + ->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_no_order', '99', null) + ->willReturn(['status' => 200, 'body' => '{}']); + + $this->processor->process($payment); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -256,4 +744,26 @@ private function buildPayment(string $factoryName, array $details): PaymentInter return $payment; } + + private function buildHostedFieldsPayment(array $details): PaymentInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('000000042'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturn($details); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(2400); + $payment->method('getId')->willReturn(42); + + return $payment; + } } diff --git a/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php b/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php new file mode 100644 index 00000000..d2ac4e61 --- /dev/null +++ b/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php @@ -0,0 +1,68 @@ +createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'submerchant_123', + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + self::assertSame(['acct_123', 'submerchant_123'], GatewayCredentialsResolver::resolve($method)); + } + + public function testResolve_withNoGatewayConfig_throws(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn(null); + + $this->expectException(\LogicException::class); + + GatewayCredentialsResolver::resolve($method); + } + + public function testResolve_withMissingAccountId_throws(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => 'submerchant_123', + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $this->expectException(\LogicException::class); + + GatewayCredentialsResolver::resolve($method); + } + + public function testResolve_withMissingSubmerchantId_throws(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $this->expectException(\LogicException::class); + + GatewayCredentialsResolver::resolve($method); + } +} diff --git a/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php b/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php new file mode 100644 index 00000000..f3c5f4ae --- /dev/null +++ b/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php @@ -0,0 +1,38 @@ +expectException(\LogicException::class); + + RefundDetailsLockKey::forPaymentId([42]); + } + + /** + * The same payment id must always resolve to the same key regardless of caller — this is what + * lets RefundPaymentProcessor and HostedFieldsWebhookNotificationHandler actually serialize + * against each other. + */ + public function testForPaymentId_isStableAcrossCalls(): void + { + self::assertSame(RefundDetailsLockKey::forPaymentId(42), RefundDetailsLockKey::forPaymentId(42)); + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php new file mode 100644 index 00000000..bda89899 --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php @@ -0,0 +1,190 @@ +unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $this->configurationRepository->method('getClientId')->willReturn('client_abc'); + $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz'); + + $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); + $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); + + $this->creator = new UnifiedApiRefundCreator( + $this->unifiedApiHttpClient, + $tokenManager, + $this->configurationRepository, + 'https://api.payplug.com', + ); + + $this->tokenCache->method('get')->willReturn('cached-jwt'); + } + + public function testCreateRefund_withoutAmount_sendsAFullRefundUsingTheMethodsOwnAccountId(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Refund for order order_1', + 'submerchantExternalId' => 'submerchant_123', + ], + ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'], + ) + ->willReturn(['status' => 200, 'body' => '{"execCode":"0000"}']); + + $result = $this->creator->createRefund($method, 'pay_123', 'order_1'); + + self::assertSame(['status' => 200, 'body' => '{"execCode":"0000"}'], $result); + } + + public function testCreateRefund_withAmount_sendsAPartialRefund(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Refund for order order_1', + 'submerchantExternalId' => 'submerchant_123', + 'amount' => 500, + ], + ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'], + ) + ->willReturn(['status' => 200, 'body' => '{}']); + + $this->creator->createRefund($method, 'pay_123', 'order_1', 500); + } + + public function testCreateRefund_onA404Response_throwsPaymentNotFoundException(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 404, 'body' => '{}']); + + $this->expectException(PaymentNotFoundException::class); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + } + + public function testCreateRefund_onNon2xxResponse_throwsApiException(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 500, 'body' => '{}']); + + $this->expectException(ApiException::class); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + } + + public function testCreateRefund_withANonPositiveAmount_throwsRefundAmountExceptionBeforeAnyNetworkCall(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123', 'submerchant_123'); + $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); + + $this->expectException(RefundAmountException::class); + + $this->creator->createRefund($method, 'pay_123', 'order_1', 0); + } + + /** + * Same guard CaptureHostedPaymentRequestHandler::resolveGatewayCredentials() already applies + * at payment-creation time — a blank submerchant id must fail fast and locally rather than + * round-tripping to the Unified API for a 400 ("subMerchantExternalId is missing") that's + * indistinguishable from any other malformed-request cause once wrapped in ApiException. + */ + public function testCreateRefund_withNoConfiguredSubmerchantId_throwsLogicExceptionBeforeAnyNetworkCall(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123', ''); + + $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Hosted Fields account id or submerchant id is not configured for this payment method.'); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + } + + /** + * Credentials must come from $method's own gateway config, not from whichever + * Hosted-Fields-configured payment method IConfigurationRepository's backing store happens to + * resolve first — otherwise a merchant with more than one such payment method could have a + * refund routed to the wrong account/submerchant. + */ + public function testCreateRefund_withNoConfiguredAccountId_throwsLogicExceptionBeforeAnyNetworkCall(): void + { + $method = $this->buildHostedFieldsPaymentMethod('', 'submerchant_123'); + + $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Hosted Fields account id or submerchant id is not configured for this payment method.'); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + } + + private function buildHostedFieldsPaymentMethod( + string $accountId, + string $submerchantExternalId, + ): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => $accountId, + PayPlugGatewayFactory::HF_SUB_MERCHANT_ID => $submerchantExternalId, + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $method; + } +}