Skip to content

Commit

Permalink
Merge pull request #263 from synolia/feature/authorized-payment
Browse files Browse the repository at this point in the history
Authorized Payment
  • Loading branch information
dmurillo-payplug authored Feb 4, 2025
2 parents 38560fa + 81f9755 commit 7d225bf
Show file tree
Hide file tree
Showing 31 changed files with 602 additions and 34 deletions.
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

0 comments on commit 7d225bf

Please sign in to comment.