Skip to content

Commit 69713b7

Browse files
Merge pull request #268 from payplug/qa
Deferred Payment
2 parents 08d687c + e343013 commit 69713b7

34 files changed

Lines changed: 609 additions & 37 deletions

.github/workflows/sylius.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ jobs:
116116
run: 'vendor/bin/behat --strict --no-interaction -f progress || vendor/bin/behat --strict -vvv --no-interaction --rerun'
117117
if: 'always() && steps.end-of-setup-sylius.outcome == ''success'''
118118
-
119-
uses: actions/upload-artifact@v3
119+
uses: actions/upload-artifact@v4
120120
if: failure()
121121
with:
122122
name: logs

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ This library is under the MIT license.
234234

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

237+
## Authorized Payment
238+
239+
Since 1.11.0, the plugin supports the authorized payment feature. You can check the [Authorized Payment documentation](doc/authorized_payment.md).
240+
237241
## Doc
238242
- [Development](doc/development.md)
239243
- [Release Process](RELEASE.md)

doc/authorized_payment.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Authorized Payment
2+
3+
This feature allow merchant to deferred the capture of the payment.
4+
The payment is authorized and the capture can be done later.
5+
6+
> [!IMPORTANT]
7+
> The authorized payment feature is only available for the "PayPlug" payment gateway.
8+
9+
## Activation
10+
11+
On the payment method configuration, you can enable the deferred capture feature.
12+
13+
![admin_deferred_capture_feature.png](images/admin_deferred_capture_feature.png)
14+
15+
## Trigger the capture
16+
17+
### Periodically
18+
19+
An authorized payment is valid for 7 days.
20+
You can trigger the capture of the authorized payment by running the following command:
21+
22+
```bash
23+
$ bin/console payplug:capture-authorized-payments --days=6
24+
```
25+
26+
It will capture all authorized payments that are older than 6 days.
27+
28+
> [!TIP]
29+
> You can add this command to a cron job to automate the capture of the authorized payments.
30+
31+
### Programmatically
32+
33+
An authorized payment is in state `AUTHORIZED`.
34+
A capture trigger is placed on the complete transition for such payments.
35+
36+
```yaml
37+
winzou_state_machine:
38+
sylius_payment:
39+
callbacks:
40+
before:
41+
payplug_sylius_payplug_plugin_complete:
42+
on: ["complete"]
43+
do: ["@payplug_sylius_payplug_plugin.payment_processing.capture", "process"]
44+
args: ["object"]
45+
```
46+
> [!NOTE]
47+
> This configuration is already added by the plugin.
48+
49+
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.
50+
51+
File: `config/packages/winzou_state_machine.yaml`
52+
53+
```yaml
54+
winzou_state_machine:
55+
sylius_order_shipping:
56+
callbacks:
57+
before:
58+
app_ensure_capture_payment:
59+
on: ["ship"]
60+
do: ['@App\StateMachine\CaptureOrderProcessor', "process"]
61+
args: ["object"]
62+
```
63+
64+
File : `src/StateMachine/CaptureOrderProcessor.php`
65+
66+
```php
67+
<?php
68+
69+
declare(strict_types=1);
70+
71+
namespace App\StateMachine;
72+
73+
use SM\Factory\Factory;
74+
use Sylius\Component\Core\Model\OrderInterface;
75+
use Sylius\Component\Core\Model\PaymentInterface;
76+
use Sylius\Component\Payment\PaymentTransitions;
77+
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
78+
79+
#[Autoconfigure(public: true)] // make the service public to be callable by winzou_state_machine
80+
class CaptureOrderProcessor
81+
{
82+
public function __construct(private Factory $stateMachineFactory) {}
83+
84+
public function process(OrderInterface $order): void
85+
{
86+
$payment = $order->getLastPayment(PaymentInterface::STATE_AUTHORIZED);
87+
if (null === $payment) {
88+
// No payment in authorized state, nothing to do here
89+
return;
90+
}
91+
92+
$this->stateMachineFactory
93+
->get($payment, PaymentTransitions::GRAPH)
94+
->apply(PaymentTransitions::TRANSITION_COMPLETE);
95+
96+
if (PaymentInterface::STATE_COMPLETED !== $payment->getState()) {
97+
throw new \LogicException('Oh no! Payment capture failed 💸');
98+
}
99+
}
100+
}
101+
```
25 KB
Loading

rulesets/phpstan-baseline.neon

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@ parameters:
265265
count: 1
266266
path: ../src/Gateway/Validator/Constraints/IsCanSaveCardsValidator.php
267267

268+
-
269+
message: "#^Cannot call method getData\\(\\) on mixed\\.$#"
270+
count: 1
271+
path: ../src/Gateway/Validator/Constraints/PayplugPermissionValidator.php
272+
268273
-
269274
message: "#^Method PayPlug\\\\SyliusPayPlugPlugin\\\\Gateway\\\\Validator\\\\Constraints\\\\IsCanSaveCardsValidator\\:\\:validate\\(\\) has parameter \\$value with no type specified\\.$#"
270275
count: 1
@@ -330,6 +335,15 @@ parameters:
330335
count: 1
331336
path: ../src/Handler/PaymentNotificationHandler.php
332337

