Skip to content

Commit 22b8be0

Browse files
adumont-payplughdelaforce-payplug
authored andcommitted
PRE-3585: Set alias with Unified hosted field
1 parent 3bc282f commit 22b8be0

53 files changed

Lines changed: 3936 additions & 664 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎assets/shop/controllers/hosted-fields_controller.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ export default class extends Controller {
149149
this.form.querySelector('#hostedfields_token').value = result.hfToken;
150150
this.form.querySelector('#hostedfields_selected_brand').value = selectedBrand;
151151
this.form.querySelector('#hostedfields_save_card').value = saveCard ? 'true' : 'false';
152+
// last4/expirationMonth/expirationYear/country field names are unverified against a real
153+
// createToken() response (no vendored SDK docs/types exist in this repo to confirm them) —
154+
// if wrong, these silently fall back to '' rather than error. This data is fully
155+
// client-controlled and only ever used as a display-only fallback for a saved card's
156+
// metadata when PayPlug's own operation-fetch is unavailable — PayplugCardPersister
157+
// validates the format of each field (4-digit last4, 1-12 month, a plausible year, a
158+
// 2-letter country) before trusting any of it, and discards anything that doesn't match
159+
// rather than persisting it as-is.
160+
this.form.querySelector('#hostedfields_last4').value = result.last4 || '';
161+
this.form.querySelector('#hostedfields_exp_month').value = result.expirationMonth || '';
162+
this.form.querySelector('#hostedfields_exp_year').value = result.expirationYear || '';
163+
this.form.querySelector('#hostedfields_country').value = result.country || '';
152164
this.form.submit();
153165
});
154166
}

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"ext-json": "*",
1515
"giggsey/libphonenumber-for-php": "^8.12",
1616
"payplug/payplug-php": "^4.0",
17-
"payplug/unified-plugin-core": "^1.0.1",
17+
"payplug/unified-plugin-core": "^1.1.0",
1818
"php-http/message-factory": "^1.1",
1919
"sylius/refund-plugin": "^2.0",
2020
"sylius/sylius": "^2.0",

‎composer.lock‎

Lines changed: 324 additions & 315 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎config/services.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ services:
6868
PayplugUnifiedCore\Contracts\IOrderStateMutator:
6969
alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusOrderStateMutator
7070

71-
PayPlug\SyliusPayPlugPlugin\Upc\HostedPaymentCreatorInterface:
72-
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiHostedPaymentCreator
71+
PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface:
72+
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreator
7373

