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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"ext-json": "*",
"giggsey/libphonenumber-for-php": "^8.12",
"payplug/payplug-php": "^4.0",
"payplug/unified-plugin-core": "^1.0.0",
"payplug/unified-plugin-core": "dev-feature/PRE-3614_handling_unified_notifier",
"php-http/message-factory": "^1.1",
"sylius/refund-plugin": "^2.0",
"sylius/sylius": "^2.0",
Expand Down
11 changes: 11 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ parameters:
payplug.oauth_audience: '%env(default:payplug.oauth_audience.default:PAYPLUG_OAUTH_AUDIENCE)%'
payplug.unified_api_base_url.default: 'https://api.payplug.com'
payplug.unified_api_base_url: '%env(default:payplug.unified_api_base_url.default:PAYPLUG_UNIFIED_API_BASE_URL)%'
# Separate from payplug.unified_api_base_url on purpose: on at least one QA/staging setup, the
# payment-status GET lives on a different host than the payment-creation POST (an "internal"
# Dalenys/GCP host, distinct from the public-facing payment-gateway proxy the create call goes
# through). Defaults to the same value as payplug.unified_api_base_url, so environments that
# never need the split (production included) are unaffected.
payplug.unified_api_payment_status_base_url.default: '%payplug.unified_api_base_url%'
payplug.unified_api_payment_status_base_url: '%env(default:payplug.unified_api_payment_status_base_url.default:PAYPLUG_UNIFIED_API_PAYMENT_STATUS_BASE_URL)%'

services:
_defaults:
Expand All @@ -22,6 +29,7 @@ services:
$payplugOauthBaseUrl: '%payplug.oauth_base_url%'
$payplugOauthAudience: '%payplug.oauth_audience%'
$unifiedApiBaseUrl: '%payplug.unified_api_base_url%'
$unifiedApiPaymentStatusBaseUrl: '%payplug.unified_api_payment_status_base_url%'

PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface:
class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository
Expand Down Expand Up @@ -64,6 +72,9 @@ services:
PayPlug\SyliusPayPlugPlugin\Upc\HostedPaymentCreatorInterface:
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiHostedPaymentCreator

PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface:
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiOperationStatusFetcher

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

Expand Down
130 changes: 126 additions & 4 deletions src/Command/Handler/StatusHostedPaymentRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@
namespace PayPlug\SyliusPayPlugPlugin\Command\Handler;

use PayPlug\SyliusPayPlugPlugin\Command\StatusHostedPaymentRequest;
use PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface;
use PayplugUnifiedCore\Contracts\IOrderStateMutator;
use PayplugUnifiedCore\Contracts\IPaymentRepository;
use PayplugUnifiedCore\Exceptions\ApiException;
use PayplugUnifiedCore\Exceptions\OperationNotFoundException;
use PayplugUnifiedCore\Utilities\Helpers\ExecCodeMapper;
use Psr\Log\LoggerInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Payment\PaymentRequestTransitions;
use Sylius\Component\Payment\PaymentTransitions;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
Expand All @@ -16,26 +24,140 @@ final class StatusHostedPaymentRequestHandler
{
private const FORCED_STATUS_CANCELED = 'canceled';

// The Unified API's transient "challenge required" code β€” never a final outcome. Left
// unmapped by ExecCodeMapper on purpose (it only ever sees final codes from the webhook), so
// it must be special-cased here before reaching it: 0001 is FAILED under that mapper's "only
// 0000 is PAID" rule, which would wrongly fail a payment still awaiting the customer.
private const EXEC_CODE_PENDING = '0001';

// States that already carry a final outcome β€” reached by the webhook, by the synchronous
// branch in CaptureHostedPaymentRequestHandler, or by a previous poll. Anything else
// (observed in practice: the payment stays "new" β€” Sylius's state machine is never driven
// to "processing" for this flow, despite what SyliusOrderStateMutator's docblock assumes) is
// still awaiting an outcome and is worth polling for.
private const RESOLVED_STATES = [
PaymentInterface::STATE_COMPLETED,
PaymentInterface::STATE_FAILED,
PaymentInterface::STATE_CANCELLED,
PaymentInterface::STATE_REFUNDED,
PaymentInterface::STATE_AUTHORIZED,
];

public function __construct(
private PaymentRequestProviderInterface $paymentRequestProvider,
private StateMachineInterface $stateMachine,
private OperationStatusFetcherInterface $operationStatusFetcher,
private IPaymentRepository $paymentRepository,
private IOrderStateMutator $orderStateMutator,
private LoggerInterface $logger,
) {
}

public function __invoke(StatusHostedPaymentRequest $statusHostedPaymentRequest): void
{
$paymentRequest = $this->paymentRequestProvider->provide($statusHostedPaymentRequest);
/** @var PaymentInterface $payment */
$payment = $paymentRequest->getPayment();

if (self::FORCED_STATUS_CANCELED === $statusHostedPaymentRequest->getForcedStatus()) {
$payment = $paymentRequest->getPayment();
if ($this->stateMachine->can($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL)) {
$this->stateMachine->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL);
}
} else {
$this->pollForOutcomeIfStillPending($payment);
}

// No polling against any API: the Payment's current state is whatever the Notify handler
// (Task 10) has already applied from the webhook, or still "processing" if none has
// arrived yet β€” this handler never queries UPC/PayPlug for a status.
$this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
}

