Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PPSYL-111 - Trigger capture auto from a command #149

Merged
merged 4 commits into from
Dec 10, 2024
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ This library is under the MIT license.

For better Oney integration, you can check the [Oney enhancement documentation](doc/oney_enhancement.md).

## Authorized Payment

Since 1.10.0, the plugin supports the authorized payment feature. You can check the [Authorized Payment documentation](doc/authorized_payment.md).

## Doc
- [Development](doc/development.md)
- [Release Process](RELEASE.md)
97 changes: 97 additions & 0 deletions doc/authorized_payment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Authorized Payment

This feature allow merchant to deferred the capture of the payment.
The payment is authorized and the capture can be done later.

> [!IMPORTANT]
> The authorized payment feature is only available for the "PayPlug" payment gateway.

## Activation

On the payment method configuration, you can enable the deferred catpure feature.

![admin_deferred_capture_feature.png](images/admin_deferred_capture_feature.png)

## Trigger the capture

### Periodically

An authorized payment is valid for 7 days.
You can trigger the capture of the authorized payment by running the following command:

```bash
$ bin/console payplug:capture-authorized-payments --days=6
```

It will capture all authorized payments that are older than 6 days.

> [!TIP]
> You can add this command to a cron job to automate the capture of the authorized payments.

### Programmatically

An authorized payment is in state `AUTHORIZED`.
A capture trigger is placed on the complete transition for such payments.

```yaml
winzou_state_machine:
sylius_payment:
callbacks:
before:
payplug_sylius_payplug_plugin_complete:
on: ["complete"]
do: ["@payplug_sylius_payplug_plugin.payment_processing.capture", "process"]
args: ["object"]
```
> [!NOTE]
> This configuration is already added by the plugin.

For example, if you want to trigger the capture when an order is shipped, you can create a callback on the `sylius_order_shipping` state machine.

```yaml
winzou_state_machine:
sylius_order_shipping:
callbacks:
before:
app_ensure_capture_payment:
on: ["ship"]
do: ["@App\StateMachine\CaptureOrderProcessor", "process"]
args: ["object"]
```

```php
<?php

declare(strict_types=1);

namespace App\StateMachine;

use SM\Factory\Factory;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Payment\PaymentTransitions;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(public: true)] // make the service public to be callable by winzou_state_machine
class CaptureOrderProcessor
{
public function __construct(private Factory $stateMachineFactory) {}

public function process(OrderInterface $order): void
{
$payment = $order->getLastPayment(PaymentInterface::STATE_AUTHORIZED);
if (null === $payment) {
// No payment in authorized state, nothing to do here
return;
}

$this->stateMachineFactory
->get($payment, PaymentTransitions::GRAPH)
->apply(PaymentTransitions::TRANSITION_COMPLETE);

if (PaymentInterface::STATE_COMPLETED !== $payment->getState()) {
throw new \LogicException('Oh no! Payment capture failed 💸');
}
}
}
```
Binary file added doc/images/admin_deferred_capture_feature.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions rulesets/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@ parameters:
count: 1
path: ../src/Handler/PaymentNotificationHandler.php

-
message: "#^Parameter \\#1 \\$timestamp of method DateTimeImmutable\\:\\:setTimestamp\\(\\) expects int, mixed given\\.$#"
count: 1
path: ../src/Resolver/PaymentStateResolver.php

-
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
count: 2
Expand Down
86 changes: 86 additions & 0 deletions src/Command/CaptureAuthorizedPaymentCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Command;

use Doctrine\ORM\EntityManagerInterface;
use PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface;
use Psr\Log\LoggerInterface;
use SM\Factory\Factory;
use Sylius\Component\Payment\PaymentTransitions;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class CaptureAuthorizedPaymentCommand extends Command
{
private Factory $stateMachineFactory;
private PaymentRepositoryInterface $paymentRepository;
private EntityManagerInterface $entityManager;
private LoggerInterface $logger;

public function __construct(
Factory $stateMachineFactory,
PaymentRepositoryInterface $paymentRepository,
EntityManagerInterface $entityManager,
LoggerInterface $logger,
) {
$this->stateMachineFactory = $stateMachineFactory;
$this->paymentRepository = $paymentRepository;
$this->entityManager = $entityManager;
$this->logger = $logger;

parent::__construct();
}

protected function configure(): void
{
$this->setName('payplug:capture-authorized-payments')
->setDescription('Capture payplug authorized payments older than X days (default 6)')
->addOption('days', 'd', InputOption::VALUE_OPTIONAL, 'Number of days to wait before capturing authorized payments', 6)
;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$days = \filter_var($input->getOption('days'), FILTER_VALIDATE_INT);
if (false === $days) {
throw new \InvalidArgumentException('Invalid number of days provided.');
}

$payments = $this->paymentRepository->findAllAuthorizedOlderThanDays($days);

if (\count($payments) === 0) {
$this->logger->debug('[Payplug] No authorized payments found.');
}

foreach ($payments as $i => $payment) {
$stateMachine = $this->stateMachineFactory->get($payment, PaymentTransitions::GRAPH);
$this->logger->info('[Payplug] Capturing payment {paymentId} (order #{orderNumber})', [
'paymentId' => $payment->getId(),
'orderNumber' => $payment->getOrder()?->getNumber() ?? 'N/A',
]);
$output->writeln(sprintf('Capturing payment %d (order #%s)', $payment->getId(), $payment->getOrder()?->getNumber() ?? 'N/A'));

try {
$stateMachine->apply(PaymentTransitions::TRANSITION_COMPLETE);
} catch (\Throwable $e) {
$this->logger->critical('[Payplug] Error while capturing payment {paymentId}', [
'paymentId' => $payment->getId(),
'exception' => $e->getMessage(),
]);
continue;
}

if ($i % 10 === 0) {
$this->entityManager->flush();
}
}

$this->entityManager->flush();

return Command::SUCCESS;
}
}
25 changes: 25 additions & 0 deletions src/Repository/PaymentRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace PayPlug\SyliusPayPlugPlugin\Repository;

