Skip to content
Draft
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
7 changes: 7 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"ext-json": "*",
"giggsey/libphonenumber-for-php": "^8.12",
"payplug/payplug-php": "^4.0",
"payplug/unified-plugin-core": "dev-develop",
"php-http/message-factory": "^1.1",
"sylius/refund-plugin": "^2.0",
"sylius/sylius": "^2.0",
Expand Down Expand Up @@ -57,6 +58,12 @@
"webmozart/assert": "^1.8"
},
"prefer-stable": true,
"repositories": [
{
"type": "vcs",
"url": "https://github.com/payplug/unified-plugin-core.git"
}
Comment thread
jhoaraupp marked this conversation as resolved.
],
"autoload": {
"psr-4": {
"PayPlug\\SyliusPayPlugPlugin\\": "src/"
Expand Down
6 changes: 6 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ services:
bind:
Psr\Log\LoggerInterface: '@monolog.logger.payplug'

# PRE-3469: explicit alias, matching this file's convention for other interfaces below —
# relying on Symfony to auto-resolve a singly-implemented interface does not work for plain
# autowiring (confirmed: omitting this alias fails container compilation outright, since
# NotifyPaymentRequestHandler's #[AsMessageHandler] service is always instantiated).
PayplugUnifiedCore\Contracts\IOrderStateMutator: '@PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PayplugOrderStateMutator'

PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface:
class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository
parent: sylius.repository.payment
Expand Down
62 changes: 62 additions & 0 deletions migrations/Version20260720100000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Migrations;

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

/**
* PRE-3469 spike only: schema for the throwaway PayplugOperation entity (src/Spike/Entity).
* A real migration — not a manual step or a SchemaTool call from the test — because the
* mapping is registered whenever kernel.environment=test (see
* PayPlugSyliusPayPlugExtension::prependSpikeDoctrineMapping()), which includes routine
* `sylius:fixtures:load` runs on a fresh checkout, not just the spike's own integration test.
* Without this migration, `sylius:fixtures:load` fails for anyone setting up the test
* application from scratch — found by testing this on a clean database, not by reasoning about
* it. Guarded to APP_ENV=test in both directions so a real merchant deployment never gets this
* table created (up) or, if it somehow did, never gets it dropped outside test either (down) —
* a normal plugin migration otherwise runs unconditionally, including in production. Drop this
* migration together with src/Spike/ once the spike is closed.
*/
final class Version20260720100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'PRE-3469 spike: add payplug_operation table for the throwaway PayplugOperation entity.';
}

public function up(Schema $schema): void
{
$this->skipIf(
'test' !== ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null),
'PRE-3469 spike-only schema, not applied outside the test environment.',
);

$this->addSql('CREATE TABLE payplug_operation (
id INT AUTO_INCREMENT NOT NULL,
operation_id VARCHAR(255) NOT NULL,
exec_code VARCHAR(255) NOT NULL,
outcome VARCHAR(255) NOT NULL,
amount INT NOT NULL,
order_id VARCHAR(255) NOT NULL,
treated TINYINT(1) NOT NULL,
treated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
UNIQUE INDEX payplug_operation_id_unique (operation_id),
INDEX payplug_operation_order_id_idx (order_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB');
}

public function down(Schema $schema): void
{
$this->skipIf(
'test' !== ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null),
'PRE-3469 spike-only schema, not applied outside the test environment.',
);

$this->addSql('DROP TABLE payplug_operation');
}
}
6 changes: 5 additions & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@
<testsuites>
<testsuite name="Test Suite">
<directory>tests/PHPUnit</directory>
<!-- PRE-3469 spike: needs a provisioned DB + fixtures + the payplug_operation table,
none of which a fresh checkout or CI has. Not part of the default run — see its
own docblock for how to run it explicitly. -->
<exclude>tests/PHPUnit/Spike/SpikeIntegrationTest.php</exclude>
</testsuite>
</testsuites>

<php>
<ini name="error_reporting" value="-1" />
<server name="KERNEL_CLASS_PATH" value="Sylius\TestApplication\Kernel" />
<server name="KERNEL_CLASS" value="Sylius\TestApplication\Kernel" />
<server name="IS_DOCTRINE_ORM_SUPPORTED" value="true" />

<server name="APP_ENV" value="test" force="true" />
Expand Down
62 changes: 62 additions & 0 deletions src/Command/Handler/NotifyPaymentRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@

