Skip to content
Open
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
9 changes: 9 additions & 0 deletions assets/shop/controllers/hosted-fields_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ export default class extends Controller {
this.form.querySelector('#hostedfields_token').value = result.hfToken;
this.form.querySelector('#hostedfields_selected_brand').value = selectedBrand;
this.form.querySelector('#hostedfields_save_card').value = saveCard ? 'true' : 'false';
// last4/expirationMonth/expirationYear/country field names are unverified against a real
// createToken() response (no vendored SDK docs/types exist in this repo to confirm them) —
// if wrong, these silently fall back to '' rather than error, so double-check against a
// real sandbox response if saved-card metadata (Card::$last4/$expirationMonth/etc.) ever
// looks wrong in practice.
this.form.querySelector('#hostedfields_last4').value = result.last4 || '';
this.form.querySelector('#hostedfields_exp_month').value = result.expirationMonth || '';
this.form.querySelector('#hostedfields_exp_year').value = result.expirationYear || '';
this.form.querySelector('#hostedfields_country').value = result.country || '';
this.form.submit();
});
}
Expand Down
4 changes: 2 additions & 2 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ services:
PayplugUnifiedCore\Contracts\IOrderStateMutator:
alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusOrderStateMutator

PayPlug\SyliusPayPlugPlugin\Upc\HostedPaymentCreatorInterface:
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiHostedPaymentCreator
PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface:
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreator

PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface:
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiOperationStatusFetcher
Expand Down
88 changes: 88 additions & 0 deletions src/Command/BuildsCommonPaymentContextTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Command;

use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Upc\IntegrationDescriptionProvider;
use PayplugUnifiedCore\Dto\BrowserDto;
use PayplugUnifiedCore\Dto\CommonFieldsDto;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Payment\Model\PaymentMethodInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
* Shared by CaptureHostedPaymentRequestHandler and CaptureAliasPaymentRequestHandler, the two
* sibling capture paths that each build a CommonFieldsDto/BrowserDto for the Unified API the same
* way, differing only in the final HostedFieldDto/PaymentDto they build around it. Requires the
* host class to have $urlGenerator, $afterPayUrlProvider, $orderAddressDtoFactory and
* $requestStack properties of the usual types.
*/
trait BuildsCommonPaymentContextTrait
{
/**
* @return array{0: string, 1: string} accountId, submerchantExternalId
*/
private 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];
}

private function buildCommonFields(
string $accountId,
int $amount,
string $currencyCode,
string $orderId,
string $submerchantExternalId,
PaymentRequestInterface $paymentRequest,
?OrderInterface $order,
): CommonFieldsDto {
$common = new CommonFieldsDto($accountId, $amount, \strtoupper($currencyCode), $orderId, $submerchantExternalId);
$common->description = IntegrationDescriptionProvider::build();
$common->notificationUrl = $this->urlGenerator->generate(
'sylius_payment_request_notify',
['hash' => (string) $paymentRequest->getHash()],
UrlGeneratorInterface::ABSOLUTE_URL,
);
$successUrl = $this->afterPayUrlProvider->getUrl($paymentRequest, UrlGeneratorInterface::ABSOLUTE_URL);
$common->successUrl = $successUrl;
$common->cancelUrl = $successUrl . '?' . http_build_query(['status' => 'canceled']);
if (null !== $order) {
$common->billing = $this->orderAddressDtoFactory->createBilling($order);
$common->shipping = $this->orderAddressDtoFactory->createShipping($order);
}

return $common;
}

private function buildBrowserDto(): ?BrowserDto
{
$request = $this->requestStack->getCurrentRequest();

return null !== $request
? new BrowserDto(
$request->getClientIp() ?? '',
$request->headers->get('referer', '') ?? '',
$request->headers->get('User-Agent', '') ?? '',
)
: 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;
}
}
9 changes: 9 additions & 0 deletions src/Command/CaptureAliasPaymentRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Command;

class CaptureAliasPaymentRequest extends AbstractPayplugPaymentRequest
{
}
142 changes: 142 additions & 0 deletions src/Command/Handler/CaptureAliasPaymentRequestHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Command\Handler;

