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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 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,18 @@ 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. This data is fully
// client-controlled and only ever used as a display-only fallback for a saved card's
// metadata when PayPlug's own operation-fetch is unavailable β€” PayplugCardPersister
// validates the format of each field (4-digit last4, 1-12 month, a plausible year, a
// 2-letter country) before trusting any of it, and discards anything that doesn't match
// rather than persisting it as-is.
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 || '';
Comment thread
adumont-payplug marked this conversation as resolved.
this.form.submit();
});
}
Expand Down
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.1",
"payplug/unified-plugin-core": "^1.1.0",
"php-http/message-factory": "^1.1",
"sylius/refund-plugin": "^2.0",
"sylius/sylius": "^2.0",
Expand Down
639 changes: 324 additions & 315 deletions composer.lock

Large diffs are not rendered by default.

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
37 changes: 37 additions & 0 deletions migrations/Version20260901120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Closes a TOCTOU race in PayplugCardPersister::persist(): its findOneBy-then-add dedup guard is
* reachable from two independent paths for the same alias (the synchronous frictionless capture
* and the async webhook), so without a DB-level constraint a race between them could create two
* Card rows for one alias/liveness pair.
*/
final class Version20260901120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Added a unique constraint on payplug_cards (external_id, is_live) to prevent duplicate card aliases under a race.';
}

public function up(Schema $schema): void
{
// The race this constraint closes has been reachable since the payplug_cards table's
// introduction, so an existing merchant database may already carry duplicate
// (external_id, is_live) rows β€” remove all but the lowest-id row per pair first, or the
// CREATE UNIQUE INDEX below fails outright on any DB where that already happened.
$this->addSql('DELETE t1 FROM payplug_cards t1 INNER JOIN payplug_cards t2 ON t1.external_id = t2.external_id AND t1.is_live = t2.is_live AND t1.id > t2.id');
$this->addSql('CREATE UNIQUE INDEX UNIQ_payplug_cards_external_id_is_live ON payplug_cards (external_id, is_live)');
}

public function down(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_payplug_cards_external_id_is_live ON payplug_cards');
}
}
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
{
}
107 changes: 107 additions & 0 deletions src/Command/Handler/CaptureAliasPaymentRequestHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Command\Handler;

use PayPlug\SyliusPayPlugPlugin\Command\CaptureAliasPaymentRequest;
use PayPlug\SyliusPayPlugPlugin\Command\PaymentCaptureFlow;
use PayPlug\SyliusPayPlugPlugin\Entity\Card;
use PayPlug\SyliusPayPlugPlugin\Resolver\SelectedCardResolver;
use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureContextBuilder;
use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureOutcomeApplier;
use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface;
use PayplugUnifiedCore\Dto\CommonFieldsDto;
use PayplugUnifiedCore\Dto\PaymentDto;
use PayplugUnifiedCore\Exceptions\ApiException;
use PayplugUnifiedCore\Exceptions\InvalidPaymentException;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Payment\Model\PaymentMethodInterface;
use Sylius\Component\Payment\PaymentRequestTransitions;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

/**
* 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
{
public function __construct(
private PaymentRequestProviderInterface $paymentRequestProvider,
private StateMachineInterface $stateMachine,
private UnifiedApiPaymentCreatorInterface $unifiedApiPaymentCreator,
private SelectedCardResolver $selectedCardResolver,
private PaymentCaptureContextBuilder $contextBuilder,
private PaymentCaptureOutcomeApplier $outcomeApplier,
) {
}

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

try {
$method = $this->contextBuilder->resolvePaymentMethod($payment);

$card = $this->selectedCardResolver->resolve();
if (null === $card) {
throw new \LogicException('No saved card alias selected for the payment.');
}
Comment thread
adumont-payplug marked this conversation as resolved.
[$amount, $currencyCode] = $this->contextBuilder->resolveAmountAndCurrency($payment);
[$accountId, $submerchantExternalId] = $this->contextBuilder->resolveGatewayCredentials($method);

$order = $this->assertCardBelongsToOrder($card, $payment->getOrder(), $method);
$common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $submerchantExternalId, $paymentRequest, $order);
$dto = $this->buildPaymentDto($common, $card, $order);

$output = $this->unifiedApiPaymentCreator->createPayment($dto);
} catch (ApiException | InvalidPaymentException | \LogicException $e) {
$this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Alias);

return;
}

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

$this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output);

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

private function assertCardBelongsToOrder(
Card $card,
?OrderInterface $order,
PaymentMethodInterface $method,
): OrderInterface {
if (null === $order || $card->getCustomer() !== $order->getCustomer() || $card->getPaymentMethod() !== $method) {
throw new \LogicException('Selected card does not belong to the paying customer or payment method.');
}

return $order;
}

private function buildPaymentDto(CommonFieldsDto $common, Card $card, OrderInterface $order): PaymentDto
{
$customerDto = $this->contextBuilder->buildCustomerDto($order);
$browserDto = $this->contextBuilder->buildBrowserDto();

$fullName = $this->contextBuilder->resolveFullNameForCardDetails($order);
$paymentMethod = null !== $fullName
? ['details' => ['fullName' => $fullName]]
: null;

return new PaymentDto($common, $card->getExternalId(), 'ONE_CLICK', $browserDto, $customerDto, $paymentMethod);
}
}
Loading
Loading