338+
-
339+
message: "#^Parameter \\#1 \\$timestamp of method DateTimeImmutable\\:\\:setTimestamp\\(\\) expects int, mixed given\\.$#"
340+
count: 1
341+
path: ../src/Resolver/PaymentStateResolver.php
342+
-
343+
message: "#^Parameter \\#1 \\$timestamp of method DateTimeImmutable\\:\\:setTimestamp\\(\\) expects int, mixed given\\.$#"
344+
count: 1
345+
path: ../src/Action/CaptureAction.php
346+
333347
-
334348
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
335349
count: 2

src/Action/CaptureAction.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Payplug\Exception\BadRequestException;
99
use Payplug\Exception\ForbiddenException;
1010
use Payplug\Resource\Payment;
11+
use Payplug\Resource\PaymentAuthorization;
1112
use PayPlug\SyliusPayPlugPlugin\Action\Api\ApiAwareTrait;
1213
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
1314
use PayPlug\SyliusPayPlugPlugin\Entity\Card;
@@ -178,6 +179,14 @@ public function execute($request): void
178179
return;
179180
}
180181

182+
$now = new \DateTimeImmutable();
183+
if ($payment->__isset('authorization') &&
184+
$payment->__get('authorization') instanceof PaymentAuthorization &&
185+
null !== $payment->__get('authorization')->__get('expires_at') &&
186+
$now < $now->setTimestamp($payment->__get('authorization')->__get('expires_at'))) {
187+
return;
188+
}
189+
181190
$details['status'] = PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK;
182191
$details['hosted_payment'] = [
183192
'payment_url' => $payment->hosted_payment->payment_url,
@@ -278,12 +287,16 @@ private function createPayment(ArrayObject $details, PaymentInterface $paymentMo
278287
}
279288
}
280289

290+
$this->logger->debug('[PayPlug] Create payment', [
291+
'detail' => $details->getArrayCopy(),
292+
]);
281293
$payment = $this->payPlugApiClient->createPayment($details->getArrayCopy());
282294
$details['payment_id'] = $payment->id;
283295
$details['is_live'] = $payment->is_live;
284296

285297
$this->logger->debug('[PayPlug] Create payment', [
286298
'payment_id' => $payment->id,
299+
'payment' => (array) $payment,
287300
]);
288301

289302
return $payment;

src/Checker/CanSaveCardChecker.php

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@
1212

