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
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": "0.0.7",
"payplug/unified-plugin-core": "dev-develop",
"php-http/message-factory": "^1.1",
"sylius/refund-plugin": "^2.0",
"sylius/sylius": "^2.0",
Expand Down
5 changes: 5 additions & 0 deletions config/resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ sylius_resource:
driver: doctrine/orm
classes:
model: PayPlug\SyliusPayPlugPlugin\Entity\Card
payplug.payplug_operation:
driver: doctrine/orm
classes:
model: PayPlug\SyliusPayPlugPlugin\Entity\PayplugOperation
repository: PayPlug\SyliusPayPlugPlugin\Repository\PayplugOperationRepository
20 changes: 20 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,26 @@ services:
- name: sylius.payment_request.provider.http_response
gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory::FACTORY_NAME

## Uhf Payplug Gateway ##
payplug_sylius_payplug_plugin.command_provider.payplug_uhf:
class: Sylius\Bundle\PaymentBundle\CommandProvider\ActionsCommandProvider
arguments:
- !tagged_locator
tag: payplug_sylius_payplug_plugin.command_provider.payplug_uhf
index_by: 'action'
tags:
- name: sylius.payment_request.command_provider
gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory::FACTORY_NAME
payplug_sylius_payplug_plugin.provider.order_pay.http_response.payplug_uhf:
class: Sylius\Bundle\PaymentBundle\Provider\ActionsHttpResponseProvider
arguments:
- !tagged_locator
tag: payplug_sylius_payplug_plugin.http_response_provider.payplug_uhf
index_by: action
tags:
- name: sylius.payment_request.provider.http_response
gateway_factory: !php/const PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory::FACTORY_NAME


## Apple Pay Payplug Gateway ##
payplug_sylius_payplug_plugin.command_provider.payplug_apple_pay:
Expand Down
32 changes: 32 additions & 0 deletions config/services/client.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,31 @@
<service id="PayplugUnifiedCore\Contracts\ITokenCache"
alias="PayPlug\SyliusPayPlugPlugin\Auth\SyliusTokenCache" />

<service id="PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient"
alias="PayPlug\SyliusPayPlugPlugin\Auth\SyliusUnifiedApiHttpClient" />

<service id="PayplugUnifiedCore\Contracts\ILock"
alias="PayPlug\SyliusPayPlugPlugin\Lock\SyliusLock" />

<service id="PayplugUnifiedCore\Contracts\IOrderStateMutator"
alias="PayPlug\SyliusPayPlugPlugin\PaymentProcessing\SyliusOrderStateMutator" />

<!-- src/Repository is excluded from the PayPlug\SyliusPayPlugPlugin\ autowire scan in
config/services.yaml, so unlike the adapters above, SyliusPaymentRepository is not
auto-registered β€” it needs an explicit <service> definition here. Its
PayplugOperationRepositoryInterface dependency is aliased to the Sylius resource
repository registered for the payplug_operation resource (config/resources.yaml). -->
<service id="PayPlug\SyliusPayPlugPlugin\Repository\PayplugOperationRepositoryInterface"
alias="PayPlug\SyliusPayPlugPlugin\Repository\PayplugOperationRepository" />

<service id="PayPlug\SyliusPayPlugPlugin\Repository\SyliusPaymentRepository" />

<service id="PayplugUnifiedCore\Contracts\IPaymentRepository"
alias="PayPlug\SyliusPayPlugPlugin\Repository\SyliusPaymentRepository" />

<service id="PayplugUnifiedCore\Contracts\IConfigurationRepository"
alias="PayPlug\SyliusPayPlugPlugin\Auth\SyliusConfigurationRepository" />

<service id="PayplugUnifiedCore\Auth\OAuth2Client">
<!-- Only used for the client-credentials grant (background token refresh);
redirectUri/scope are irrelevant to that grant, left empty. -->
Expand All @@ -29,6 +54,13 @@

<service id="PayplugUnifiedCore\Auth\TokenManager" />

<!-- src/ApiClient is excluded from the PayPlug\SyliusPayPlugPlugin\ autowire scan in
config/services.yaml (same reason as PayPlugApiClientFactory above), so this needs
an explicit <service> definition too. -->
<service id="PayPlug\SyliusPayPlugPlugin\ApiClient\UnifiedApiHostedPaymentServiceFactory">
<argument key="$payplugOauthBaseUrl">%payplug.oauth_base_url%</argument>
</service>

