From 4938e647b4faa2cb18eefeaf20e132182e672a66 Mon Sep 17 00:00:00 2001 From: adumont-payplug Date: Thu, 8 Oct 2026 15:23:45 +0200 Subject: [PATCH 1/3] PRE-3722: bump UPC for the new Unified API contract Require UPC 1.2.1 and read the alias from storedId first. Backfill the payment id from the webhook, refuse refunds cleanly when it is missing, and make the SDK URL a parameter that defaults to production. --- CHANGELOG.md | 16 +- composer.json | 2 +- composer.lock | 14 +- config/services.yaml | 6 + .../CaptureAliasPaymentRequestHandler.php | 4 +- .../CaptureHostedPaymentRequestHandler.php | 1 + src/Command/PaymentCaptureFlow.php | 6 +- src/Controller/UnifiedApiIpnAction.php | 49 ++- ...HostedFieldsWebhookNotificationHandler.php | 26 +- .../RefundPaymentProcessor.php | 64 ++- src/Repository/PaymentRepository.php | 30 ++ src/Repository/PaymentRepositoryInterface.php | 9 + src/Twig/PayPlugExtension.php | 7 + .../CardDataFromPaymentMethodExtractor.php | 18 +- src/Upc/HostedFieldsPaymentIdBackfiller.php | 90 ++++ src/Upc/HostedFieldsPaymentIdExtractor.php | 32 ++ src/Upc/PaymentCaptureOutcomeApplier.php | 23 + templates/shop/hosted_fields/index.html.twig | 2 +- .../CaptureAliasPaymentRequestHandlerTest.php | 57 +++ ...CaptureHostedPaymentRequestHandlerTest.php | 54 +++ .../HostedFieldsSdkUrlConfigurationTest.php | 48 +++ .../Controller/UnifiedApiIpnActionTest.php | 106 +++++ ...edFieldsWebhookNotificationHandlerTest.php | 408 ++++++++++++++++++ .../RefundPaymentProcessorTest.php | 134 ++++++ .../PHPUnit/Repository/Fixture/QueryOrder.php | 34 ++ .../Repository/Fixture/QueryPayment.php | 37 ++ .../Repository/PaymentRepositoryTest.php | 153 +++++++ tests/PHPUnit/Twig/PayPlugExtensionTest.php | 24 +- ...CardDataFromPaymentMethodExtractorTest.php | 58 +++ .../HostedFieldsPaymentIdExtractorTest.php | 49 +++ .../Upc/PaymentCaptureOutcomeApplierTest.php | 43 ++ tests/PHPUnit/Upc/UnifiedApiContractTest.php | 45 ++ translations/messages.en.yml | 1 + translations/messages.fr.yml | 1 + translations/messages.it.yml | 1 + 35 files changed, 1604 insertions(+), 48 deletions(-) create mode 100644 src/Upc/HostedFieldsPaymentIdBackfiller.php create mode 100644 src/Upc/HostedFieldsPaymentIdExtractor.php create mode 100644 tests/PHPUnit/Config/HostedFieldsSdkUrlConfigurationTest.php create mode 100644 tests/PHPUnit/Repository/Fixture/QueryOrder.php create mode 100644 tests/PHPUnit/Repository/Fixture/QueryPayment.php create mode 100644 tests/PHPUnit/Repository/PaymentRepositoryTest.php create mode 100644 tests/PHPUnit/Upc/HostedFieldsPaymentIdExtractorTest.php create mode 100644 tests/PHPUnit/Upc/UnifiedApiContractTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 32548821..baaf630e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Hosted Fields deferred capture**: with deferred capture enabled, Hosted Fields payments are created as authorizations and captured (in full, partially, several times) or cancelled (in full or partially) from the admin order screen, with the remaining amount and capture deadline shown +- The Hosted Fields JS SDK URL is configurable through `PAYPLUG_HOSTED_FIELDS_SDK_URL` and defaults + to the production SDK (`v2.2.0`): QA, staging and local setups must set it to their own SDK URL > [!IMPORTANT] > Merchants will need to contact support to switch to the new authentication method. @@ -43,6 +45,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 merchant's PayPlug account - Oney instalment options, the Oney availability check and Apple Pay now resolve the gateway serving the current channel instead of an arbitrary one +- Hosted Fields payments, with a new or a saved card, are accepted again by the Unified API, which + now reads the card token from `paymentMethod.hfToken` and the saved card from + `paymentMethod.storedId` +- Cards the shopper asked to save are stored again, including after a 3DS challenge (the alias is + read from `paymentMethod.storedId`) +- A Hosted Fields payment created without a payment reference recovers it from its payment webhook, + so it can still be refunded; when it cannot, the refund is refused with a clear back-office + message in addition to Sylius's own generic error message, and nothing is sent to PayPlug ### Breaking changes for anyone extending the plugin @@ -59,8 +69,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | `PaymentMethodRepositoryInterface::findOneByGatewayName()` — **deprecated**, returns an arbitrary config when several share a factory name | `findOneEnabledByGatewayNameAndChannel($factoryName, $channel)` | | `OneyExtension::__construct()` — `$gatewayConfigRepository` dropped, `$paymentMethodRepository` is now the plugin's `PaymentMethodRepositoryInterface` | inject the plugin repository | | `OneySupportedPaymentChoiceProvider::__construct()` | now also takes a `ChannelContextInterface` | +| `PaymentRepositoryInterface` gains `findAwaitingHostedFieldsPaymentId(string $orderNumber, int $amount): array` | implement it in any custom implementation | +| `PayPlugExtension::__construct()` | now also takes `string $hostedFieldsSdkUrl` (bound to `%payplug.hosted_fields_sdk_url%`) | +| `HostedFieldsWebhookNotificationHandler::__construct()` (non-final class) | now also takes a `HostedFieldsPaymentIdBackfiller` as its last argument | -Requires `payplug/unified-plugin-core ^1.1.2` (for the nullable `TokenOutput::$idToken`). +Requires `payplug/unified-plugin-core ^1.2.1` (for the nullable `TokenOutput::$idToken` and the +Unified API payment contract: `paymentMethod.hfToken`, `paymentMethod.storedId`). > [!NOTE] > A gateway connected before this release shows a "re-authenticate" placeholder instead of the diff --git a/composer.json b/composer.json index a9069864..3ea65550 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "ext-json": "*", "giggsey/libphonenumber-for-php": "^8.12", "payplug/payplug-php": "^4.0", - "payplug/unified-plugin-core": "^1.1.3", + "payplug/unified-plugin-core": "^1.2.1", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", diff --git a/composer.lock b/composer.lock index 1d20f3e4..16be57bc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dcc0d25a7d712c2284f331538785b388", + "content-hash": "39a63c300688d00971a9a43114e99ec7", "packages": [ { "name": "alcohol/iso4217", @@ -5748,16 +5748,16 @@ }, { "name": "payplug/unified-plugin-core", - "version": "1.3.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/payplug/unified-plugin-core.git", - "reference": "050f14e31c88e3ac3e50a2ec4707776110e1dc7f" + "reference": "05423ee4bae1bef5e1374ba1aeadf127c9bb0dca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/050f14e31c88e3ac3e50a2ec4707776110e1dc7f", - "reference": "050f14e31c88e3ac3e50a2ec4707776110e1dc7f", + "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/05423ee4bae1bef5e1374ba1aeadf127c9bb0dca", + "reference": "05423ee4bae1bef5e1374ba1aeadf127c9bb0dca", "shasum": "" }, "require": { @@ -5794,9 +5794,9 @@ "description": "Core foundations shared library for Payplug e-commerce plugins.", "support": { "issues": "https://github.com/payplug/unified-plugin-core/issues", - "source": "https://github.com/payplug/unified-plugin-core/tree/1.3.0" + "source": "https://github.com/payplug/unified-plugin-core/tree/1.2.1" }, - "time": "2026-09-29T08:57:15+00:00" + "time": "2026-10-08T07:20:41+00:00" }, { "name": "payum/core", diff --git a/config/services.yaml b/config/services.yaml index ac50d0bd..328a2c40 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -13,6 +13,11 @@ parameters: # explicitly opt out for its own known-internal host, not as a general "skip TLS" switch. payplug.unified_api_verify_tls.default: true payplug.unified_api_verify_tls: '%env(bool:default:payplug.unified_api_verify_tls.default:PAYPLUG_UNIFIED_API_VERIFY_TLS)%' + # Hosted Fields JS SDK loaded by the checkout: one full https URL, because the host, the path + # and the pinned version all differ per environment. Defaults to the production SDK; QA and + # staging setups set PAYPLUG_HOSTED_FIELDS_SDK_URL to their own SDK URL. + payplug.hosted_fields_sdk_url.default: 'https://js.dalenys.com/hosted-fields/v2.2.0/hosted-fields.min.js' + payplug.hosted_fields_sdk_url: '%env(default:payplug.hosted_fields_sdk_url.default:PAYPLUG_HOSTED_FIELDS_SDK_URL)%' services: _defaults: @@ -29,6 +34,7 @@ services: $payplugOauthAudience: '%payplug.oauth_audience%' $unifiedApiBaseUrl: '%payplug.unified_api_base_url%' $unifiedApiVerifyTls: '%payplug.unified_api_verify_tls%' + $hostedFieldsSdkUrl: '%payplug.hosted_fields_sdk_url%' # `security.csrf.token_manager` is only registered while CSRF protection is enabled, so it is # injected with `@?`: a hard reference would break container compilation for an application diff --git a/src/Command/Handler/CaptureAliasPaymentRequestHandler.php b/src/Command/Handler/CaptureAliasPaymentRequestHandler.php index 93cf61c7..192b5dc1 100644 --- a/src/Command/Handler/CaptureAliasPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureAliasPaymentRequestHandler.php @@ -69,14 +69,16 @@ public function __invoke(CaptureAliasPaymentRequest $captureAliasPaymentRequest) return; } + $hostedFieldsIds = $this->contextBuilder->resolveHostedFieldsIds($output->body); $createdDetails = [ ...$payment->getDetails(), 'alias_id' => $card->getExternalId(), 'alias_payment_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), - ...$this->contextBuilder->resolveHostedFieldsIds($output->body), + ...$hostedFieldsIds, ]; // Before applyOutcome() below — see CaptureHostedPaymentRequestHandler's own comment. $payment->setDetails($common->capture ? $createdDetails : AuthorizationDetails::open($createdDetails, $output, $amount)); + $this->outcomeApplier->warnWhenPaymentIdMissing($payment, PaymentCaptureFlow::Alias, $hostedFieldsIds); $this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output); diff --git a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php index bf75760f..d7c6f1d8 100644 --- a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php +++ b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php @@ -82,6 +82,7 @@ public function __invoke(CaptureHostedPaymentRequest $captureHostedPaymentReques // Before applyOutcome() below, which reads this back to apply "authorize" rather than // "complete" for an authorization-only payment. $payment->setDetails($dto->common->capture ? $createdDetails : AuthorizationDetails::open($createdDetails, $output, $amountAndCurrency[0])); + $this->outcomeApplier->warnWhenPaymentIdMissing($payment, PaymentCaptureFlow::Hosted, $hostedFieldsIds); $this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output); diff --git a/src/Command/PaymentCaptureFlow.php b/src/Command/PaymentCaptureFlow.php index 074790fd..641c9641 100644 --- a/src/Command/PaymentCaptureFlow.php +++ b/src/Command/PaymentCaptureFlow.php @@ -5,9 +5,9 @@ namespace PayPlug\SyliusPayPlugPlugin\Command; /** - * Identifies which capture path a PaymentCaptureOutcomeApplier::failPaymentRequest() call - * came from, so its log message stays distinguishable between the two handlers without each - * caller passing a free-text string. + * Identifies which capture path a PaymentCaptureOutcomeApplier call came from, so its log + * messages stay distinguishable between the two handlers without each caller passing a + * free-text string. */ enum PaymentCaptureFlow: string { diff --git a/src/Controller/UnifiedApiIpnAction.php b/src/Controller/UnifiedApiIpnAction.php index f87b9d8e..161935aa 100644 --- a/src/Controller/UnifiedApiIpnAction.php +++ b/src/Controller/UnifiedApiIpnAction.php @@ -82,26 +82,55 @@ private function resolveHostedFieldsPayment(string $input): ?PaymentInterface { $content = json_decode($input, true); $id = \is_array($content) ? ($content['id'] ?? null) : null; - if (!\is_string($id) || '' === $id) { + if (!\is_array($content) || !\is_string($id) || '' === $id) { // if we are too fast canceling a payment before we got an answer from PayPlug gateway return null; } - $payment = $this->findPaymentWithRetry($id); - if (null === $payment) { - return null; - } + $payment = $this->findPaymentWithRetry($id) ?? $this->findPaymentAwaitingPaymentId($content); + + return null !== $payment && self::isOnHostedFields($payment) ? $payment : null; + } + // Defensive: this route is dedicated to Unified API traffic, so a resolved payment that isn't + // actually on a Unified API-backed config (currently, only Hosted Fields) is rejected rather + // than guessed at. Update this check if/when a second Unified API-backed payment method is + // added. + private static function isOnHostedFields(PaymentInterface $payment): bool + { $paymentMethod = $payment->getMethod(); Assert::isInstanceOf($paymentMethod, PaymentMethodInterface::class); $gateway = $paymentMethod->getGatewayConfig(); Assert::isInstanceOf($gateway, GatewayConfigInterface::class); - // Defensive: this route is dedicated to Unified API traffic, so a resolved payment that - // isn't actually on a Unified API-backed config (currently, only Hosted Fields) is - // rejected rather than guessed at. Update this check if/when a second Unified API-backed - // payment method is added. - return PayPlugGatewayFactory::isHostedFieldsConfig($gateway) ? $payment : null; + return PayPlugGatewayFactory::isHostedFieldsConfig($gateway); + } + + /** + * A payment whose creation response carried no id cannot be found by the webhook's id. It is + * then matched by order number and amount, and only when exactly one payment qualifies; + * HostedFieldsWebhookNotificationHandler cross-checks both again before applying anything. + * + * @param mixed[] $content the decoded webhook body + */ + private function findPaymentAwaitingPaymentId(array $content): ?PaymentInterface + { + $orderId = $content['orderId'] ?? null; + $amount = $content['amount'] ?? null; + if (!\is_string($orderId) || '' === $orderId || !\is_int($amount)) { + return null; + } + + $candidates = $this->paymentRepository->findAwaitingHostedFieldsPaymentId($orderId, $amount); + if (1 !== \count($candidates)) { + if ([] !== $candidates) { + $this->logger->warning('[PayPlug][UPC] Several payments await a payment id for this webhook, refusing to guess.', ['order_id' => $orderId]); + } + + return null; + } + + return $candidates[0]; } private function findPaymentWithRetry(string $id): ?PaymentInterface diff --git a/src/Handler/HostedFieldsWebhookNotificationHandler.php b/src/Handler/HostedFieldsWebhookNotificationHandler.php index 3cb8df07..d2b8a3df 100644 --- a/src/Handler/HostedFieldsWebhookNotificationHandler.php +++ b/src/Handler/HostedFieldsWebhookNotificationHandler.php @@ -8,6 +8,7 @@ use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationLockKey; use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationWebhookBody; use PayPlug\SyliusPayPlugPlugin\Upc\CardDataFromPaymentMethodExtractor; +use PayPlug\SyliusPayPlugPlugin\Upc\HostedFieldsPaymentIdBackfiller; use PayPlug\SyliusPayPlugPlugin\Upc\PaymentOrderIdResolver; use PayPlug\SyliusPayPlugPlugin\Upc\PayplugCardPersister; use PayPlug\SyliusPayPlugPlugin\Upc\RefundDetailsLockKey; @@ -26,9 +27,11 @@ /** * Verifies and applies a Unified API (Hosted Fields) webhook notification against a Payment - * already resolved by IpnAction, via PaymentRepositoryInterface::findOneByPayPlugPaymentId() — - * which matches on the webhook's own payment id being present anywhere in Payment::details, - * including the "hosted_fields_payment_id" key CaptureHostedPaymentRequestHandler stores there. + * already resolved by UnifiedApiIpnAction, via PaymentRepositoryInterface::findOneByPayPlugPaymentId() + * — which matches on the webhook's own payment id being present anywhere in Payment::details, + * including the "hosted_fields_payment_id" key the capture handlers store there — or, for a payment + * whose creation response carried no id, via findAwaitingHostedFieldsPaymentId(), which matches + * the webhook's order number and amount against a single active payment. * * The static, per-account IPN receiver counterpart to NotifyHostedPaymentRequestHandler, which * instead resolves its target Payment via a per-request PaymentRequest hash. Both share the same @@ -64,6 +67,7 @@ public function __construct( private ILock $lock, private LoggerInterface $logger, private PayplugCardPersister $cardPersister, + private HostedFieldsPaymentIdBackfiller $paymentIdBackfiller, ) { } @@ -279,11 +283,19 @@ private function applyLocked( return; } + // A backfill deferred by a busy lock leaves the notification untreated, so a redelivery + // retries it; re-applying the outcome then is a no-op (SyliusOrderStateMutator checks + // the transition first). + $backfillSettled = !$applyOutcome || PaymentOutcome::PAID !== $operationData->outcome || + $this->paymentIdBackfiller->backfill($payment, $rawBody); + $this->paymentRepository->save($operationData); if ($applyOutcome) { $this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), $operationData->outcome); } - $this->paymentRepository->markTreated($operationData->operationId); + if ($backfillSettled) { + $this->paymentRepository->markTreated($operationData->operationId); + } // AUTHORIZED too: a deferred-capture payment's card is saved once its authorization // is confirmed, exactly like an immediate one's once it is paid. Never for a @@ -300,9 +312,9 @@ private function applyLocked( // A 3DS-challenge capture never gets an alias back synchronously (CaptureHostedPaymentRequestHandler // only sees one on a direct, frictionless success) — this webhook, fired once the challenge is // validated, is the only place a 3DS payment's card ever gets saved. The alias/card metadata - // itself is already in $rawBody: confirmed the same paymentMethod.{id, card, details} shape as - // the operation resource CaptureHostedPaymentRequestHandler fetches separately, so no extra API - // call is needed here. + // itself is already in $rawBody: the same paymentMethod.{storedId, card, details} shape as the + // operation resource CaptureHostedPaymentRequestHandler fetches separately (the alias in + // storedId, paymentMethod.id read as a legacy fallback), so no extra API call is needed here. private function maybeSaveCard(PaymentInterface $payment, string $rawBody): void { $details = $payment->getDetails(); diff --git a/src/PaymentProcessing/RefundPaymentProcessor.php b/src/PaymentProcessing/RefundPaymentProcessor.php index 275c8fe1..1085f0b5 100644 --- a/src/PaymentProcessing/RefundPaymentProcessor.php +++ b/src/PaymentProcessing/RefundPaymentProcessor.php @@ -31,6 +31,7 @@ use Sylius\RefundPlugin\Entity\RefundPayment; use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Workflow\Attribute\AsCompletedListener; use Symfony\Component\Workflow\Event\CompletedEvent; use Symfony\Contracts\Translation\TranslatorInterface; @@ -161,10 +162,7 @@ public function processWithAmount(PaymentInterface $payment, int $amount, int $r */ 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']); + $hostedFieldsPaymentId = $this->resolveHostedFieldsPaymentId($payment); $originalAmount = $payment->getAmount(); if (null === $originalAmount) { throw new \LogicException('Payment amount is not set.'); @@ -174,7 +172,7 @@ private function processHostedFields(PaymentInterface $payment): void $method = $payment->getMethod(); $lockKey = RefundDetailsLockKey::forPaymentId($payment->getId()); - $this->runLockedRefund($lockKey, ['sylius_payment_id' => $payment->getId()], function () use ($payment, $method, $originalAmount): void { + $this->runLockedRefund($lockKey, ['sylius_payment_id' => $payment->getId()], function () use ($payment, $method, $originalAmount, $hostedFieldsPaymentId): 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 @@ -182,7 +180,7 @@ private function processHostedFields(PaymentInterface $payment): void $details = $payment->getDetails(); $externalId = $this->createRefundOperation( $method, - $details['hosted_fields_payment_id'], + $hostedFieldsPaymentId, PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), null, $payment->getCurrencyCode(), @@ -367,8 +365,7 @@ private static function sumRecordedRefunds(array $refunds): int */ 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']); + $hostedFieldsPaymentId = $this->resolveHostedFieldsPaymentId($payment); /** @var PaymentMethodInterface $method */ $method = $payment->getMethod(); @@ -377,7 +374,7 @@ private function processHostedFieldsWithAmount(PaymentInterface $payment, int $a $this->runLockedRefund( $lockKey, ['sylius_payment_id' => $payment->getId(), 'refund_id' => $refundId], - function () use ($payment, $method, $amount, $refundId): void { + function () use ($payment, $method, $amount, $refundId, $hostedFieldsPaymentId): void { /** @var RefundPayment $refundPayment */ $refundPayment = $this->refundPaymentRepository->findOneBy(['id' => $refundId]); @@ -392,7 +389,7 @@ function () use ($payment, $method, $amount, $refundId): void { $details = $payment->getDetails(); $externalId = $this->createRefundOperation( $method, - $details['hosted_fields_payment_id'], + $hostedFieldsPaymentId, PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), $amount, $payment->getCurrencyCode(), @@ -414,6 +411,53 @@ function () use ($payment, $method, $amount, $refundId): void { ); } + /** + * Refunds are keyed by the payment's own id; the refund route refuses the operation id ("The + * reference transaction has not been found."), so there is nothing to fall back on. Refuses + * before the lock, the API call and any bookkeeping, tells the admin and logs ids only. A + * stored id is never changed afterwards, so the value read here is the one used under the lock. + */ + private function resolveHostedFieldsPaymentId(PaymentInterface $payment): string + { + $details = $payment->getDetails(); + $paymentId = $details['hosted_fields_payment_id'] ?? null; + if (\is_string($paymentId) && '' !== $paymentId) { + return $paymentId; + } + + $orderNumber = $payment->getOrder()?->getNumber(); + $this->logger->error('[PayPlug][UPC] Refund refused: the payment has no hosted_fields_payment_id.', [ + 'sylius_payment_id' => $payment->getId(), + 'order_number' => $orderNumber, + 'hosted_fields_operation_id' => $details['hosted_fields_operation_id'] ?? null, + ]); + $this->notifyAdminRefundUnavailable($orderNumber); + + throw new UpdateHandlingException(); + } + + /** + * No main request or no session (CLI, worker) means there is no admin to tell; the log entry + * and the exception still record the refusal. + */ + private function notifyAdminRefundUnavailable(?string $orderNumber): void + { + $request = $this->requestStack->getMainRequest(); + if (null === $request || !$request->hasSession()) { + return; + } + + $session = $request->getSession(); + if (!$session instanceof FlashBagAwareSessionInterface) { + return; + } + + $session->getFlashBag()->add('error', $this->translator->trans( + 'payplug_sylius_payplug_plugin.ui.payment_refund_missing_payment_id', + ['%order%' => $orderNumber ?? ''], + )); + } + private static function extractFirstOperationId(string $body): ?string { $decoded = \json_decode($body, true); diff --git a/src/Repository/PaymentRepository.php b/src/Repository/PaymentRepository.php index d500fbc1..3429cfbd 100644 --- a/src/Repository/PaymentRepository.php +++ b/src/Repository/PaymentRepository.php @@ -39,6 +39,36 @@ public function findOneByPayPlugPaymentId(string $payplugPaymentId): ?PaymentInt return $result; } + public function findAwaitingHostedFieldsPaymentId(string $orderNumber, int $amount): array + { + /** @var array $result */ + $result = $this->createQueryBuilder('o') + ->innerJoin('o.order', 'ord') + ->where('ord.number = :orderNumber') + ->andWhere('o.amount = :amount') + ->andWhere('o.state IN (:activeStates)') + ->andWhere("(o.details LIKE :hostedFieldsCreatedAt ESCAPE '!' OR o.details LIKE :aliasPaymentCreatedAt ESCAPE '!')") + ->andWhere("o.details NOT LIKE :paymentId ESCAPE '!'") + ->setParameter('orderNumber', $orderNumber) + ->setParameter('amount', $amount) + ->setParameter('activeStates', [PaymentInterface::STATE_NEW, PaymentInterface::STATE_PROCESSING]) + ->setParameter('hostedFieldsCreatedAt', self::containsJsonKey('hosted_fields_created_at')) + ->setParameter('aliasPaymentCreatedAt', self::containsJsonKey('alias_payment_created_at')) + ->setParameter('paymentId', self::containsJsonKey('hosted_fields_payment_id')) + ->setMaxResults(2) + ->getQuery() + ->getResult() + ; + + return $result; + } + + /** A LIKE pattern, escaped with "!", matching a JSON document that contains $key as a key. */ + private static function containsJsonKey(string $key): string + { + return '%"' . \str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $key) . '"%'; + } + /** * @return array */ diff --git a/src/Repository/PaymentRepositoryInterface.php b/src/Repository/PaymentRepositoryInterface.php index b8b783e6..13b86258 100644 --- a/src/Repository/PaymentRepositoryInterface.php +++ b/src/Repository/PaymentRepositoryInterface.php @@ -13,6 +13,15 @@ public function findAllActiveByGatewayFactoryName(string $gatewayFactoryName): a public function findOneByPayPlugPaymentId(string $payplugPaymentId): ?PaymentInterface; + /** + * Payments of the given order and amount whose Unified API creation has committed but that + * have no hosted_fields_payment_id yet. At most two are returned: callers only need to know + * whether exactly one matches. + * + * @return array + */ + public function findAwaitingHostedFieldsPaymentId(string $orderNumber, int $amount): array; + /** * @return array */ diff --git a/src/Twig/PayPlugExtension.php b/src/Twig/PayPlugExtension.php index 06a62756..446d75b6 100644 --- a/src/Twig/PayPlugExtension.php +++ b/src/Twig/PayPlugExtension.php @@ -16,6 +16,7 @@ final class PayPlugExtension extends AbstractExtension public function __construct( private CanSaveCardCheckerInterface $canSaveCardChecker, private PayPlugApiClientFactoryInterface $apiClientFactory, + private string $hostedFieldsSdkUrl, ) { } @@ -25,10 +26,16 @@ public function getFunctions(): array new TwigFunction('is_save_card_enabled', $this->isSaveCardAllowed(...)), new TwigFunction('is_payplug_test_mode_enabled', $this->isTest(...)), new TwigFunction('payplug_hosted_fields_company_id', $this->hostedFieldsCompanyId(...)), + new TwigFunction('payplug_hosted_fields_sdk_url', $this->hostedFieldsSdkUrl(...)), new TwigFunction('payplug_display_mode', $this->displayMode(...)), ]; } + public function hostedFieldsSdkUrl(): string + { + return $this->hostedFieldsSdkUrl; + } + /** * @param array $config */ diff --git a/src/Upc/CardDataFromPaymentMethodExtractor.php b/src/Upc/CardDataFromPaymentMethodExtractor.php index 99e11af5..7269e568 100644 --- a/src/Upc/CardDataFromPaymentMethodExtractor.php +++ b/src/Upc/CardDataFromPaymentMethodExtractor.php @@ -9,10 +9,11 @@ * `paymentMethod` object — shared by CaptureHostedPaymentRequestHandler (parsing the operation * resource fetched via OperationStatusFetcherInterface) and HostedFieldsWebhookNotificationHandler * (parsing the webhook's own raw body directly — confirmed the same shape as the operation - * resource, so no separate API call is needed there). Confirmed against a real staging response: - * paymentMethod.id ("card_xxx", the alias), paymentMethod.card.{network, code6x4} ("VISA", a - * masked PAN "424242XXXXXX4242") and paymentMethod.details.{selectedBrand, validityDate} ("VISA", - * "2027-12" — YYYY-MM). last4 isn't its own field — it's derived from code6x4's last 4 characters. + * resource, so no separate API call is needed there). The alias is paymentMethod.storedId, with + * paymentMethod.id read as a fallback, as UPC's UnifiedApiPaymentService::extractAliasId() does; + * paymentMethod.card.{network, code6x4} ("VISA", a masked PAN "424242XXXXXX4242") and + * paymentMethod.details.{selectedBrand, validityDate} ("VISA", "2027-12" — YYYY-MM). last4 isn't + * its own field — it's derived from code6x4's last 4 characters. */ final class CardDataFromPaymentMethodExtractor { @@ -56,9 +57,14 @@ public static function extractFromDecoded(mixed $decoded): array */ private static function extractAliasId(array $paymentMethod): array { - $aliasId = $paymentMethod['id'] ?? null; + foreach (['storedId', 'id'] as $key) { + $aliasId = $paymentMethod[$key] ?? null; + if (\is_string($aliasId) && '' !== $aliasId) { + return ['aliasId' => $aliasId]; + } + } - return \is_string($aliasId) && '' !== $aliasId ? ['aliasId' => $aliasId] : []; + return []; } /** @return array{brand?: string, last4?: string} */ diff --git a/src/Upc/HostedFieldsPaymentIdBackfiller.php b/src/Upc/HostedFieldsPaymentIdBackfiller.php new file mode 100644 index 00000000..1da283ea --- /dev/null +++ b/src/Upc/HostedFieldsPaymentIdBackfiller.php @@ -0,0 +1,90 @@ +awaitsPaymentId($payment, $webhookPaymentId)) { + return true; + } + + $lockKey = RefundDetailsLockKey::forPaymentId($payment->getId()); + if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) { + return false; + } + + try { + if ($this->awaitsPaymentId($payment, $webhookPaymentId)) { + $payment->setDetails([...$payment->getDetails(), 'hosted_fields_payment_id' => $webhookPaymentId]); + $this->logger->info('[PayPlug][UPC] Payment id missing from the creation response, backfilled from the webhook.', [ + 'sylius_payment_id' => $payment->getId(), + 'hosted_fields_payment_id' => $webhookPaymentId, + ]); + } + } finally { + $this->lock->release($lockKey); + } + + return true; + } + + /** + * True only when the payment has no stored payment id and its creation has committed: both + * capture handlers write their created_at key in the same setDetails() call as the ids, so + * writing before it exists would be overwritten by the capture handler's own stale snapshot. + * An id equal to the payment's creation operation id is never a payment id (the status poll + * passes the operation body to the webhook handler). A stored id is never overwritten. + * + * @phpstan-impure + */ + private function awaitsPaymentId(PaymentInterface $payment, string $webhookPaymentId): bool + { + $details = $payment->getDetails(); + if ($webhookPaymentId === ($details['hosted_fields_operation_id'] ?? null)) { + return false; + } + + $storedPaymentId = $details['hosted_fields_payment_id'] ?? null; + if (\is_string($storedPaymentId) && '' !== $storedPaymentId) { + if ($storedPaymentId !== $webhookPaymentId) { + $this->logger->error('[PayPlug][UPC] Webhook payment id differs from the stored one, keeping the stored one.', [ + 'sylius_payment_id' => $payment->getId(), + 'stored_payment_id' => $storedPaymentId, + 'webhook_payment_id' => $webhookPaymentId, + ]); + } + + return false; + } + + return isset($details['hosted_fields_created_at']) || isset($details['alias_payment_created_at']); + } +} diff --git a/src/Upc/HostedFieldsPaymentIdExtractor.php b/src/Upc/HostedFieldsPaymentIdExtractor.php new file mode 100644 index 00000000..86d78429 --- /dev/null +++ b/src/Upc/HostedFieldsPaymentIdExtractor.php @@ -0,0 +1,32 @@ +getFlashBag()->add('error', self::SHOPPER_ERROR_FLASH_KEY); } + /** + * Refunds are keyed by the payment's top-level id, so a creation response without one leaves + * the payment unrefundable until a webhook provides it. Ids only: the response body carries + * customer data. + * + * @param array{hosted_fields_payment_id?: string, hosted_fields_operation_id?: string} $hostedFieldsIds + */ + public function warnWhenPaymentIdMissing( + PaymentInterface $payment, + PaymentCaptureFlow $flow, + array $hostedFieldsIds, + ): void { + if (isset($hostedFieldsIds['hosted_fields_payment_id'])) { + return; + } + + $this->logger->warning('[PayPlug][UPC] Payment created but the response carried no payment id; it cannot be refunded until a webhook provides it.', [ + 'sylius_payment_id' => $payment->getId(), + 'flow' => $flow->value, + 'hosted_fields_operation_id' => $hostedFieldsIds['hosted_fields_operation_id'] ?? null, + ]); + } + public function applyOutcome( PaymentRequestInterface $paymentRequest, PaymentInterface $payment, diff --git a/templates/shop/hosted_fields/index.html.twig b/templates/shop/hosted_fields/index.html.twig index da0dfcfe..8dc16b9f 100644 --- a/templates/shop/hosted_fields/index.html.twig +++ b/templates/shop/hosted_fields/index.html.twig @@ -1,4 +1,4 @@ - +