7474
PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface:
7575
alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiOperationStatusFetcher
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PayPlug\SyliusPayPlugPlugin\Migrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Closes a TOCTOU race in PayplugCardPersister::persist(): its findOneBy-then-add dedup guard is
12+
* reachable from two independent paths for the same alias (the synchronous frictionless capture
13+
* and the async webhook), so without a DB-level constraint a race between them could create two
14+
* Card rows for one alias/liveness pair.
15+
*/
16+
final class Version20260901120000 extends AbstractMigration
17+
{
18+
public function getDescription(): string
19+
{
20+
return 'Added a unique constraint on payplug_cards (external_id, is_live) to prevent duplicate card aliases under a race.';
21+
}
22+
23+
public function up(Schema $schema): void
24+
{
25+
// The race this constraint closes has been reachable since the payplug_cards table's
26+
// introduction, so an existing merchant database may already carry duplicate
27+
// (external_id, is_live) rows — remove all but the lowest-id row per pair first, or the
28+
// CREATE UNIQUE INDEX below fails outright on any DB where that already happened.
29+
$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');
30+
$this->addSql('CREATE UNIQUE INDEX UNIQ_payplug_cards_external_id_is_live ON payplug_cards (external_id, is_live)');
31+
}
32+
33+
public function down(Schema $schema): void
34+
{
35+
$this->addSql('DROP INDEX UNIQ_payplug_cards_external_id_is_live ON payplug_cards');
36+
}
37+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PayPlug\SyliusPayPlugPlugin\Command;
6+
7+
class CaptureAliasPaymentRequest extends AbstractPayplugPaymentRequest
8+
{
9+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PayPlug\SyliusPayPlugPlugin\Command\Handler;
6+
7+
use PayPlug\SyliusPayPlugPlugin\Command\CaptureAliasPaymentRequest;
8+
use PayPlug\SyliusPayPlugPlugin\Command\PaymentCaptureFlow;
9+
use PayPlug\SyliusPayPlugPlugin\Entity\Card;
10+
use PayPlug\SyliusPayPlugPlugin\Resolver\SelectedCardResolver;
11+
use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureContextBuilder;
12+
use PayPlug\SyliusPayPlugPlugin\Upc\PaymentCaptureOutcomeApplier;
13+
use PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface;
14+
use PayplugUnifiedCore\Dto\CommonFieldsDto;
15+
use PayplugUnifiedCore\Dto\PaymentDto;
16+
use PayplugUnifiedCore\Exceptions\ApiException;
17+
use PayplugUnifiedCore\Exceptions\InvalidPaymentException;
18+
use Sylius\Abstraction\StateMachine\StateMachineInterface;
19+
use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface;
20+
use Sylius\Component\Core\Model\OrderInterface;
21+
use Sylius\Component\Core\Model\PaymentInterface;
22+
use Sylius\Component\Payment\Model\PaymentMethodInterface;
23+
use Sylius\Component\Payment\PaymentRequestTransitions;
24+
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
25+
26+
/**
27+
* Pays with an already-created alias (a saved Card selected at checkout) instead of a
28+
* hosted-fields token — the sibling capture path to CaptureHostedPaymentRequestHandler, dispatched
29+
* by CaptureHostedPaymentRequestCommandProvider when the customer picked a saved card.
30+
*/
31+
#[AsMessageHandler]
32+
final class CaptureAliasPaymentRequestHandler
33+
{
34+
public function __construct(
35+
private PaymentRequestProviderInterface $paymentRequestProvider,
36+
private StateMachineInterface $stateMachine,
37+
private UnifiedApiPaymentCreatorInterface $unifiedApiPaymentCreator,
38+
private SelectedCardResolver $selectedCardResolver,
39+
private PaymentCaptureContextBuilder $contextBuilder,
40+
private PaymentCaptureOutcomeApplier $outcomeApplier,
41+
) {
42+
}
43+
44+
public function __invoke(CaptureAliasPaymentRequest $captureAliasPaymentRequest): void
45+
{
46+
$paymentRequest = $this->paymentRequestProvider->provide($captureAliasPaymentRequest);
47+
/** @var PaymentInterface $payment */
48+
$payment = $paymentRequest->getPayment();
49+
50+
try {
51+
$method = $this->contextBuilder->resolvePaymentMethod($payment);
52+
53+
$card = $this->selectedCardResolver->resolve();
54+
if (null === $card) {
55+
throw new \LogicException('No saved card alias selected for the payment.');
56+
}
57+
[$amount, $currencyCode] = $this->contextBuilder->resolveAmountAndCurrency($payment);
58+
[$accountId, $submerchantExternalId] = $this->contextBuilder->resolveGatewayCredentials($method);
59+
60+
$order = $this->assertCardBelongsToOrder($card, $payment->getOrder(), $method);
61+
$common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $submerchantExternalId, $paymentRequest, $order);
62+
$dto = $this->buildPaymentDto($common, $card, $order);
63+
64+
$output = $this->unifiedApiPaymentCreator->createPayment($dto);
65+
} catch (ApiException | InvalidPaymentException | \LogicException $e) {
66+
$this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Alias);
67+
68+
return;
69+
}
70+
71+
$payment->setDetails([
72+
...$payment->getDetails(),
73+
'alias_id' => $card->getExternalId(),
74+
'alias_payment_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
75+
...$this->contextBuilder->resolveHostedFieldsIds($output->body),
76+
]);
77+
78+
$this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output);
79+
80+
$this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE);
81+
}
82+
83+
private function assertCardBelongsToOrder(
84+
Card $card,
85+
?OrderInterface $order,
86+
PaymentMethodInterface $method,
87+
): OrderInterface {
88+
if (null === $order || $card->getCustomer() !== $order->getCustomer() || $card->getPaymentMethod() !== $method) {
89+
throw new \LogicException('Selected card does not belong to the paying customer or payment method.');
90+
}
91+
92+
return $order;
93+
}
94+
95+
private function buildPaymentDto(CommonFieldsDto $common, Card $card, OrderInterface $order): PaymentDto
96+
{
97+
$customerDto = $this->contextBuilder->buildCustomerDto($order);
98+
$browserDto = $this->contextBuilder->buildBrowserDto();
99+
100+
$fullName = $this->contextBuilder->resolveFullNameForCardDetails($order);
101+
$paymentMethod = null !== $fullName
102+
? ['details' => ['fullName' => $fullName]]
103+
: null;
104+
105+
return new PaymentDto($common, $card->getExternalId(), 'ONE_CLICK', $browserDto, $customerDto, $paymentMethod);
106+
}
107+
}

0 commit comments

Comments
 (0)