<service id="payplug_sylius_payplug_plugin.api_client.payplug"
class="PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClient"
public="true"
Expand Down
8 changes: 8 additions & 0 deletions config/services/gateway.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,13 @@
<tag name="payum.gateway_factory_builder"
factory="payplug_wero"/>
</service>

<!-- Gateway Unified Hosted Fields by PayPlug -->
<service id="payplug_sylius_payplug_plugin.gateway_factory.uhf"
class="Payum\Core\Bridge\Symfony\Builder\GatewayFactoryBuilder">
<argument type="string">PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory</argument>
<tag name="payum.gateway_factory_builder"
factory="payplug_uhf"/>
</service>
</services>
</container>
38 changes: 38 additions & 0 deletions migrations/Version20260806120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Migrations;

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

final class Version20260806120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Added payplug_operations table backing UPC IPaymentRepository (webhook idempotency tracking).';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE payplug_operations (
id INT AUTO_INCREMENT NOT NULL,
operation_id VARCHAR(255) NOT NULL,
order_id VARCHAR(255) NOT NULL,
exec_code VARCHAR(255) NOT NULL,
outcome VARCHAR(255) NOT NULL,
amount INT NOT NULL,
treated TINYINT(1) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE INDEX UNIQ_PAYPLUG_OPERATIONS_OPERATION_ID (operation_id),
INDEX IDX_PAYPLUG_OPERATIONS_ORDER_ID (order_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB');
}

public function down(Schema $schema): void
{
$this->addSql('DROP TABLE payplug_operations');
}
}
12 changes: 12 additions & 0 deletions ruleset/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,18 @@ parameters:
count: 1
path: ../src/Repository/PaymentRepository.php

-
message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\PayplugOperationRepository\:\:findOneByOperationId\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\PayplugOperation\|null but returns mixed\.$#'
identifier: return.type
count: 1
path: ../src/Repository/PayplugOperationRepository.php

-
message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\PayplugOperationRepository\:\:findOneByOrderId\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\PayplugOperation\|null but returns mixed\.$#'
identifier: return.type
count: 1
path: ../src/Repository/PayplugOperationRepository.php

-
message: '#^Method PayPlug\\SyliusPayPlugPlugin\\Repository\\RefundHistoryRepository\:\:findLastProcessedRefundForPayment\(\) should return PayPlug\\SyliusPayPlugPlugin\\Entity\\RefundHistory\|null but returns mixed\.$#'
identifier: return.type
Expand Down
70 changes: 70 additions & 0 deletions src/ApiClient/UnifiedApiHostedPaymentServiceFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\ApiClient;

use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException;
use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
use PayplugUnifiedCore\Auth\TokenManager;
use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient;
use PayplugUnifiedCore\Services\UnifiedApiHostedPaymentService;
use Sylius\Component\Payment\Model\GatewayConfigInterface;
use Sylius\Component\Payment\Model\PaymentMethodInterface;

/**
* Builds a per-merchant UnifiedApiHostedPaymentService from a payment method's gateway config,
* the same way PayPlugApiClientFactory::createForPaymentMethod() builds a per-merchant
* PayPlugApiClient: clientId/clientSecret/accountId are plain constructor arguments (not sourced
* from IConfigurationRepository), since account_id identifies the Unified API processing account
* and has no relationship to the OAuth2 clientId/clientSecret pair (see
* UnifiedApiHostedPaymentService's own docblock).
*/
final class UnifiedApiHostedPaymentServiceFactory
{
public function __construct(
private IUnifiedApiHttpClient $httpClient,
private TokenManager $tokenManager,
private string $payplugOauthBaseUrl,
) {
}

public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): UnifiedApiHostedPaymentService
{
$gatewayConfig = $paymentMethod->getGatewayConfig() ?? throw new \LogicException('Gateway config not found');
$clientConfig = $this->getClientConfig($gatewayConfig);

$clientId = $clientConfig['client_id'] ?? '';
$clientSecret = $clientConfig['client_secret'] ?? '';
$accountId = $clientConfig['account_id'] ?? '';

if ('' === $clientId || '' === $clientSecret || '' === $accountId) {
throw new GatewayConfigurationException('No client config found for ' . UhfGatewayFactory::FACTORY_NAME . '. Please renew your credentials in the PayPlug plugin configuration.');
}

return new UnifiedApiHostedPaymentService(
$this->httpClient,
$this->tokenManager,
$this->payplugOauthBaseUrl,
$clientId,
$clientSecret,
$accountId,
);
}