/**
* Fallback for when the asynchronous webhook (NotifyHostedPaymentRequestHandler) hasn't
* confirmed the outcome by the time the customer is bounced back from the 3DS challenge page
* β€” a GET against the Unified API, per PayPlug's own documented recommendation for a delayed
* or lost webhook. Skipped entirely once anything else has already resolved the payment (the
* webhook, or the synchronous branch in CaptureHostedPaymentRequestHandler), so there's
* nothing here to double-apply β€” and IOrderStateMutator is idempotent besides.
*
* Polls the *operation* (GET /processing-operations/operations/{id}), not the payment: the
* payment representation carries no execCode at all, while the operation's does
* (transaction.status.execCode), in the same vocabulary ExecCodeMapper already maps from the
* webhook and payment-creation flows. isTreated()/markTreated() are keyed by the payment id
* (not the operation id polled here) to match what the webhook path actually dedupes on:
* WebhookNotificationHelper::parse() populates OperationData's operationId from the webhook
* payload's top-level payment id, not from a per-operation id β€” so whichever of the two
* (webhook or this poll) arrives first is correctly recognized by the other as already done.
*/
private function pollForOutcomeIfStillPending(PaymentInterface $payment): void
{
if (\in_array($payment->getState(), self::RESOLVED_STATES, true)) {
return;
}

$ids = self::resolvePollingIds($payment->getDetails());
if (null === $ids) {
return;
}
[$operationId, $paymentId] = $ids;

try {
$response = $this->operationStatusFetcher->getOperation($operationId);
} catch (OperationNotFoundException | ApiException $e) {
$this->logger->error('[PayPlug][UPC] Hosted payment status poll failed.', [
'sylius_payment_id' => $payment->getId(),
'hosted_fields_operation_id' => $operationId,
'error' => $e->getMessage(),
]);

return;
}

$execCode = self::extractExecCode($response['body']);
if (!\is_string($execCode) || '' === $execCode || self::EXEC_CODE_PENDING === $execCode) {
// No final code yet (the challenge genuinely hasn't been completed) β€” leave the
// payment as-is; the webhook or a later poll will resolve it.
return;
}

if ($this->paymentRepository->isTreated($paymentId)) {
return;
}

$this->orderStateMutator->apply(self::idToString($payment->getId()), ExecCodeMapper::toPaymentOutcome($execCode));
$this->paymentRepository->markTreated($paymentId);
}

/**
* @param mixed[] $details
*
* @return array{0: string, 1: string}|null
*/
private static function resolvePollingIds(array $details): ?array
{
$operationId = $details['hosted_fields_operation_id'] ?? null;
$paymentId = $details['hosted_fields_payment_id'] ?? null;
if (!\is_string($operationId) || '' === $operationId || !\is_string($paymentId) || '' === $paymentId) {
return null;
}

return [$operationId, $paymentId];
}

private static function extractExecCode(string $operationBody): ?string
{
$body = \json_decode($operationBody, true);
$transaction = \is_array($body) ? ($body['transaction'] ?? null) : null;
$status = \is_array($transaction) ? ($transaction['status'] ?? null) : null;
$execCode = \is_array($status) ? ($status['execCode'] ?? null) : null;

return \is_string($execCode) ? $execCode : null;
}

private static function idToString(mixed $id): string
{
if (!\is_int($id) && !\is_string($id)) {
throw new \LogicException('Unexpected non-scalar resource identifier.');
}

return (string) $id;
}
}
33 changes: 33 additions & 0 deletions src/Controller/IpnAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Handler\HostedFieldsWebhookNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\Handler\RefundNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface;
use PayplugUnifiedCore\Exceptions\InvalidNotificationException;
use Payum\Core\Bridge\Spl\ArrayObject;
use Psr\Log\LoggerInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
Expand All @@ -37,6 +39,7 @@ public function __construct(
private LoggerInterface $logger,
private PaymentNotificationHandler $paymentNotificationHandler,
private RefundNotificationHandler $refundNotificationHandler,
private HostedFieldsWebhookNotificationHandler $hostedFieldsWebhookNotificationHandler,
private PayPlugApiClientFactoryInterface $apiClientFactory,
private PaymentRepositoryInterface $paymentRepository,
private EntityManagerInterface $entityManager,
Expand Down Expand Up @@ -86,6 +89,21 @@ public function __invoke(Request $request): JsonResponse
return new JsonResponse(null, Response::HTTP_UNAUTHORIZED);
}

// Hosted Fields notifications are Unified API webhooks, not legacy SDK ones: a different
// signature scheme and payload shape that $this->payPlugApiClient->treat() below cannot
// parse. Branching here β€” rather than letting it fall through and blow up inside treat()
// β€” is what lets this same static, already-deployed IPN URL serve as the account-level
// webhook receiver for Hosted Fields too, alongside every other gateway's legacy flow.
if (PayPlugGatewayFactory::isHostedFieldsConfig($gateway)) {
try {
$this->hostedFieldsWebhookNotificationHandler->treat($payment, $input, self::flattenHeaders($request->headers->all()));
} catch (InvalidNotificationException $exception) {
$this->logger->error('[PayPlug][UPC] Rejected webhook notification.', ['error' => $exception->getMessage()]);
}

return new JsonResponse();
}