use Payplug\Resource\Payment;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Command\NotifyPaymentRequest;
use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\Handler\RefundNotificationHandler;
use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\PaymentTransitionApplier;
use PayplugUnifiedCore\Contracts\IOrderStateMutator;
use PayplugUnifiedCore\Models\PaymentOutcome;
use Psr\Log\LoggerInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\PaymentBundle\Provider\PaymentRequestProviderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
Expand All @@ -19,13 +23,30 @@
#[AsMessageHandler]
class NotifyPaymentRequestHandler
{
/**
* PRE-3469: additive translation from the PayPlug status vocabulary (the same one
* PaymentTransitionApplier already maps from) to UPC's PaymentOutcome vocabulary, for the
* real IOrderStateMutator call below. Statuses with no PaymentOutcome equivalent
* (e.g. STATUS_ABORTED/STATUS_CANCELED*, which PaymentTransitionApplier maps to
* TRANSITION_CANCEL) are intentionally absent here — they're skipped, never force-mapped.
*
* @var array<string, string>
*/
private const STATUS_TO_OUTCOME = [
PayPlugApiClientInterface::STATUS_CAPTURED => PaymentOutcome::PAID,
PayPlugApiClientInterface::STATUS_AUTHORIZED => PaymentOutcome::AUTHORIZED,
PayPlugApiClientInterface::FAILED => PaymentOutcome::FAILED,
];

public function __construct(
private PaymentRequestProviderInterface $paymentRequestProvider,
private StateMachineInterface $stateMachine,
private PayPlugApiClientFactoryInterface $apiClientFactory,
private PaymentNotificationHandler $paymentNotificationHandler,
private RefundNotificationHandler $refundNotificationHandler,
private PaymentTransitionApplier $paymentTransitionApplier,
private IOrderStateMutator $orderStateMutator,
private LoggerInterface $logger,
) {}

public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
Expand Down Expand Up @@ -67,6 +88,7 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
$payment->setDetails($details->getArrayCopy());
if ($resource instanceof Payment) {
$this->paymentTransitionApplier->apply($payment);
$this->applyOrderStateMutator($payment);
}

$this->stateMachine->apply(
Expand All @@ -85,4 +107,44 @@ public function __invoke(NotifyPaymentRequest $notifyPaymentRequest): void
);
}
}

/**
* PRE-3469: additive real call site for PayplugOrderStateMutator. PaymentTransitionApplier
* has already applied the real transition above by the time this runs, so the mutator's own
* can()-guard makes this a no-op in the normal case — this exists to prove the contract works
* against a live webhook event, not to change behavior. Any failure here is caught and
* logged, never allowed to affect the primary notification flow above.
*/
private function applyOrderStateMutator(PaymentInterface $payment): void
{
$details = $payment->getDetails(); // @phpstan-ignore-line - getDetails() return mixed
$status = $details['status'] ?? '';
$outcome = self::STATUS_TO_OUTCOME[$status] ?? null;
if (null === $outcome) {
return;
}

$order = $payment->getOrder();
if (null === $order) {
return;
}

// PayplugOrderStateMutator resolves the order's *last* payment internally (its contract
// is keyed by order ID, not payment ID). On a multi-payment order — e.g. a failed attempt
// followed by a retry — that could be a different payment than the one this webhook is
// actually about. Skip rather than risk transitioning the wrong payment.
if ($order->getLastPayment()?->getId() !== $payment->getId()) {
return;
}

try {
$this->orderStateMutator->apply((string) $order->getId(), $outcome); // @phpstan-ignore-line - ResourceInterface::getId() return mixed
} catch (\Throwable $e) {
$this->logger->warning('[PayPlug] PayplugOrderStateMutator additive call failed.', [
'sylius_payment_id' => $payment->getId(),
'outcome' => $outcome,
'exception' => $e->getMessage(),
]);
}
}
}
123 changes: 123 additions & 0 deletions src/ConfigurationRepository/PayplugConfigurationRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\ConfigurationRepository;

use PayplugUnifiedCore\Contracts\IConfigurationRepository;
use PayplugUnifiedCore\Exceptions\ApiException;
use Sylius\Component\Payment\Model\GatewayConfigInterface;

