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
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.11.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.
14 changes: 14 additions & 0 deletions rulesets/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,11 @@ parameters:
count: 1
path: ../src/Gateway/Validator/Constraints/IsCanSaveCardsValidator.php

-
message: "#^Cannot call method getData\\(\\) on mixed\\.$#"
count: 1
path: ../src/Gateway/Validator/Constraints/PayplugPermissionValidator.php

-
message: "#^Method PayPlug\\\\SyliusPayPlugPlugin\\\\Gateway\\\\Validator\\\\Constraints\\\\IsCanSaveCardsValidator\\:\\:validate\\(\\) has parameter \\$value with no type specified\\.$#"
count: 1
Expand Down Expand Up @@ -330,6 +335,15 @@ 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 \\#1 \\$timestamp of method DateTimeImmutable\\:\\:setTimestamp\\(\\) expects int, mixed given\\.$#"
count: 1
path: ../src/Action/CaptureAction.php

-
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
count: 2
Expand Down
13 changes: 13 additions & 0 deletions src/Action/CaptureAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Payplug\Exception\BadRequestException;
use Payplug\Exception\ForbiddenException;
use Payplug\Resource\Payment;
use Payplug\Resource\PaymentAuthorization;
use PayPlug\SyliusPayPlugPlugin\Action\Api\ApiAwareTrait;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Entity\Card;
Expand Down Expand Up @@ -178,6 +179,14 @@ public function execute($request): void
return;
}

$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;
}

$details['status'] = PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK;
$details['hosted_payment'] = [
'payment_url' => $payment->hosted_payment->payment_url,
Expand Down Expand Up @@ -278,12 +287,16 @@ private function createPayment(ArrayObject $details, PaymentInterface $paymentMo
}
}

$this->logger->debug('[PayPlug] Create payment', [
'detail' => $details->getArrayCopy(),
]);
$payment = $this->payPlugApiClient->createPayment($details->getArrayCopy());
$details['payment_id'] = $payment->id;
$details['is_live'] = $payment->is_live;

$this->logger->debug('[PayPlug] Create payment', [
'payment_id' => $payment->id,
'payment' => (array) $payment,
]);

return $payment;
Expand Down
23 changes: 8 additions & 15 deletions src/Checker/CanSaveCardChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@

class CanSaveCardChecker implements CanSaveCardCheckerInterface
{
/** @var CustomerContextInterface */
private $customerContext;
private CustomerContextInterface $customerContext;
private PayplugFeatureChecker $payplugFeatureChecker;

public function __construct(CustomerContextInterface $customerContext)
{
public function __construct(
CustomerContextInterface $customerContext,
PayplugFeatureChecker $payplugFeatureChecker,
) {
$this->customerContext = $customerContext;
$this->payplugFeatureChecker = $payplugFeatureChecker;
}

public function isAllowed(PaymentMethodInterface $paymentMethod): bool
Expand All @@ -26,16 +29,6 @@ public function isAllowed(PaymentMethodInterface $paymentMethod): bool
return false;
}

$gatewayConfiguration = $paymentMethod->getGatewayConfig();

if (!$gatewayConfiguration instanceof GatewayConfigInterface) {
return false;
}

if (!\array_key_exists(PayPlugGatewayFactory::ONE_CLICK, $gatewayConfiguration->getConfig())) {
return false;
}

return (bool) $gatewayConfiguration->getConfig()[PayPlugGatewayFactory::ONE_CLICK] ?? false;
return $this->payplugFeatureChecker->isOneClickEnabled($paymentMethod);
}
}
42 changes: 42 additions & 0 deletions src/Checker/PayplugFeatureChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Checker;

use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;

class PayplugFeatureChecker
{
public function isDeferredCaptureEnabled(PaymentMethodInterface $paymentMethod): bool
{
return $this->getConfigCheckboxValue($paymentMethod, PayPlugGatewayFactory::DEFERRED_CAPTURE);
}

public function isIntegratedPaymentEnabled(PaymentMethodInterface $paymentMethod): bool
{
return $this->getConfigCheckboxValue($paymentMethod, PayPlugGatewayFactory::INTEGRATED_PAYMENT);
}

public function isOneClickEnabled(PaymentMethodInterface $paymentMethod): bool
{
return $this->getConfigCheckboxValue($paymentMethod, PayPlugGatewayFactory::ONE_CLICK);
}

private function getConfigCheckboxValue(PaymentMethodInterface $paymentMethod, string $configKey): bool
{
$gatewayConfiguration = $paymentMethod->getGatewayConfig();

if (!$gatewayConfiguration instanceof GatewayConfigInterface) {
return false;
}

if (!\array_key_exists($configKey, $gatewayConfiguration->getConfig())) {
return false;
}

return (bool) ($gatewayConfiguration->getConfig()[$configKey] ?? false);
}
}
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;
}
}
35 changes: 35 additions & 0 deletions src/Const/Permission.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace PayPlug\SyliusPayPlugPlugin\Const;

/**
* Permission list that payplug can return
*/
final class Permission
{
public const USE_LIVE_MODE = 'use_live_mode';
public const CAN_SAVE_CARD = 'can_save_cards';
public const CAN_CREATE_DEFERRED_PAYMENT = 'can_create_deferred_payment';
public const CAN_USE_INTEGRATED_PAYMENTS = 'can_use_integrated_payments';
public const CAN_CREATE_INSTALLMENT_PLAN = 'can_create_installment_plan';
public const CAN_USE_ONEY = 'can_use_oney';

public static function getAll(): array
{
return [
self::USE_LIVE_MODE,
self::CAN_SAVE_CARD,
self::CAN_CREATE_DEFERRED_PAYMENT,
self::CAN_USE_INTEGRATED_PAYMENTS,
self::CAN_CREATE_INSTALLMENT_PLAN,
self::CAN_USE_ONEY,
];
}

public static function isPermission(string $permission): bool
{
return in_array($permission, self::getAll(), true);
}
}
Loading
Loading