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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- PHP 8.2+ compatibility
- Use Payment Request API from Sylius
- New Unified Authentication System (OAuth2)
- **Multi-shop support**: several gateway configurations of the same type may now coexist, each
connected to its own PayPlug account and scoped to its own channels
- The connected PayPlug account is displayed on each gateway's update screen
- "Disconnect this account" per gateway, clearing that gateway's credentials without touching others
- Channels already claimed by another enabled gateway of the same type are rendered unselectable

> [!IMPORTANT]
> Merchants will need to contact support to switch to the new authentication method.

### Changed
- Plugin structure has been changed to follow the new Symfony bundle structure
- Front assets have been migrated to use Stimulus
- Gateway uniqueness is now enforced **per channel** instead of per installation: two gateways of
the same type may both be enabled as long as their channel sets are disjoint
- Credentials are resolved from the payment method rather than from the gateway factory name, so a
request for one channel can no longer be signed with another channel's account

### Removed
- Drop Payum support
- Drop Sylius 1.x support
- Drop usage of Secret key - Use OAuth2 instead

### Fixed
- Integrated Payment no longer accepts a payment method id that does not belong to the order's
channel, or a disabled one — previously a shopper could have the payment created on another
merchant's PayPlug account
- Oney instalment options, the Oney availability check and Apple Pay now resolve the gateway serving
the current channel instead of an arbitrary one

### Breaking changes for anyone extending the plugin

| Removed / changed | Replacement |
| --- | --- |
| `PayPlugApiClientFactoryInterface::create(string $factoryName)` | `createForPaymentMethod(PaymentMethodInterface $pm)` |
| `UnifiedApiPaymentCreatorInterface::createPayment($dto)` | `createPayment($dto, PaymentMethodInterface $method)` |
| `OperationStatusFetcherInterface::getOperation($id)` | `getOperation($id, PaymentMethodInterface $method)` |
| `AbstractGatewayConfigurationType::__construct()` — `$gatewayConfigRepository` and `$requestStack` dropped | translator only |
| `shouldValidateBaseCurrency()` / `baseCurrencyViolationMessage()` — `protected` → `public`, now take the **mapped** config | same hooks, new visibility/shape |
| `$gatewayFactoryName` property on the 8 configuration types | no longer read; the factory name comes off the gateway config |
| Translation key `form.only_one_gateway_allowed` | `form.gateway_channel_conflict` (`%channel%`, `%payment_method%`) |
| Injecting `PayplugUnifiedCore\Contracts\IConfigurationRepository` (its service alias is gone) | `ScopedConfigurationRepositoryInterface`, scoped per payment method |
| `PaymentMethodRepositoryInterface::findOneByGatewayName()` — **deprecated**, returns an arbitrary config when several share a factory name | `findOneEnabledByGatewayNameAndChannel($factoryName, $channel)` |
| `OneyExtension::__construct()` — `$gatewayConfigRepository` dropped, `$paymentMethodRepository` is now the plugin's `PaymentMethodRepositoryInterface` | inject the plugin repository |
| `OneySupportedPaymentChoiceProvider::__construct()` | now also takes a `ChannelContextInterface` |

Requires `payplug/unified-plugin-core ^1.1.2` (for the nullable `TokenOutput::$idToken`).

> [!NOTE]
> A gateway connected before this release shows a "re-authenticate" placeholder instead of the
> account email until the merchant reconnects — the address is only available from the interactive
> OAuth `id_token`, which is minted at login.