/**
* PRE-3469: real implementation of IConfigurationRepository against Sylius's
* GatewayConfigInterface.
*
* IConfigurationRepository assumes one flat set of credentials, but Sylius scopes gateway
* config per PaymentMethod *and* per live/test mode (`config['live_client']` vs
* `config['test_client']`, selected by `config['live']`, exactly as PayPlugApiClientFactory
* already does). So a single PayplugConfigurationRepository instance has to be constructed per
* GatewayConfigInterface (i.e. per PaymentMethod) rather than shared as one repository-wide
* service — a factory, not a singleton. Not blocking, but worth flagging if the Unified API
* client this feeds ever assumes one repository == one merchant.
*
* getPublicKeyId()/getPublicKeyValue() default to an empty string rather than throwing: unlike
* client_id/client_secret, no production code writes public_key_id/public_key_value yet
* (Hosted Fields isn't built) — requiring them would make the contract unimplementable until a
* future ticket adds that writer.
*
* Sylius already ships an (experimental) GatewayConfigEncrypter that transparently encrypts the
* whole `getConfig()` array at rest (Sylius\Component\Payment\Encryption) — if wired up,
* CLIENT_SECRET benefits from that for free. What this class must still guarantee on its own is
* that a *decrypted* secret never leaks into a log line or exception message, which is why
* requireString() below only ever interpolates the config *key name*, never its value.
*/
final class PayplugConfigurationRepository implements IConfigurationRepository
{
public function __construct(private readonly GatewayConfigInterface $gatewayConfig)
{
}

public function get(string $key): ?string
{
$client = $this->activeClientConfig();
$value = $client[$key] ?? null;

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

public function set(string $key, string $value): void
{
$config = $this->gatewayConfig->getConfig();
$scope = $this->activeScope($config);
$client = $config[$scope] ?? [];
if (!\is_array($client)) {
$client = [];
}
$client[$key] = $value;
$config[$scope] = $client;

// Persisting $config is the caller's responsibility (Doctrine flush), same as every
// other GatewayConfigInterface mutation in this plugin (see UnifiedAuthenticationController).
$this->gatewayConfig->setConfig($config);
}

public function getClientId(): string
{
return $this->requireString('client_id');
}

public function getClientSecret(): string
{
return $this->requireString('client_secret');
}

public function getPublicKeyId(): string
{
return $this->get('public_key_id') ?? '';
}

public function getPublicKeyValue(): string
{
return $this->get('public_key_value') ?? '';
}

private function requireString(string $key): string
{
$value = $this->get($key);
if (null === $value || '' === $value) {
// Never interpolate the resolved *value* here, only the key name and factory name.
throw new ApiException(\sprintf(
'Missing "%s" in gateway configuration "%s".',
$key,
$this->gatewayConfig->getFactoryName() ?? 'unknown',
));
}

return $value;
}

/**
* @return array<string, mixed>
*/
private function activeClientConfig(): array
{
$config = $this->gatewayConfig->getConfig();
$client = $config[$this->activeScope($config)] ?? [];
if (!\is_array($client)) {
return [];
}

/** @var array<string, mixed> $typedClient */
$typedClient = $client;

return $typedClient;
}

/**
* @param array<string, mixed> $config
*/
private function activeScope(array $config): string
{
return true === ($config['live'] ?? false) ? 'live_client' : 'test_client';
}
}
29 changes: 29 additions & 0 deletions src/DependencyInjection/PayPlugSyliusPayPlugExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,35 @@ public function prepend(ContainerBuilder $container): void
$this->prependTwigExtension($container);
$this->prependDoctrineMigrations($container);
$this->prependMonologExtension($container);
$this->prependSpikeDoctrineMapping($container);
}

/**
* PRE-3469 spike only — registers src/Spike/Entity as a plain (non-Sylius-resource)
* Doctrine mapping so the spike's integration test can persist PayplugOperation for real.
* Not a `sylius_resource` on purpose: that would pull in grids/forms/routes this throwaway
* entity has no use for. Restricted to the `test` environment on purpose too — this is
* test-only scaffolding, it must never register Doctrine metadata for a throwaway entity in
* prod. Remove this method along with src/Spike/ once the spike is closed.
*/
private function prependSpikeDoctrineMapping(ContainerBuilder $container): void
{
if (!$container->hasExtension('doctrine') || 'test' !== $container->getParameter('kernel.environment')) {
return;
}

$container->prependExtensionConfig('doctrine', [
'orm' => [
'mappings' => [
'PayPlugSyliusPayPlugPluginSpike' => [
'type' => 'attribute',
'dir' => dirname(__DIR__) . '/Spike/Entity',
'prefix' => 'PayPlug\SyliusPayPlugPlugin\Spike\Entity',
'is_bundle' => false,
],
],
],
]);
}

private function prependTwigExtension(ContainerBuilder $container): void
Expand Down
Loading
Loading