/** @return array<string, string> */
private function getClientConfig(GatewayConfigInterface $gatewayConfig): array
{
$config = $gatewayConfig->getConfig();
$isLive = true === ($config['live'] ?? false);
$rawClientConfig = $isLive ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null);
if (!\is_array($rawClientConfig)) {
return [];
}

/** @var array<string, string> $clientConfig */
$clientConfig = $rawClientConfig;

return $clientConfig;
}
}
89 changes: 89 additions & 0 deletions src/Auth/SyliusConfigurationRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Auth;

use PayPlug\SyliusPayPlugPlugin\Gateway\UhfGatewayFactory;
use PayplugUnifiedCore\Contracts\IConfigurationRepository;
use Sylius\Component\Payment\Model\GatewayConfigInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;

final class SyliusConfigurationRepository implements IConfigurationRepository
{
public function __construct(
private RepositoryInterface $gatewayConfigRepository,
) {
}

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

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

public function set(string $key, string $value): void
{
$gatewayConfig = $this->getGatewayConfig();
$config = $gatewayConfig->getConfig();
$config[$key] = $value;
$gatewayConfig->setConfig($config);
$this->gatewayConfigRepository->add($gatewayConfig);
}

public function getClientId(): string
{
return $this->getClientConfig()['client_id'] ?? '';
}

public function getClientSecret(): string
{
return $this->getClientConfig()['client_secret'] ?? '';
}

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

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

/** @return array<string, mixed> */
private function getConfig(): array
{
return $this->getGatewayConfig()->getConfig();
}

private function getGatewayConfig(): GatewayConfigInterface
{
/** @var GatewayConfigInterface|null $gatewayConfig */
$gatewayConfig = $this->gatewayConfigRepository->findOneBy(['factoryName' => UhfGatewayFactory::FACTORY_NAME]);

if (null === $gatewayConfig) {
throw new \LogicException('No gateway config found for ' . UhfGatewayFactory::FACTORY_NAME);
}

return $gatewayConfig;
}

/** @return array<string, string> */
private function getClientConfig(): array
{
$config = $this->getConfig();
$isLive = true === ($config['live'] ?? false);
$rawClientConfig = $isLive ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null);

if (!\is_array($rawClientConfig)) {
return [];
}

/** @var array<string, string> $clientConfig */
$clientConfig = $rawClientConfig;

return $clientConfig;
}
}
62 changes: 62 additions & 0 deletions src/Auth/SyliusUnifiedApiHttpClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Auth;

use PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class SyliusUnifiedApiHttpClient implements IUnifiedApiHttpClient
{
public function __construct(
private HttpClientInterface $httpClient,
) {
}

/**
* @param array<string, string> $headers
*
* @return array{status: int, body: string}
*/
public function get(string $url, array $headers = []): array
{
return $this->request('GET', $url, ['headers' => $headers]);
}

/**
* @param array<string, mixed> $body
* @param array<string, string> $headers
*
* @return array{status: int, body: string}
*/
public function postJson(string $url, array $body, array $headers = []): array
{
return $this->request('POST', $url, ['json' => $body, 'headers' => $headers]);
}

/**
* @param array<string, mixed> $options
*
* @return array{status: int, body: string}
*/
private function request(string $method, string $url, array $options): array
{
try {
$response = $this->httpClient->request($method, $url, $options);

return [
'status' => $response->getStatusCode(),
// false = don't throw on non-2xx; the Unified API caller itself checks the status.
'body' => $response->getContent(false),
];
} catch (TransportExceptionInterface $e) {
// Network-level failure (DNS, timeout, connection reset) β€” getStatusCode()/getContent()
// throw this regardless of the `false` above, since it only suppresses HTTP status
// exceptions, not transport ones. Status 0 mirrors SyliusOAuthHttpClient's translation
// so callers can treat any UPC HTTP adapter failure uniformly.
return ['status' => 0, 'body' => $e->getMessage()];
}
}
}
Loading
Loading