Please refer to [github releases](https://github.com/payplug/SyliusPayPlugPlugin/releases) for historical release information.

---
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.1.0",
"payplug/unified-plugin-core": "^1.1.2",
"php-http/message-factory": "^1.1",
"sylius/refund-plugin": "^2.0",
"sylius/sylius": "^2.0",
Expand Down
14 changes: 7 additions & 7 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ services:
$unifiedApiBaseUrl: '%payplug.unified_api_base_url%'
$unifiedApiVerifyTls: '%payplug.unified_api_verify_tls%'

# `security.csrf.token_manager` is only registered while CSRF protection is enabled, so it is
# injected with `@?`: a hard reference would break container compilation for an application
# that turns it off, rather than degrading the way Sylius's own admin actions do.
PayPlug\SyliusPayPlugPlugin\Action\Admin\Auth\UnifiedLogoutController:
arguments:
$csrfTokenManager: '@?security.csrf.token_manager'

PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface:
class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository
parent: sylius.repository.payment
Expand Down Expand Up @@ -59,7 +66,13 @@ services:
PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient:
alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusUnifiedApiHttpClient

PayplugUnifiedCore\Contracts\IConfigurationRepository:
# Deliberately NOT aliased: PayplugUnifiedCore\Contracts\IConfigurationRepository carries no
# account context on any of its methods, and since PRE-3628 a merchant may hold several CB
# gateway configs — an unscoped SyliusUpcConfigurationRepository throws on every read. Aliasing
# it would let a future UPC consumer autowire a service that blows up at runtime, in whatever
# code path UPC happened to choose; without the alias it fails at container build instead.
# Consumers take ScopedConfigurationRepositoryInterface and scope it to their payment method.
PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface:
alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusUpcConfigurationRepository

PayplugUnifiedCore\Contracts\IPaymentRepository:
Expand Down
9 changes: 9 additions & 0 deletions config/twig_hooks/admin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,30 @@ sylius_twig_hooks:
renew_oauth: &renewOAuth
template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/renew_oauth.html.twig'
priority: -5
connected_account: &connectedAccount
template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/connected_account.html.twig'
priority: -4
'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_oney':
<<: *oneyGateway
renew_oauth: *renewOAuth
connected_account: *connectedAccount
'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_bancontact':
<<: *bancontactGateway
renew_oauth: *renewOAuth
connected_account: *connectedAccount
'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_apple_pay':
<<: *applePayGateway
renew_oauth: *renewOAuth
connected_account: *connectedAccount
'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_american_express':
<<: *amexGateway
renew_oauth: *renewOAuth
connected_account: *connectedAccount
'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_scalapay':
<<: *scalapayGateway
renew_oauth: *renewOAuth
connected_account: *connectedAccount
'sylius_admin.payment_method.update.content.form.sections.gateway_configuration.payplug_wero':
<<: *weroGateway
renew_oauth: *renewOAuth
connected_account: *connectedAccount
10 changes: 2 additions & 8 deletions ruleset/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -665,14 +665,8 @@ parameters:
path: ../src/Gateway/AbstractGatewayFactory.php

-
message: '#^Cannot call method add\(\) on mixed\.$#'
identifier: method.nonObject
count: 1
path: ../src/Gateway/Form/Type/AbstractGatewayConfigurationType.php

-
message: '#^Cannot call method getId\(\) on mixed\.$#'
identifier: method.nonObject
message: '#^PHPDoc tag @SuppressWarnings has invalid value \(\(PHPMD\.UnusedFormalParameter\)\)\: Unexpected token "\.UnusedFormalParameter\)", expected ''\)'' at offset 554 on line 11$#'
identifier: phpDoc.parseError
count: 1
path: ../src/Gateway/Form/Type/AbstractGatewayConfigurationType.php

Expand Down
9 changes: 9 additions & 0 deletions src/Action/Admin/Auth/UnifiedAuthenticationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Doctrine\ORM\EntityManagerInterface;
use Payplug\Authentication;
use Payplug\Payplug;
use PayPlug\SyliusPayPlugPlugin\Auth\IdTokenEmailExtractor;
use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator;
use PayplugUnifiedCore\Auth\OAuth2Client;
use PayplugUnifiedCore\Contracts\IOAuthHttpClient;
Expand Down Expand Up @@ -44,6 +45,7 @@ public function __construct(
private PaymentMethodValidator $paymentMethodValidator,
private LoggerInterface $logger,
private IOAuthHttpClient $oauthHttpClient,
private IdTokenEmailExtractor $idTokenEmailExtractor,
private string $payplugOauthBaseUrl,
private string $payplugOauthAudience,
) {
Expand Down Expand Up @@ -125,6 +127,13 @@ public function oauthCallback(Request $request): Response
$config = $gatewayConfig->getConfig();
$config['live_client'] = $liveClientDataResult['httpResponse'] ?? null;
$config['test_client'] = $testClientDataResult['httpResponse'] ?? null;
// Who just authorized, for the admin screen to show. The id_token is the only carrier
// of that identity — /account has no email field and the client-credentials token used
// for every later API call names no user — and it is discarded with $token when this
// method returns, so it is captured here or not at all. Overwritten unconditionally,
// including with null: after a re-auth against a different PayPlug account, keeping the
// previous address would misreport which account is taking the money.
$config['account_email'] = $this->idTokenEmailExtractor->extract($token->idToken);
$gatewayConfig->setConfig($config);

$this->entityManager->flush();
Expand Down
126 changes: 126 additions & 0 deletions src/Action/Admin/Auth/UnifiedLogoutController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Action\Admin\Auth;

use PayPlug\SyliusPayPlugPlugin\Auth\GatewayConnectionRevoker;
use Psr\Log\LoggerInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Resource\Doctrine\Persistence\RepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

/**
* Disconnects one gateway config from its PayPlug account — the counterpart of
* {@see UnifiedAuthenticationController}, kept apart from it because it shares none of the OAuth
* dance's session state.
*
* Distinct from the `renew_oauth` checkbox, which immediately starts a *new* authorization and
* ends with credentials again. Logout ends with none, and with the gateway disabled.
*
* GET rather than POST: the button lives inside the Sylius payment-method form, where a nested
* <form> would be invalid HTML. The CSRF token is therefore carried in the query string and
* checked here — it is the only thing between a crafted link and a merchant losing a connection.
*/
#[Route('/payplug/auth')]
final class UnifiedLogoutController extends AbstractController
{
/**
* Per-payment-method so a token minted for one gateway cannot be replayed against another.
* Public because the template that renders the button mints the token with it.
*/
public const CSRF_TOKEN_ID_PREFIX = 'payplug_logout_';

/**
* @param RepositoryInterface<\Sylius\Component\Core\Model\PaymentMethod> $paymentMethodRepository
*/
public function __construct(
private RouterInterface $router,
private RepositoryInterface $paymentMethodRepository,
private GatewayConnectionRevoker $connectionRevoker,
private ?CsrfTokenManagerInterface $csrfTokenManager,
private LoggerInterface $logger,
) {
}

#[Route('/logout/{id}', name: 'payplug_sylius_admin_auth_logout', requirements: ['id' => '\d+'], methods: ['GET'])]
public function logout(Request $request, int $id): Response
{
$this->denyUnlessCsrfTokenIsValid($request, $id);
$paymentMethod = $this->findConnectedPayPlugPaymentMethod($id);

try {
$this->connectionRevoker->revoke($paymentMethod);
$this->addFlashMessage($request, 'success', 'payplug_sylius_payplug_plugin.admin.logout_success');
} catch (\Throwable $e) {
$this->logger->critical('Error while logging out the Payplug gateway', ['message' => $e->getMessage(), 'exception' => $e]);
$this->addFlashMessage($request, 'error', 'payplug_sylius_payplug_plugin.admin.logout_error');
}

return new RedirectResponse($this->router->generate('sylius_admin_payment_method_update', ['id' => $id]));
}

/**
* SessionInterface makes no promise about flashes — only FlashBagAwareSessionInterface does —
* and the parent's addFlash() would reach for the container instead of the request in hand.
* A non-flash-aware session means a non-web context, where the message has no one to reach
* anyway, so it is dropped rather than raised.
*/
private function addFlashMessage(Request $request, string $type, string $message): void
{
$session = $request->getSession();

if ($session instanceof FlashBagAwareSessionInterface) {
$session->getFlashBag()->add($type, $message);
}
}

/**
* A null token manager means the application runs with CSRF protection off — the service only
* exists while it is on, hence the optional injection in config/services.yaml. The check is
* then skipped rather than failed, matching Sylius's own admin actions that carry a token in
* the query string and guard it with sylius_csrf_protection_enabled().
*/
private function denyUnlessCsrfTokenIsValid(Request $request, int $id): void
{
if (null === $this->csrfTokenManager) {
return;
}

$token = new CsrfToken(self::CSRF_TOKEN_ID_PREFIX . $id, $request->query->getString('_csrf_token'));

if (!$this->csrfTokenManager->isTokenValid($token)) {
throw new BadRequestHttpException('Invalid CSRF token for the PayPlug logout request.');
}
}

/**
* 404 rather than a flash for both misses: the only legitimate source of this link is the
* connected-account block on a PayPlug gateway's own update screen, so an id that names
* nothing — or names another provider's payment method, which logout must never disable —
* is a tampered or stale URL, not a merchant mistake worth explaining.
*/
private function findConnectedPayPlugPaymentMethod(int $id): PaymentMethodInterface
{
$paymentMethod = $this->paymentMethodRepository->find($id);

if (
!$paymentMethod instanceof PaymentMethodInterface ||
!str_contains((string) $paymentMethod->getGatewayConfig()?->getFactoryName(), 'payplug')
) {
throw new NotFoundHttpException(sprintf('No PayPlug payment method found with id %d.', $id));
}

return $paymentMethod;
}
}
12 changes: 12 additions & 0 deletions src/ApiClient/PayPlugApiClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ public function __construct(
) {
}

/**
* Channel-ambiguous: since PRE-3628 several enabled gateway configs may share a factory name —
* one per channel — and findOneBy() then returns an arbitrary one of them, so the client this
* returns may carry another channel's account credentials.
*
* @internal Kept off {@see PayPlugApiClientFactoryInterface} so no application class can reach
* it; the sole remaining callers are the `payplug_sylius_payplug_plugin.api_client.*`
* service-factory definitions in config/services/client.xml, which are #[Autowire]d
* into seven services that have no payment method in scope. Use
* {@see self::createForPaymentMethod()} everywhere else. Removed once those
* singletons are made channel-aware — the open half of PRE-3682.
*/
public function create(string $factoryName): PayPlugApiClientInterface
{
/** @var GatewayConfigInterface|null $gatewayConfig */
Expand Down
9 changes: 7 additions & 2 deletions src/ApiClient/PayPlugApiClientFactoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@

interface PayPlugApiClientFactoryInterface
{
public function create(string $factoryName): PayPlugApiClientInterface;

/**
* The only way to obtain a client from application code. Resolving one by factory name is
* deliberately absent: since PRE-3628 several enabled gateway configs may share a factory name
* — one per channel — so a name-based lookup returns an arbitrary one of them and can sign a
* request for channel A with channel B's account credentials. Keeping that signature off this
* interface makes the compiler, rather than review, the guard against reintroducing it.
*/
public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): PayPlugApiClientInterface;
}
Loading
Loading