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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,12 @@ but does not block the transition. Operations execute with the Sylius payment th
gateway-updated details persist; an unqualified `Refund` refunds Quickpay's remaining `balance` and the balance is
persisted into the details by the library's Status/Confirm/Sync actions. Each operation can be turned off via the plugin config
`operations.capture` / `operations.refund` / `operations.cancel` (defined in `DependencyInjection/Configuration.php`,
passed to the processor as container parameters).
passed to the processor as container parameters). An opt-in `fraud.block_capture` flag makes the processor consult
`Fraud/FraudChecker` (fetches the payment via `Quickpay/ClientFactory`, reads `metadata.fraud_suspected`, fails
open on any error) before the automatic capture and skip it with a warning when fraud is suspected — the
transition itself proceeds. The operation-history admin panel shows a fraud badge from the same metadata, and the
reconcile command has a `--fraud-suspected` report mode that queries each configured gateway's account directly
(`PaymentsQuery`), transitioning nothing.

### Payment link (admin)
`PaymentLink/PaymentLinkProvider` returns Sylius' `sylius_shop_order_pay` url (built for the order's channel
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ setono_sylius_quickpay:
capture: true # forward the payment's complete transition to Quickpay as a capture
refund: true # forward the refund transition to Quickpay
cancel: true # forward the cancel transition to Quickpay
fraud:
block_capture: false # skip the automatic capture when Quickpay reports the payment as fraud suspected (see Fraud signals)
```

The plugin hooks into **both state machine adapters** supported by Sylius 1.14 — a `winzou_state_machine`
Expand Down Expand Up @@ -204,6 +206,24 @@ captured balance and a test-mode badge. The data is fetched from Quickpay *after
so the order page is never delayed by a slow gateway; if Quickpay cannot be reached, the panel shows an
inline notice with a retry link. Nothing is stored — the panel reflects what Quickpay reports right now.

## Fraud signals

Quickpay flags payments it suspects of fraud, and the plugin surfaces that in three places:

- A **Fraud suspected** badge on the admin operation history panel, next to the test-mode badge.
- An **opt-in capture guard**: with `fraud.block_capture: true` (see [Configure the plugin](#3-configure-the-plugin-optional)),
the automatic capture on the payment's `complete` transition is skipped when Quickpay reports the payment as
fraud suspected — the completion itself is not blocked; the payment is logged and left for manual review, so
you capture or cancel it in the Quickpay manager after looking at it. The check asks Quickpay at capture time
and fails open: an unreachable Quickpay never blocks the payment flow. It costs one extra API call per
automatic capture, which is why it is off by default.
- A **report mode** on the reconciliation command that asks Quickpay for every payment flagged in the period,
regardless of its local state — report only, nothing is transitioned:

```bash
bin/console setono:sylius-quickpay:reconcile-payments --fraud-suspected --since="7 days"
```

## Callbacks

All callbacks for what your store does arrive on a **per-payment url** the gateway mints and registers itself —
Expand Down
4 changes: 4 additions & 0 deletions UPGRADE-2.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ if you make those and want the store to notice. The README's *Callbacks* section
- **New: a reconciliation command.** `setono:sylius-quickpay:reconcile-payments` polls Quickpay for
payments stuck in a non-final state — e.g. because a callback never arrived — and applies the
matching payment transition. See the README for options and a suggested cron cadence.
- **New: Quickpay's fraud signals are surfaced.** The admin operation history shows a *Fraud suspected*
badge, the reconciliation command gains a `--fraud-suspected` report mode, and an opt-in
`fraud.block_capture` config flag skips the automatic capture on completion for fraud suspected
payments, leaving them for manual review.
- **New: the API key is verified at form-save time.** Saving a Quickpay payment method pings the
Quickpay API with the submitted key and rejects the form when Quickpay rejects it; an unreachable
Quickpay skips the check, so an outage never blocks saving.
Expand Down
70 changes: 70 additions & 0 deletions src/Command/ReconcilePaymentsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@
use Payum\Core\Payum;
use Payum\Core\Request\GetHumanStatus;
use Setono\Doctrine\ORMTrait;
use Setono\Payum\Quickpay\QuickpayGatewayFactory;
use Setono\Quickpay\Exception\QuickpayException;
use Setono\Quickpay\Request\Payment\PaymentsQuery;
use Setono\SyliusQuickpayPlugin\Provider\PendingPaymentProviderInterface;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyResolver;
use Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Payment\PaymentTransitions;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
Expand All @@ -36,10 +41,15 @@ final class ReconcilePaymentsCommand extends Command
{
use ORMTrait;

/**
* @param RepositoryInterface<GatewayConfigInterface> $gatewayConfigRepository
*/
public function __construct(
private readonly PendingPaymentProviderInterface $pendingPaymentProvider,
private readonly Payum $payum,
private readonly StateMachineInterface $stateMachine,
private readonly ClientFactoryInterface $clientFactory,
private readonly RepositoryInterface $gatewayConfigRepository,
ManagerRegistry $managerRegistry,
) {
parent::__construct();
Expand All @@ -53,6 +63,7 @@ protected function configure(): void
->addOption('since', null, InputOption::VALUE_REQUIRED, 'Only reconcile payments created within this period', '7 days')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of payments to check', '100')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would happen without applying any transition')
->addOption('fraud-suspected', null, InputOption::VALUE_NONE, 'Report the payments Quickpay flags as fraud suspected instead of reconciling; no transition is applied')
;
}

Expand All @@ -74,6 +85,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$limit = (int) $input->getOption('limit');
$dryRun = (bool) $input->getOption('dry-run');

if (true === $input->getOption('fraud-suspected')) {
return $this->reportFraudSuspected($io, $createdSince, $limit);
}

$checked = $transitioned = $unchanged = $errored = 0;

foreach ($this->pendingPaymentProvider->findPending($createdSince, $limit) as $payment) {
Expand Down Expand Up @@ -119,6 +134,61 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return $errored > 0 ? Command::FAILURE : Command::SUCCESS;
}

/**
* Unlike reconciliation, which walks the local pending payments, this asks Quickpay directly:
* every configured Quickpay gateway is queried for payments flagged fraud suspected within the
* period, regardless of their local state. Report only — nothing is transitioned or persisted.
*/
private function reportFraudSuspected(SymfonyStyle $io, \DateTimeImmutable $createdSince, int $limit): int
{
$rows = [];
$errored = false;

/** @var GatewayConfigInterface $gatewayConfig */
foreach ($this->gatewayConfigRepository->findBy(['factoryName' => QuickpayGatewayFactory::NAME]) as $gatewayConfig) {
$apiKey = ApiKeyResolver::fromGatewayConfig($gatewayConfig->getConfig());
if (null === $apiKey) {
continue;
}

try {
$payments = $this->clientFactory->create($apiKey)->payments()->paginate(new PaymentsQuery(
minTime: $createdSince,
fraudSuspected: true,
sortBy: 'created_at',
sortDir: 'desc',
));

foreach ($payments as $payment) {
$rows[] = [
(string) $gatewayConfig->getGatewayName(),
(string) $payment->id,
$payment->orderId,
$payment->state,
$payment->createdAt?->format('Y-m-d H:i:s') ?? '',
$payment->testMode ? 'yes' : 'no',
];

if (\count($rows) >= $limit) {
break 2;
}
}
} catch (QuickpayException $e) {
$errored = true;
$io->warning(sprintf('Gateway "%s": %s', (string) $gatewayConfig->getGatewayName(), $e->getMessage()));
}
}

if ([] === $rows) {
$io->success('Quickpay reports no fraud suspected payments in the period.');
} else {
$io->table(['Gateway', 'Quickpay id', 'Order id', 'State', 'Created', 'Test mode'], $rows);
$io->warning(sprintf('%d payment(s) flagged as fraud suspected. Review them in the Quickpay manager before capturing.', \count($rows)));
}

return $errored ? Command::FAILURE : Command::SUCCESS;
}

private static function resolveGatewayName(PaymentInterface $payment): string
{
/** @var PaymentMethodInterface $method */
Expand Down
29 changes: 7 additions & 22 deletions src/Controller/Admin/PaymentOperationsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@

namespace Setono\SyliusQuickpayPlugin\Controller\Admin;

use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyResolver;
use Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface;
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Core\Repository\PaymentRepositoryInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
Expand Down Expand Up @@ -42,9 +41,14 @@ public function __invoke(int $id): Response
throw new NotFoundHttpException(sprintf('Payment %d has no Quickpay payment id', $id));
}

$apiKey = ApiKeyResolver::fromPayment($payment);
if (null === $apiKey) {
throw new NotFoundHttpException('The payment\'s gateway carries no api key');
}

try {
$quickpayPayment = $this->clientFactory
->create(self::resolveApiKey($payment))
->create($apiKey)
->payments()
->getById((int) $quickpayPaymentId)
;
Expand All @@ -59,23 +63,4 @@ public function __invoke(int $id): Response
'quickpay_payment' => $quickpayPayment,
]));
}

private static function resolveApiKey(PaymentInterface $payment): string
{
/** @var PaymentMethodInterface $method */
$method = $payment->getMethod();

/** @var GatewayConfigInterface $gatewayConfig */
$gatewayConfig = $method->getGatewayConfig();

$config = $gatewayConfig->getConfig();

// Configurations written by the 1.x form may still carry the old key
$apiKey = $config['api_key'] ?? $config['apikey'] ?? null;
if (!is_string($apiKey) || '' === $apiKey) {
throw new NotFoundHttpException('The payment\'s gateway carries no api key');
}

return $apiKey;
}
}
10 changes: 10 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()
->end()
->arrayNode('fraud')
->info("Reacting to Quickpay's fraud signals")
->addDefaultsIfNotSet()
->children()
->booleanNode('block_capture')
->info('Skip the automatic capture on the complete transition when Quickpay reports the payment as fraud suspected, leaving it for manual review in the Quickpay manager (the transition itself is not blocked). Costs one extra Quickpay API call per automatic capture')
->defaultFalse()
->end()
->end()
->end()
->arrayNode('checkout')
->info('Presentation of Quickpay payment methods on the checkout payment step')
->addDefaultsIfNotSet()
Expand Down
3 changes: 2 additions & 1 deletion src/DependencyInjection/SetonoSyliusQuickpayExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ final class SetonoSyliusQuickpayExtension extends Extension implements PrependEx
{
public function load(array $configs, ContainerBuilder $container): void
{
/** @var array{operations: array{capture: bool, refund: bool, cancel: bool}, checkout: array{payment_method_logos: array<string, string|null>, creditcard_brands: list<string>}} $config */
/** @var array{operations: array{capture: bool, refund: bool, cancel: bool}, fraud: array{block_capture: bool}, checkout: array{payment_method_logos: array<string, string|null>, creditcard_brands: list<string>}} $config */
$config = $this->processConfiguration($this->getConfiguration([], $container), $configs);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));

$container->setParameter('setono_sylius_quickpay.operations.capture', $config['operations']['capture']);
$container->setParameter('setono_sylius_quickpay.operations.refund', $config['operations']['refund']);
$container->setParameter('setono_sylius_quickpay.operations.cancel', $config['operations']['cancel']);
$container->setParameter('setono_sylius_quickpay.fraud.block_capture', $config['fraud']['block_capture']);
$container->setParameter('setono_sylius_quickpay.checkout.payment_method_logos', $config['checkout']['payment_method_logos']);
$container->setParameter('setono_sylius_quickpay.checkout.creditcard_brands', $config['checkout']['creditcard_brands']);

Expand Down
51 changes: 51 additions & 0 deletions src/Fraud/FraudChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Fraud;

use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Setono\Payum\Quickpay\Details;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyResolver;
use Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface;
use Sylius\Component\Core\Model\PaymentInterface;

final class FraudChecker implements FraudCheckerInterface, LoggerAwareInterface
{
use LoggerAwareTrait;

public function __construct(private readonly ClientFactoryInterface $clientFactory)
{
}

public function isFraudSuspected(PaymentInterface $payment): bool
{
$details = new \ArrayObject($payment->getDetails());
if (!Details::hasPaymentId($details)) {
return false;
}

$apiKey = ApiKeyResolver::fromPayment($payment);
if (null === $apiKey) {
return false;
}

try {
// Details::paymentId() throws for a present but unusable id, which lands in the same
// fail-open catch as an unreachable Quickpay: the question cannot be answered
$quickpayPaymentId = Details::paymentId($details);

$quickpayPayment = $this->clientFactory->create($apiKey)->payments()->getById($quickpayPaymentId);
} catch (\Throwable $e) {
$this->logger?->warning(sprintf('Could not check the Quickpay payment for suspected fraud: %s', $e->getMessage()), [
'quickpayPaymentId' => $details['quickpayPaymentId'] ?? null,
'paymentId' => $payment->getId(),
]);

return false;
}

return true === $quickpayPayment->metadata?->fraudSuspected;
}
}
17 changes: 17 additions & 0 deletions src/Fraud/FraudCheckerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Fraud;

use Sylius\Component\Core\Model\PaymentInterface;

interface FraudCheckerInterface
{
/**
* Whether Quickpay reports the payment as fraud suspected. Answers false whenever the question
* cannot be answered — no Quickpay payment, no api key, Quickpay unreachable — so a broken check
* never blocks the payment flow.
*/
public function isFraudSuspected(PaymentInterface $payment): bool;
}
40 changes: 40 additions & 0 deletions src/Quickpay/ApiKeyResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Quickpay;

use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;

/**
* Reads the Quickpay api key off a gateway configuration (or the configuration of a payment's own
* method), answering null when no usable key is stored. The one place that knows configurations
* written by the 1.x form may still carry the old `apikey` name.
*/
final class ApiKeyResolver
{
private function __construct()
{
}

/**
* @param array<array-key, mixed> $config
*/
public static function fromGatewayConfig(array $config): ?string
{
$apiKey = $config['api_key'] ?? $config['apikey'] ?? null;

return is_string($apiKey) && '' !== $apiKey ? $apiKey : null;
}

public static function fromPayment(PaymentInterface $payment): ?string
{
$method = $payment->getMethod();
if (!$method instanceof PaymentMethodInterface) {
return null;
}

return self::fromGatewayConfig($method->getGatewayConfig()?->getConfig() ?? []);
}
}
Loading
Loading