1313
class CanSaveCardChecker implements CanSaveCardCheckerInterface
1414
{
15-
/** @var CustomerContextInterface */
16-
private $customerContext;
15+
private CustomerContextInterface $customerContext;
16+
private PayplugFeatureChecker $payplugFeatureChecker;
1717

18-
public function __construct(CustomerContextInterface $customerContext)
19-
{
18+
public function __construct(
19+
CustomerContextInterface $customerContext,
20+
PayplugFeatureChecker $payplugFeatureChecker,
21+
) {
2022
$this->customerContext = $customerContext;
23+
$this->payplugFeatureChecker = $payplugFeatureChecker;
2124
}
2225

2326
public function isAllowed(PaymentMethodInterface $paymentMethod): bool
@@ -26,16 +29,6 @@ public function isAllowed(PaymentMethodInterface $paymentMethod): bool
2629
return false;
2730
}
2831

29-
$gatewayConfiguration = $paymentMethod->getGatewayConfig();
30-
31-
if (!$gatewayConfiguration instanceof GatewayConfigInterface) {
32-
return false;
33-
}
34-
35-
if (!\array_key_exists(PayPlugGatewayFactory::ONE_CLICK, $gatewayConfiguration->getConfig())) {
36-
return false;
37-
}
38-
39-
return (bool) $gatewayConfiguration->getConfig()[PayPlugGatewayFactory::ONE_CLICK] ?? false;
32+
return $this->payplugFeatureChecker->isOneClickEnabled($paymentMethod);
4033
}
4134
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PayPlug\SyliusPayPlugPlugin\Checker;
6+
7+
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
8+
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
9+
use Sylius\Component\Core\Model\PaymentMethodInterface;
10+
11+
class PayplugFeatureChecker
12+
{
13+
public function isDeferredCaptureEnabled(PaymentMethodInterface $paymentMethod): bool
14+
{
15+
return $this->getConfigCheckboxValue($paymentMethod, PayPlugGatewayFactory::DEFERRED_CAPTURE);
16+
}
17+
18+
public function isIntegratedPaymentEnabled(PaymentMethodInterface $paymentMethod): bool
19+
{
20+
return $this->getConfigCheckboxValue($paymentMethod, PayPlugGatewayFactory::INTEGRATED_PAYMENT);
21+
}
22+
23+
public function isOneClickEnabled(PaymentMethodInterface $paymentMethod): bool
24+
{
25+
return $this->getConfigCheckboxValue($paymentMethod, PayPlugGatewayFactory::ONE_CLICK);
26+
}
27+
28+
private function getConfigCheckboxValue(PaymentMethodInterface $paymentMethod, string $configKey): bool
29+
{
30+
$gatewayConfiguration = $paymentMethod->getGatewayConfig();
31+
32+
if (!$gatewayConfiguration instanceof GatewayConfigInterface) {
33+
return false;
34+
}
35+
36+
if (!\array_key_exists($configKey, $gatewayConfiguration->getConfig())) {
37+
return false;
38+
}
39+
40+
return (bool) ($gatewayConfiguration->getConfig()[$configKey] ?? false);
41+
}
42+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PayPlug\SyliusPayPlugPlugin\Command;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface;
9+
use Psr\Log\LoggerInterface;
10+
use SM\Factory\Factory;
11+
use Sylius\Component\Payment\PaymentTransitions;
12+
use Symfony\Component\Console\Command\Command;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Input\InputOption;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
17+
class CaptureAuthorizedPaymentCommand extends Command
18+
{
19+
private Factory $stateMachineFactory;
20+
private PaymentRepositoryInterface $paymentRepository;
21+
private EntityManagerInterface $entityManager;
22+
private LoggerInterface $logger;
23+
24+
public function __construct(
25+
Factory $stateMachineFactory,
26+
PaymentRepositoryInterface $paymentRepository,
27+
EntityManagerInterface $entityManager,
28+
LoggerInterface $logger,
29+
) {
30+
$this->stateMachineFactory = $stateMachineFactory;
31+
$this->paymentRepository = $paymentRepository;
32+
$this->entityManager = $entityManager;
33+
$this->logger = $logger;
34+
35+
parent::__construct();
36+
}
37+
38+
protected function configure(): void
39+
{
40+
$this->setName('payplug:capture-authorized-payments')
41+
->setDescription('Capture payplug authorized payments older than X days (default 6)')
42+
->addOption('days', 'd', InputOption::VALUE_OPTIONAL, 'Number of days to wait before capturing authorized payments', 6)
43+
;
44+
}
45+
46+
protected function execute(InputInterface $input, OutputInterface $output): int
47+
{
48+
$days = \filter_var($input->getOption('days'), FILTER_VALIDATE_INT);
49+
if (false === $days) {
50+
throw new \InvalidArgumentException('Invalid number of days provided.');
51+
}
52+
53+
$payments = $this->paymentRepository->findAllAuthorizedOlderThanDays($days);
54+
55+
if (\count($payments) === 0) {
56+
$this->logger->debug('[Payplug] No authorized payments found.');
57+
}
58+
59+
foreach ($payments as $i => $payment) {
60+
$stateMachine = $this->stateMachineFactory->get($payment, PaymentTransitions::GRAPH);
61+
$this->logger->info('[Payplug] Capturing payment {paymentId} (order #{orderNumber})', [
62+
'paymentId' => $payment->getId(),
63+
'orderNumber' => $payment->getOrder()?->getNumber() ?? 'N/A',
64+
]);
65+
$output->writeln(sprintf('Capturing payment %d (order #%s)', $payment->getId(), $payment->getOrder()?->getNumber() ?? 'N/A'));
66+
67+
try {
68+
$stateMachine->apply(PaymentTransitions::TRANSITION_COMPLETE);
69+
} catch (\Throwable $e) {
70+
$this->logger->critical('[Payplug] Error while capturing payment {paymentId}', [
71+
'paymentId' => $payment->getId(),
72+
'exception' => $e->getMessage(),
73+
]);
74+
continue;
75+
}
76+
77+
if ($i % 10 === 0) {
78+
$this->entityManager->flush();
79+
}
80+
}
81+
82+
$this->entityManager->flush();
83+
84+
return Command::SUCCESS;
85+
}
86+
}

src/Const/Permission.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PayPlug\SyliusPayPlugPlugin\Const;
6+
7+
/**
8+
* Permission list that payplug can return
9+
*/
10+
final class Permission
11+
{
12+
public const USE_LIVE_MODE = 'use_live_mode';
13+
public const CAN_SAVE_CARD = 'can_save_cards';
14+
public const CAN_CREATE_DEFERRED_PAYMENT = 'can_create_deferred_payment';
15+
public const CAN_USE_INTEGRATED_PAYMENTS = 'can_use_integrated_payments';
16+
public const CAN_CREATE_INSTALLMENT_PLAN = 'can_create_installment_plan';
17+
public const CAN_USE_ONEY = 'can_use_oney';
18+
19+
public static function getAll(): array
20+
{
21+
return [
22+
self::USE_LIVE_MODE,
23+
self::CAN_SAVE_CARD,
24+
self::CAN_CREATE_DEFERRED_PAYMENT,
25+
self::CAN_USE_INTEGRATED_PAYMENTS,
26+
self::CAN_CREATE_INSTALLMENT_PLAN,
27+
self::CAN_USE_ONEY,
28+
];
29+
}
30+
31+
public static function isPermission(string $permission): bool
32+
{
33+
return in_array($permission, self::getAll(), true);
34+
}
35+
}

0 commit comments

Comments
 (0)