use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use Sylius\Bundle\CoreBundle\Doctrine\ORM\PaymentRepository as BasePaymentRepository;
use Sylius\Component\Core\Model\PaymentInterface;

Expand Down Expand Up @@ -34,4 +35,28 @@ public function findOneByPayPlugPaymentId(string $payplugPaymentId): PaymentInte
->getSingleResult()
;
}

public function findAllAuthorizedOlderThanDays(int $days, ?string $gatewayFactoryName = null): array
{
if (null === $gatewayFactoryName) {
// For now, only this gateway support authorized payments
$gatewayFactoryName = PayPlugGatewayFactory::FACTORY_NAME;
}

$date = (new \DateTime())->modify(sprintf('-%d days', $days));

/** @var array<PaymentInterface> */
return $this->createQueryBuilder('o')
->innerJoin('o.method', 'method')
->innerJoin('method.gatewayConfig', 'gatewayConfig')
->where('o.state = :state')
->andWhere('o.updatedAt < :date')
->andWhere('gatewayConfig.factoryName = :factoryName')
->setParameter('state', PaymentInterface::STATE_AUTHORIZED)
->setParameter('factoryName', $gatewayFactoryName)
->setParameter('date', $date)
->getQuery()
->getResult()
;
}
}
5 changes: 5 additions & 0 deletions src/Repository/PaymentRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ interface PaymentRepositoryInterface extends BasePaymentRepositoryInterface
public function findAllActiveByGatewayFactoryName(string $gatewayFactoryName): array;

public function findOneByPayPlugPaymentId(string $payplugPaymentId): PaymentInterface;

/**
* @return array<PaymentInterface>
*/
public function findAllAuthorizedOlderThanDays(int $days, ?string $gatewayFactoryName = null): array;
}
19 changes: 19 additions & 0 deletions src/Resolver/PaymentStateResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace PayPlug\SyliusPayPlugPlugin\Resolver;

use Doctrine\ORM\EntityManagerInterface;
use Payplug\Resource\Payment;
use Payplug\Resource\PaymentAuthorization;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use Payum\Core\Model\GatewayConfigInterface;
Expand Down Expand Up @@ -67,6 +69,10 @@ public function resolve(PaymentInterface $payment): void
case null !== $payment->failure:
$this->applyTransition($paymentStateMachine, PaymentTransitions::TRANSITION_FAIL);

break;
case $this->isAuthorized($payment):
$this->applyTransition($paymentStateMachine, PaymentTransitions::TRANSITION_AUTHORIZE);

break;
default:
$this->applyTransition($paymentStateMachine, PaymentTransitions::TRANSITION_PROCESS);
Expand All @@ -81,4 +87,17 @@ private function applyTransition(StateMachineInterface $paymentStateMachine, str
$paymentStateMachine->apply($transition);
}
}

private function isAuthorized(Payment $payment): bool
{
$now = new \DateTimeImmutable();
if ($payment->__isset('authorization') &&
$payment->__get('authorization') instanceof PaymentAuthorization &&
null !== $payment->__get('authorization')->__get('expires_at') &&
$now < $now->setTimestamp($payment->__get('authorization')->__get('expires_at'))) {
return true;
}

return false;
}
}
5 changes: 5 additions & 0 deletions src/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@
<tag name="console.command" />
</service>

<service id="PayPlug\SyliusPayPlugPlugin\Command\CaptureAuthorizedPaymentCommand" class="PayPlug\SyliusPayPlugPlugin\Command\CaptureAuthorizedPaymentCommand">
<argument key="$paymentRepository" type="service" id="payplug_sylius_payplug_plugin.repository.payment" />
<tag name="console.command" />
</service>

<service id="PayPlug\SyliusPayPlugPlugin\Controller\OneySimulationPopin" class="PayPlug\SyliusPayPlugPlugin\Controller\OneySimulationPopin">
<argument type="service" id="sylius.repository.product_variant" key="$productVariantRepository" />
</service>
Expand Down
Loading