use PayPlug\SyliusPayPlugPlugin\Command\BuildsCommonPaymentContextTrait;
use PayPlug\SyliusPayPlugPlugin\Command\CaptureAliasPaymentRequest;
use PayPlug\SyliusPayPlugPlugin\Command\ResolvesSelectedCardTrait;
use PayPlug\SyliusPayPlugPlugin\Upc\OrderAddressDtoFactory;
use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface;
use PayplugUnifiedCore\Contracts\IOrderStateMutator;
use PayplugUnifiedCore\Dto\CustomerDto;
use PayplugUnifiedCore\Dto\PaymentDto;
use PayplugUnifiedCore\Exceptions\ApiException;
use PayplugUnifiedCore\Exceptions\InvalidPaymentException;
use PayplugUnifiedCore\Utilities\Helpers\ExecCodeMapper;
use Psr\Log\LoggerInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\CoreBundle\OrderPay\Provider\UrlProviderInterface;
use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface;
use Sylius\Component\Payment\PaymentRequestTransitions;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
* Pays with an already-created alias (a saved Card selected at checkout) instead of a
* hosted-fields token — the sibling capture path to CaptureHostedPaymentRequestHandler, dispatched
* by CaptureHostedPaymentRequestCommandProvider when the customer picked a saved card.
*/
#[AsMessageHandler]
final class CaptureAliasPaymentRequestHandler
{
use BuildsCommonPaymentContextTrait;
use ResolvesSelectedCardTrait;

public function __construct(
private PaymentRequestProviderInterface $paymentRequestProvider,
private StateMachineInterface $stateMachine,
private UnifiedApiPaymentCreatorInterface $unifiedApiPaymentCreator,
private RequestStack $requestStack,
private RepositoryInterface $payplugCardRepository,
private LoggerInterface $logger,
private IOrderStateMutator $orderStateMutator,
private UrlGeneratorInterface $urlGenerator,
// Builds the successUrl/cancelUrl sent to the Unified API, so a 3DS/SCA challenge returns
// the shopper to this same /pay page afterwards.
#[Autowire(service: 'sylius_shop.provider.order_pay.after_pay_url')] // @phpstan-ignore-line
private UrlProviderInterface $afterPayUrlProvider,
private OrderAddressDtoFactory $orderAddressDtoFactory,
) {
}

public function __invoke(CaptureAliasPaymentRequest $captureAliasPaymentRequest): void
{
$paymentRequest = $this->paymentRequestProvider->provide($captureAliasPaymentRequest);
/** @var \Sylius\Component\Core\Model\PaymentInterface $payment */
$payment = $paymentRequest->getPayment();
$method = $payment->getMethod();
if (null === $method) {
throw new \LogicException('Payment method is not set for the payment.');
}

$card = $this->resolveSelectedCard();
if (null === $card) {
throw new \LogicException('No saved card alias selected for the payment.');
}

$amount = $payment->getAmount();
$currencyCode = $payment->getCurrencyCode();
if (null === $amount || null === $currencyCode) {
throw new \LogicException('Payment amount or currency is not set.');
}

[$accountId, $submerchantExternalId] = $this->resolveGatewayCredentials($method);

try {
$order = $payment->getOrder();

if ($card->getCustomer() !== $order?->getCustomer() || $card->getPaymentMethod() !== $method) {
throw new \LogicException('Selected card does not belong to the paying customer or payment method.');
}

// $order is provably non-null here: Card::getCustomer() never returns null, so the
// check above already threw if $order (and thus $order?->getCustomer()) were null.
$orderId = $order->getNumber() ?? self::idToString($payment->getId());

$common = $this->buildCommonFields($accountId, $amount, $currencyCode, $orderId, $submerchantExternalId, $paymentRequest, $order);

$customer = $order->getCustomer();
if (null === $customer->getEmail()) {
throw new \LogicException('Customer email is not set for the payment.');
}
$customerDto = new CustomerDto(self::idToString($customer->getId()), $customer->getEmail());

$browserDto = $this->buildBrowserDto();

$fullName = $order->getBillingAddress()?->getFullName();
$paymentMethod = null !== $fullName && '' !== $fullName
? ['details' => ['fullName' => $fullName]]
: null;

$dto = new PaymentDto($common, $card->getExternalId(), 'ONE_CLICK', $browserDto, $customerDto, $paymentMethod);

Check failure on line 106 in src/Command/Handler/CaptureAliasPaymentRequestHandler.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality

Instantiated class PayplugUnifiedCore\Dto\PaymentDto not found.

$output = $this->unifiedApiPaymentCreator->createPayment($dto);

Check failure on line 108 in src/Command/Handler/CaptureAliasPaymentRequestHandler.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality

Parameter #1 $dto of method PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface::createPayment() expects PayplugUnifiedCore\Dto\PaymentRequestPayload, PayplugUnifiedCore\Dto\PaymentDto given.
} catch (ApiException | InvalidPaymentException | \LogicException $e) {

Check failure on line 109 in src/Command/Handler/CaptureAliasPaymentRequestHandler.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality

Caught class PayplugUnifiedCore\Exceptions\InvalidPaymentException not found.
$this->logger->error('[PayPlug][UPC] Alias payment creation failed.', [
'sylius_payment_id' => $payment->getId(),
'error' => $e->getMessage(),

Check failure on line 112 in src/Command/Handler/CaptureAliasPaymentRequestHandler.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality

Call to method getMessage() on an unknown class PayplugUnifiedCore\Exceptions\InvalidPaymentException.
]);
$paymentRequest->setResponseData(['error' => $e->getMessage()]);

Check failure on line 114 in src/Command/Handler/CaptureAliasPaymentRequestHandler.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality

Call to method getMessage() on an unknown class PayplugUnifiedCore\Exceptions\InvalidPaymentException.
$this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL);

return;
}

$payment->setDetails([
...$payment->getDetails(),
'alias_id' => $card->getExternalId(),
'alias_payment_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
]);

if (null !== $output->redirectHtml) {

Check failure on line 126 in src/Command/Handler/CaptureAliasPaymentRequestHandler.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality

Access to property $redirectHtml on an unknown class PayplugUnifiedCore\Output\PaymentOutput.
$paymentRequest->setResponseData(['redirect_html' => $output->redirectHtml]);
} elseif (null !== $output->redirectUrl) {
$paymentRequest->setResponseData(['redirect_url' => $output->redirectUrl]);
} else {
$paymentRequest->setResponseData(['status' => $output->status]);

$responseBody = \json_decode($output->body, true);
$execCode = \is_array($responseBody) ? ($responseBody['execCode'] ?? null) : null;
if (\is_string($execCode)) {
$this->orderStateMutator->apply(self::idToString($payment->getId()), ExecCodeMapper::toPaymentOutcome($execCode));
}
}

$this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
}
}
Loading
Loading