$this->payPlugApiClient = $this->apiClientFactory->create($factoryName);

try {
Expand All @@ -101,4 +119,19 @@ public function __invoke(Request $request): JsonResponse

return new JsonResponse();
}

/**
* @param array<string, array<int, string|null>> $rawHeaders
*
* @return array<string, string>
*/
private static function flattenHeaders(array $rawHeaders): array
{
$headers = [];
foreach ($rawHeaders as $name => $values) {
$headers[$name] = $values[0] ?? '';
}

return $headers;
}
}
71 changes: 71 additions & 0 deletions src/Handler/HostedFieldsWebhookNotificationHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Handler;

use PayplugUnifiedCore\Contracts\IConfigurationRepository;
use PayplugUnifiedCore\Contracts\IOrderStateMutator;
use PayplugUnifiedCore\Contracts\IPaymentRepository;
use PayplugUnifiedCore\Exceptions\InvalidNotificationException;
use PayplugUnifiedCore\Utilities\Helpers\WebhookNotificationHelper;
use Sylius\Component\Core\Model\PaymentInterface;

/**
* 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.
*
* 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
* verify/parse/idempotency primitives (WebhookNotificationHelper, IPaymentRepository); only the
* resolution path differs β€” so unlike that handler, this one applies the outcome against the
* Payment id already resolved by the caller rather than against OperationData's own orderId
* field (which carries the order *number* PayPlug was given at creation time, not a Sylius
* Payment id) β€” there is nothing left to resolve here.
*/
class HostedFieldsWebhookNotificationHandler
{
// Mirrors NotifyHostedPaymentRequestHandler's own documented gap: nothing currently writes
// this configuration key, so $expectedHeader always resolves empty and every notification is
// rejected by WebhookNotificationHelper::verifySignature() until something does.
private const CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER = 'payplug_webhook_authorization_header';

public function __construct(
private IPaymentRepository $paymentRepository,
private IOrderStateMutator $orderStateMutator,
private IConfigurationRepository $configurationRepository,
) {
}

/**
* @param array<string, string> $headers
*
* @throws InvalidNotificationException if the notification fails signature verification or
* parsing β€” the caller is expected to catch this, same
* as it already does for the legacy SDK's PayplugException.
*/
public function treat(PaymentInterface $payment, string $rawBody, array $headers): void
{
$expectedHeader = $this->configurationRepository->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? '';
$operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader);

if ($this->paymentRepository->isTreated($operationData->operationId)) {
return;
}

$this->paymentRepository->save($operationData);
$this->orderStateMutator->apply(self::idToString($payment->getId()), $operationData->outcome);
$this->paymentRepository->markTreated($operationData->operationId);
}

private static function idToString(mixed $id): string
{
if (!\is_int($id) && !\is_string($id)) {
throw new \LogicException('Unexpected non-scalar resource identifier.');
}

return (string) $id;
}
}
17 changes: 15 additions & 2 deletions src/OrderPay/Provider/CaptureHttpResponseProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ class CaptureHttpResponseProvider implements HttpResponseProviderInterface
{
public function supports(RequestConfiguration $requestConfiguration, PaymentRequestInterface $paymentRequest): bool
{
return $paymentRequest->getAction() === PaymentRequestInterface::ACTION_CAPTURE &&
($paymentRequest->getResponseData()['redirect_url'] ?? null) !== null;
if ($paymentRequest->getAction() !== PaymentRequestInterface::ACTION_CAPTURE) {
return false;
}

$data = $paymentRequest->getResponseData();

return null !== ($data['redirect_url'] ?? null) || null !== ($data['redirect_html'] ?? null);
}

public function getResponse(
Expand All @@ -53,6 +58,14 @@ public function getResponse(
): Response {
// This is called after the capture payment request has been handled
$data = $paymentRequest->getResponseData();

// The Unified API's "recommended for web" 3DS shape (Hosted Fields only, see
// CaptureHostedPaymentRequestHandler): a self-submitting HTML form to render as-is, rather
// than a plain redirect target.
if (\is_string($data['redirect_html'] ?? null)) {
return new Response($data['redirect_html']);
}

if (!\is_string($data['redirect_url'] ?? null)) {
throw new \LogicException('Redirect URL is not set in the payment request response data.');
}
Expand Down
19 changes: 19 additions & 0 deletions src/Upc/OperationStatusFetcherInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Upc;

use PayplugUnifiedCore\Exceptions\ApiException;
use PayplugUnifiedCore\Exceptions\OperationNotFoundException;

interface OperationStatusFetcherInterface
{
/**
* @return array{status: int, body: string}
*
* @throws OperationNotFoundException
* @throws ApiException
*/
public function getOperation(string $operationId): array;
}
Loading
Loading