Skip to content
Merged
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 @@ -85,7 +85,7 @@
"ecs": "ecs check -c ruleset/ecs.php --ansi --clear-cache",
"fix-ecs": "@ecs --fix --memory-limit=4G",
"phpmd": "phpmd src ansi ruleset/.php_md.xml",
"phpstan": "phpstan analyse src -c ruleset/phpstan.neon",
"phpstan": "phpstan analyse src -c ruleset/phpstan.neon --memory-limit=4G",
"phpunit": "phpunit tests/PHPUnit --colors=always",
"test-coverage": "phpunit tests/PHPUnit --colors=always --coverage-clover=build/logs/clover.xml",
"tests": [
Expand Down
3 changes: 3 additions & 0 deletions config/twig_hooks/admin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ sylius_twig_hooks:

'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_scalapay': &scalapayGateway
live_checkbox: *liveCheckbox
amount_range:
template: '@PayPlugSyliusPayPlugPlugin/admin/payment_method/form/scalapay_amount_range.html.twig'
priority: 0

'sylius_admin.payment_method.create.content.form.sections.gateway_configuration.payplug_wero': &weroGateway
live_checkbox: *liveCheckbox
Expand Down
12 changes: 0 additions & 12 deletions ruleset/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1252,18 +1252,6 @@ parameters:
count: 1
path: ../src/Provider/PaymentTokenProvider.php

-
message: '#^Cannot access offset ''max_amount'' on mixed\.$#'
identifier: offsetAccess.nonOffsetAccessible
count: 1
path: ../src/Provider/SupportedMethodsProvider.php

-
message: '#^Cannot access offset ''min_amount'' on mixed\.$#'
identifier: offsetAccess.nonOffsetAccessible
count: 1
path: ../src/Provider/SupportedMethodsProvider.php

-
message: '#^PHPDoc tag @var with type Payum\\Core\\Model\\GatewayConfigInterface is not subtype of native type Sylius\\Component\\Payment\\Model\\GatewayConfigInterface\|null\.$#'
identifier: varTag.nativeType
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Gateway\Form\Extension;

use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\AbstractGatewayConfigurationType;
use PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type\ScalapayGatewayConfigurationType;
use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory;
use Sylius\Bundle\MoneyBundle\Form\Type\MoneyType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormBuilderInterface;

final class ScalapayGatewayConfigurationTypeExtension extends AbstractTypeExtension
{
/**
* @inheritdoc
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add(ScalapayGatewayFactory::MIN_AMOUNT, MoneyType::class, [
'label' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.min_amount',
'help' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.amount_help',
'currency' => 'EUR',
'required' => false,
'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS,
])
->add(ScalapayGatewayFactory::MAX_AMOUNT, MoneyType::class, [
'label' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.max_amount',
'help' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.amount_help',
'currency' => 'EUR',
'required' => false,
'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS,
])
;
}

public static function getExtendedTypes(): iterable
{
return [ScalapayGatewayConfigurationType::class];
}
}
4 changes: 4 additions & 0 deletions src/Gateway/ScalapayGatewayFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ final class ScalapayGatewayFactory extends AbstractGatewayFactory
public const FACTORY_TITLE = 'Scalapay by PayPlug';

public const PAYMENT_METHOD_SCALAPAY = 'scalapay';

public const MIN_AMOUNT = 'min_amount';

public const MAX_AMOUNT = 'max_amount';
}
22 changes: 22 additions & 0 deletions src/Gateway/Validator/Constraints/IsScalapayAmountRangeValid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

/**
* @Annotation
*/
final class IsScalapayAmountRangeValid extends Constraint
{
public string $minGreaterThanMaxMessage = 'payplug_sylius_payplug_plugin.payplug_scalapay.min_amount_greater_than_max';

public string $outOfRangeMessage = 'payplug_sylius_payplug_plugin.payplug_scalapay.amount_out_of_authorized_range';

public function validatedBy(): string
{
return IsScalapayAmountRangeValidValidator::class;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints;

use Payplug\Exception\PayplugException;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException;
use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAmountRangeResolver;
use Psr\Log\LoggerInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Payment\Model\GatewayConfigInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Webmozart\Assert\Assert;
use Webmozart\Assert\InvalidArgumentException;

final class IsScalapayAmountRangeValidValidator extends ConstraintValidator
{
public function __construct(
private PayPlugApiClientFactoryInterface $apiClientFactory,
private AccountAmountRangeResolver $amountRangeResolver,
private LoggerInterface $logger,
) {
}

public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof IsScalapayAmountRangeValid) {
throw new UnexpectedTypeException($constraint, IsScalapayAmountRangeValid::class);
}

if (!$value instanceof PaymentMethodInterface) {
return;
}

$configuredAmounts = $this->resolveApplicableConfiguredAmounts($value, $constraint);
if (null === $configuredAmounts) {
return;
}

$authorizedRange = $this->resolveAuthorizedRange($value);
if (null === $authorizedRange) {
return;
}

$this->applyRangeViolations($configuredAmounts, $authorizedRange, $constraint);
}

/**
* Resolves the merchant-configured amounts, applying the early guards that don't need a
* live API call: the method must be enabled, amounts must be configured, and — when both
* sides are explicitly set — locally consistent.
*
* @return array{0: int|null, 1: int|null}|null
*/
private function resolveApplicableConfiguredAmounts(
PaymentMethodInterface $paymentMethod,
IsScalapayAmountRangeValid $constraint,
): ?array {
$configuredAmounts = false !== $paymentMethod->isEnabled() ? $this->resolveConfiguredAmounts($paymentMethod) : null;
if (null === $configuredAmounts) {
return null;
}

[$minAmount, $maxAmount] = $configuredAmounts;

if (\is_int($minAmount) && \is_int($maxAmount) && $minAmount > $maxAmount) {
Comment thread
adumont-payplug marked this conversation as resolved.
$this->context->buildViolation($constraint->minGreaterThanMaxMessage)->addViolation();

return null;
}

return $configuredAmounts;
}

/**
* @param array{0: int|null, 1: int|null} $configuredAmounts
* @param array{min_amount: int, max_amount: int} $authorizedRange
*/
private function applyRangeViolations(
array $configuredAmounts,
array $authorizedRange,
IsScalapayAmountRangeValid $constraint,
): void {
[$minAmount, $maxAmount] = $configuredAmounts;

// A merchant may configure only one side of the range; the other falls back to the
// API bound at checkout (see SupportedMethodsProvider), so the min>max check must
// compare against that same effective range, not just the explicitly configured side.
$effectiveMinAmount = $minAmount ?? $authorizedRange['min_amount'];
$effectiveMaxAmount = $maxAmount ?? $authorizedRange['max_amount'];

if ($effectiveMinAmount > $effectiveMaxAmount) {
$this->context->buildViolation($constraint->minGreaterThanMaxMessage)->addViolation();

return;
}

if (
(\is_int($minAmount) && $minAmount < $authorizedRange['min_amount']) ||
(\is_int($maxAmount) && $maxAmount > $authorizedRange['max_amount'])
) {
$this->context->buildViolation($constraint->outOfRangeMessage)
->setParameter('%min_amount%', self::formatAmount($authorizedRange['min_amount']))
->setParameter('%max_amount%', self::formatAmount($authorizedRange['max_amount']))
->addViolation()
;
}
}

/**
* The bounds are EUR cents (the form field is hardcoded to EUR), rendered with two decimals so
* 500 reads as "5.00" rather than "5".
*/
private static function formatAmount(int $amountInCents): string
{
return number_format($amountInCents / 100, 2, '.', '');
}

/**
* @return array{0: int|null, 1: int|null}|null
*/
private function resolveConfiguredAmounts(PaymentMethodInterface $paymentMethod): ?array
{
$gatewayConfig = $paymentMethod->getGatewayConfig();

if (!$gatewayConfig instanceof GatewayConfigInterface || ScalapayGatewayFactory::FACTORY_NAME !== $gatewayConfig->getFactoryName()) {
return null;
}

[$minAmount, $maxAmount] = $this->readConfiguredAmounts($gatewayConfig->getConfig());

return null === $minAmount && null === $maxAmount ? null : [$minAmount, $maxAmount];
}

/**
* The admin form only ever writes null or an int, but the gateway config is a plain serialized
* array that a direct DB edit, an import script or an admin API write can leave anything in.
* PaymentMethodValidator::process() has no surrounding try/catch, so a malformed value
* degrades to "not configured" — leaving the API bounds in force at checkout — rather than
* throwing an assertion error that would 500 the admin save.
*
* @param array<array-key, mixed> $config
*
* @return array{0: int|null, 1: int|null}
*/
private function readConfiguredAmounts(array $config): array
{
$minAmount = $config[ScalapayGatewayFactory::MIN_AMOUNT] ?? null;
$maxAmount = $config[ScalapayGatewayFactory::MAX_AMOUNT] ?? null;

try {
Assert::nullOrInteger($minAmount);
Assert::nullOrInteger($maxAmount);
} catch (InvalidArgumentException $exception) {
$this->logger->warning('Skipping Scalapay amount range validation: the stored range is malformed.', [
'min_amount' => $minAmount,
'max_amount' => $maxAmount,
'exception' => $exception->getMessage(),
]);

return [null, null];
}

return [$minAmount, $maxAmount];
}

/**
* Fails open: when the authorized range can't be established the config saves unvalidated,
* matching the plugin's convention of never blocking an admin save on an API hiccup. That is
* not free — a one-sided range that inverts against the live API bounds slips through and
* silently hides Scalapay at checkout — so the skip is logged rather than swallowed.
*
* @return array{min_amount: int, max_amount: int}|null
*/
private function resolveAuthorizedRange(PaymentMethodInterface $paymentMethod): ?array
{
try {
$authorizedRange = $this->resolveApiAuthorizedRange($paymentMethod);
} catch (GatewayConfigurationException | PayplugException | InvalidArgumentException $exception) {
$this->logger->warning('Skipping Scalapay amount range validation: the PayPlug account could not be read.', [
'payment_method' => $paymentMethod->getCode(),
'exception' => $exception->getMessage(),
]);

return null;
}

if (null === $authorizedRange) {
$this->logger->warning('Skipping Scalapay amount range validation: the PayPlug account authorizes no EUR range for Scalapay.', [
'payment_method' => $paymentMethod->getCode(),
]);
}

return $authorizedRange;
}

/**
* @return array{min_amount: int, max_amount: int}|null
*/
private function resolveApiAuthorizedRange(PaymentMethodInterface $paymentMethod): ?array
Comment thread
adumont-payplug marked this conversation as resolved.
{
$account = $this->apiClientFactory->createForPaymentMethod($paymentMethod)->getAccount();
$currencies = $this->amountRangeResolver->resolve($account, ScalapayGatewayFactory::PAYMENT_METHOD_SCALAPAY);

return $currencies['EUR'] ?? null;
}
}
30 changes: 21 additions & 9 deletions src/PaymentProcessing/PaymentTransitionApplier.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@ public function apply(PaymentInterface $payment): bool
$status = $details['status'] ?? '';

// These are known PayPlug statuses that do not map to a Sylius payment transition.
if (\in_array($status, [
PayPlugApiClientInterface::STATUS_CREATED,
PayPlugApiClientInterface::REFUNDED,
PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK,
], true)) {
if (
\in_array(
$status,
[
PayPlugApiClientInterface::STATUS_CREATED,
PayPlugApiClientInterface::REFUNDED,
PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK,
],
true,
)
) {
return false;
}

Expand All @@ -41,23 +47,29 @@ public function apply(PaymentInterface $payment): bool
};

if (null === $transition) {
$this->logger->warning('[PayPlug] Cannot apply payment transition: unknown status.', [
$this->logger->warning(
'[PayPlug] Cannot apply payment transition: unknown status.',
[
'sylius_payment_id' => $payment->getId(),
'payplug_payment_id' => $details['payment_id'] ?? null,
'status' => $status,
]);
],
);

return false;
}

if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) {
$this->logger->warning('[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', [
$this->logger->warning(
'[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).',
[
'sylius_payment_id' => $payment->getId(),
'payplug_payment_id' => $details['payment_id'] ?? null,
'current_state' => $payment->getState(),
'transition' => $transition,
'status' => $status,
]);
],
);

return false;
}
Expand Down
Loading
Loading