Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -37,6 +37,7 @@
"friendsoftwig/twigcs": "6.5.0",
"lakion/mink-debug-extension": "2.0.0",
"mockery/mockery": "1.6.12",
"payplug/unified-plugin-core": "dev-develop",
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpmd/phpmd": "^2.15.0",
"phpro/grumphp": "^2.12",
Expand All @@ -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
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');
}
Comment thread
jhoaraupp marked this conversation as resolved.

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');
}
}
Comment thread
jhoaraupp marked this conversation as resolved.
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
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);
}
Comment thread
jhoaraupp marked this conversation as resolved.

/**
* 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
116 changes: 116 additions & 0 deletions src/Spike/Entity/PayplugOperation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Spike\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use PayplugUnifiedCore\Models\OperationData;

/**
* PRE-3469 spike: normalized Doctrine schema for OperationData — not shipped code.
*
* Friction found: the plugin currently stores the Payplug payment id inline in Sylius's own
* Payment::details JSON blob and looks it up with a `LIKE '%id%'` query (see
* PaymentRepository::findOneByPayPlugPaymentId) — there is no separate "operation" table today.
* That works for the single id lookup it was built for, but it cannot support
* IPaymentRepository::markTreated()/isTreated() (needs its own indexed idempotency flag) or
* getByOperationId() (needs operationId to be a real, indexed column, not a substring match
* inside a serialized blob). Hence this new table rather than extending the existing one.
*
* Separately, this class living outside src/Entity/ means it is not auto-registered the way
* this plugin's other entities are (see config/resources.yaml, which wires Card and
* RefundHistory as `sylius_resource` entries) — a real (non-spike) version of this table would
* need either a `sylius_resource` entry or an explicit `doctrine.orm.mappings` prepend for
* whatever directory it lives in.
*/
#[ORM\Entity]
#[ORM\Table(name: 'payplug_operation')]
#[ORM\UniqueConstraint(name: 'payplug_operation_id_unique', columns: ['operation_id'])]
#[ORM\Index(name: 'payplug_operation_order_id_idx', columns: ['order_id'])]
class PayplugOperation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;

#[ORM\Column(name: 'operation_id', type: Types::STRING)]
private string $operationId;

#[ORM\Column(name: 'exec_code', type: Types::STRING)]
private string $execCode;

#[ORM\Column(name: 'outcome', type: Types::STRING)]
private string $outcome;

#[ORM\Column(name: 'amount', type: Types::INTEGER)]
private int $amount;

#[ORM\Column(name: 'order_id', type: Types::STRING)]
private string $orderId;

#[ORM\Column(name: 'treated', type: Types::BOOLEAN)]
private bool $treated = false;

#[ORM\Column(name: 'treated_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $treatedAt = null;

#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;

public function __construct(string $operationId, string $execCode, string $outcome, int $amount, string $orderId)
{
$this->operationId = $operationId;
$this->execCode = $execCode;
$this->outcome = $outcome;
$this->amount = $amount;
$this->orderId = $orderId;
$this->createdAt = new \DateTimeImmutable();
}

public static function fromOperationData(OperationData $operationData): self
{
return new self(
$operationData->operationId,
$operationData->execCode,
$operationData->outcome,
$operationData->amount,
$operationData->orderId,
);
}

public function updateFromOperationData(OperationData $operationData): void
{
$this->execCode = $operationData->execCode;
$this->outcome = $operationData->outcome;
$this->amount = $operationData->amount;
}

public function toOperationData(): OperationData
{
return new OperationData($this->operationId, $this->execCode, $this->outcome, $this->amount, $this->orderId);
}

public function getOperationId(): string
{
return $this->operationId;
}

public function getOrderId(): string
{
return $this->orderId;
}

public function isTreated(): bool
{
return $this->treated;
}

public function markTreated(): void
{
$this->treated = true;
$this->treatedAt = new \DateTimeImmutable();
}
}
119 changes: 119 additions & 0 deletions src/Spike/SyliusConfigurationRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Spike;

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

/**
* PRE-3469 spike: proof-of-concept implementation of IConfigurationRepository against Sylius's
* GatewayConfigInterface — not shipped code.
*
* Friction found: 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 IConfigurationRepository 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.
*
* Positive finding: 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 SyliusConfigurationRepository 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->requireString('public_key_id');
}

public function getPublicKeyValue(): string
{
return $this->requireString('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';
}
}
Loading
Loading