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
24 changes: 21 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,33 @@ persisted into the details by the library's Status/Confirm/Sync actions. Each op
`operations.capture` / `operations.refund` / `operations.cancel` (defined in `DependencyInjection/Configuration.php`,
passed to the processor as container parameters).

### Payment link (admin)
`PaymentLink/PaymentLinkProvider` returns Sylius' `sylius_shop_order_pay` url (built for the order's channel
hostname, like Sylius' `sylius_channel_url`; `%sylius.unsecured_urls%` picks the scheme) for a Quickpay payment
that is the order's last payment in state `new` on a non-cancelled order — null otherwise, also when the shop
route is absent (headless). It is deliberately *not* a raw Quickpay window url: opening it mints a fresh Payum
token and runs the normal Convert → payment-window flow, so nothing happens at Quickpay until the customer
clicks and a payment without a `quickpayPaymentId` works too. Exposed to Twig as
`setono_sylius_quickpay_payment_link(payment)` (on `Twig/PaymentExtension` + `PaymentRuntime`, the plugin's single
Twig extension/runtime pair); `admin/order/show/payment/_quickpay.html.twig` renders the
copy-able link plus a send link shaped like Sylius' resend-order-confirmation-email button (a GET carrying
`_csrf_token`, token id = payment id) to `Controller/Admin/SendPaymentLinkAction`, which reads the flash bag via
Sylius' `FlashBagProvider`, sends through `Mailer/PaymentLinkEmailManager` (the Sylius
email-manager pattern; code `Mailer\Emails::PAYMENT_LINK`, prepended into `sylius_mailer` with
`email/payment_link.html.twig`) in the order's locale and flashes in the `flashes` translation domain
(`Resources/translations/flashes.*.yaml`). Both Twig functions are covered by the `Twig\Test\IntegrationTestCase`
subclass `tests/Twig/PaymentExtensionTest` with `.test` fixtures in `tests/Twig/Fixtures/` (implement both `getFixturesDir()` for Twig < 3.13 and
`getFixturesDirectory()`).

### Checkout presentation
`Checkout/PaymentMethodLogoProvider` turns a Quickpay payment method's gateway `payment_methods` option into a
list of `PaymentMethodLogo` value objects (token, label, `asset()` path or null) — parsing Quickpay's token
grammar (`!` exclusions, `3d-` prefix, `-dk`/`-debet` variants collapsing onto the brand, `creditcard`
expanding to the configured `checkout.creditcard_brands`) and applying the `checkout.payment_method_logos`
config overrides (path adds/overrides, null hides). Exposed to Twig as
`setono_sylius_quickpay_payment_method_logos(method)` (`Twig/PaymentMethodLogoExtension` + `PaymentMethodLogoRuntime`,
the Twig runtime pattern; `phpstan.neon` ignores the one error Twig < 3.9's `callable|null` docblock raises for the
`[Runtime::class, 'method']` form on the lowest-deps job) and
`setono_sylius_quickpay_payment_method_logos(method)` (on `Twig/PaymentExtension` + `PaymentRuntime`, the plugin's
single Twig extension/runtime pair; `phpstan.neon` ignores the one error Twig < 3.9's `callable|null` docblock raises
for the `[Runtime::class, 'method']` form on the lowest-deps job) and
rendered by `shop/checkout/select_payment/_payment_method_logos.html.twig`, a `sylius_ui` block prepended on
`sylius.shop.checkout.select_payment.choice_item_content`. The SVGs in
`Resources/public/images/payment-methods/` are Shopify's MIT-licensed payment_icons (attribution README
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,22 @@ The markup lives in `@SetonoSyliusQuickpayPlugin/shop/checkout/select_payment/_p
`sylius_ui` block on `sylius.shop.checkout.select_payment.choice_item_content`) and can be overridden like any bundle
template.

## Payment link in the admin

Every Quickpay payment that is still **awaiting payment** shows a **Payment link** panel on the admin order view: a
copy-able link, and a button that emails it to the customer. Use it to collect payment for a phone or invoice order,
or after a customer abandoned the checkout — anyone opening the link is taken through the normal flow into the
Quickpay payment window, and the payment resolves exactly as after checkout.

The link is Sylius' own "pay for this order" url (`sylius_shop_order_pay`), built for the order's channel hostname:
nothing happens at Quickpay until the customer clicks, so it can be shown, copied and sent as often as needed, and it
also works for a payment whose Quickpay payment was never created (the flow creates it on first use). It is offered for
the order's last payment in state `new` on a Quickpay method, as long as the order is not cancelled — the same payment
Sylius' own "Pay" button in the customer account pays.

The email (`@SetonoSyliusQuickpayPlugin/email/payment_link.html.twig`, code `setono_sylius_quickpay_payment_link`)
is sent in the order's locale through Sylius' mailer and can be overridden like any Sylius email template.

## Operation history in the admin

Each Quickpay payment on the admin order view shows its **live operation history** — every
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"sylius/core": "^1.0",
"sylius/core-bundle": "^1.0",
"sylius/locale": "^1.0",
"sylius/mailer-bundle": "^1.8 || ^2.0",
"sylius/payment": "^1.0",
"sylius/payum-bundle": "^1.0",
"sylius/resource-bundle": "^1.10",
Expand All @@ -37,6 +38,8 @@
"symfony/http-foundation": "^6.4",
"symfony/http-kernel": "^6.4",
"symfony/intl": "^6.4",
"symfony/routing": "^6.4",
"symfony/security-csrf": "^6.4",
"symfony/validator": "^6.4",
"symfony/workflow": "^6.4",
"twig/twig": "^2.15 || ^3.0",
Expand Down
80 changes: 80 additions & 0 deletions src/Controller/Admin/SendPaymentLinkAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Controller\Admin;

use Setono\SyliusQuickpayPlugin\Mailer\PaymentLinkEmailManagerInterface;
use Setono\SyliusQuickpayPlugin\PaymentLink\PaymentLinkProviderInterface;
use Sylius\Bundle\CoreBundle\Provider\FlashBagProvider;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Repository\PaymentRepositoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

/**
* Emails the customer the link that pays a Quickpay payment still awaiting payment — the same
* link the admin order view shows for copying — and returns to the order. Shaped like Sylius'
* own ResendOrderConfirmationEmailAction: a GET carrying a `_csrf_token` query parameter, a
* flash, and a redirect back to the order.
*/
final class SendPaymentLinkAction
{
/**
* @param PaymentRepositoryInterface<PaymentInterface> $paymentRepository
*/
public function __construct(
private readonly PaymentRepositoryInterface $paymentRepository,
private readonly PaymentLinkProviderInterface $paymentLinkProvider,
private readonly PaymentLinkEmailManagerInterface $paymentLinkEmailManager,
private readonly CsrfTokenManagerInterface $csrfTokenManager,
private readonly RequestStack $requestStack,
private readonly UrlGeneratorInterface $urlGenerator,
) {
}

public function __invoke(Request $request, int $id): Response
{
if (!$this->csrfTokenManager->isTokenValid(new CsrfToken((string) $id, (string) $request->query->get('_csrf_token', '')))) {
throw new HttpException(Response::HTTP_FORBIDDEN, 'Invalid csrf token.');
}

$payment = $this->paymentRepository->find($id);
if (!$payment instanceof PaymentInterface) {
throw new NotFoundHttpException(sprintf('Payment %d does not exist', $id));
}

$order = $payment->getOrder();
if (!$order instanceof OrderInterface) {
throw new NotFoundHttpException(sprintf('Payment %d belongs to no order', $id));
}

$flashBag = FlashBagProvider::getFlashBag($this->requestStack);

$paymentLink = $this->paymentLinkProvider->provide($payment);
$customer = $order->getCustomer();
$email = $customer instanceof CustomerInterface ? $customer->getEmail() : null;

if (null === $paymentLink || null === $email || '' === $email) {
$flashBag->add('error', 'setono_sylius_quickpay.payment_link_not_sent');
} else {
$this->paymentLinkEmailManager->sendPaymentLinkEmail($payment, $paymentLink);

$flashBag->add('success', [
'message' => 'setono_sylius_quickpay.payment_link_sent',
'parameters' => ['%email%' => $email],
]);
}

return new RedirectResponse($this->urlGenerator->generate('sylius_admin_order_show', ['id' => $order->getId()]));
}
}
12 changes: 12 additions & 0 deletions src/DependencyInjection/SetonoSyliusQuickpayExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Setono\SyliusQuickpayPlugin\DependencyInjection;

use Setono\SyliusQuickpayPlugin\Mailer\Emails;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
Expand Down Expand Up @@ -58,6 +59,17 @@ public function prepend(ContainerBuilder $container): void
]);
}

if ($container->hasExtension('sylius_mailer')) {
$container->prependExtensionConfig('sylius_mailer', [
'emails' => [
Emails::PAYMENT_LINK => [
'subject' => 'setono_sylius_quickpay.email.payment_link.subject',
'template' => '@SetonoSyliusQuickpayPlugin/email/payment_link.html.twig',
],
],
]);
}

// The guard keeps the plugin bootable in applications running the sylius_payment graph on
// the symfony_workflow adapter, where the winzou bundle is not necessarily registered
if (!$container->hasExtension('winzou_state_machine')) {
Expand Down
14 changes: 14 additions & 0 deletions src/Mailer/Emails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Mailer;

/**
* The email codes the plugin registers with the Sylius mailer, mirroring
* {@see \Sylius\Bundle\CoreBundle\Mailer\Emails}.
*/
interface Emails
{
public const PAYMENT_LINK = 'setono_sylius_quickpay_payment_link';
}
42 changes: 42 additions & 0 deletions src/Mailer/PaymentLinkEmailManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Mailer;

use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Mailer\Sender\SenderInterface;
use Webmozart\Assert\Assert;

/**
* Sends the payment link email the way Sylius' own email managers do: through the Sylius mailer,
* with the order, channel and locale in the data so the template renders in the customer's locale.
*/
final class PaymentLinkEmailManager implements PaymentLinkEmailManagerInterface
{
public function __construct(private readonly SenderInterface $emailSender)
{
}

public function sendPaymentLinkEmail(PaymentInterface $payment, string $paymentLink): void
{
$order = $payment->getOrder();
Assert::isInstanceOf($order, OrderInterface::class);

$customer = $order->getCustomer();
Assert::isInstanceOf($customer, CustomerInterface::class);

$email = $customer->getEmail();
Assert::stringNotEmpty($email);

$this->emailSender->send(Emails::PAYMENT_LINK, [$email], [
'order' => $order,
'payment' => $payment,
'paymentLink' => $paymentLink,
'channel' => $order->getChannel(),
'localeCode' => $order->getLocaleCode(),
]);
}
}
17 changes: 17 additions & 0 deletions src/Mailer/PaymentLinkEmailManagerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Mailer;

use Sylius\Component\Core\Model\PaymentInterface;

interface PaymentLinkEmailManagerInterface
{
/**
* Emails the customer of the payment's order the link that pays the payment.
*
* @throws \InvalidArgumentException when the order has no customer email to send to
*/
public function sendPaymentLinkEmail(PaymentInterface $payment, string $paymentLink): void;
}
86 changes: 86 additions & 0 deletions src/PaymentLink/PaymentLinkProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\PaymentLink;

use Setono\Payum\Quickpay\QuickpayGatewayFactory;
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Symfony\Component\HttpFoundation\UrlHelper;
use Symfony\Component\Routing\Exception\ExceptionInterface as RoutingException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
* The payment link is Sylius' own "pay for this order" url (`sylius_shop_order_pay`): opening it
* mints a fresh Payum token for the payment awaiting payment and sends the customer through the
* plugin's normal checkout flow — the Quickpay payment is created if checkout never got that far,
* the payment window opens with the method's capture mode, and the return trip resolves the
* payment state exactly as after checkout. Nothing happens at Quickpay until the customer clicks,
* so the link can be shown, copied and emailed as often as needed.
*
* The url is built for the order's channel hostname (the way Sylius' own emails do it with
* `sylius_channel_url`), so it is right even when generated from the admin.
*/
final class PaymentLinkProvider implements PaymentLinkProviderInterface
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly UrlHelper $urlHelper,
private readonly bool $unsecuredUrls = false,
) {
}

public function provide(PaymentInterface $payment): ?string
{
if (PaymentInterface::STATE_NEW !== $payment->getState()) {
return null;
}

$method = $payment->getMethod();
$gatewayConfig = $method instanceof PaymentMethodInterface ? $method->getGatewayConfig() : null;
if (!$gatewayConfig instanceof GatewayConfigInterface || QuickpayGatewayFactory::NAME !== $gatewayConfig->getFactoryName()) {
return null;
}

$order = $payment->getOrder();
if (!$order instanceof OrderInterface || OrderInterface::STATE_CANCELLED === $order->getState()) {
return null;
}

// The pay route always charges the order's LAST payment awaiting payment
// (PayumController::prepareCaptureAction → getLastPayment(STATE_NEW)) — the plugin cannot
// point it at another one. So the link is only offered on that payment: shown on any earlier
// `new` payment it would silently pay a different one, possibly through another gateway.
// Sylius core never leaves two `new` payments on an order (OrderPaymentProcessor reuses the
// last one), so this only matters for customized setups — and there the link belongs to the
// payment Sylius will actually charge, Quickpay or not.
if ($order->getLastPayment(PaymentInterface::STATE_NEW) !== $payment) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if an order has multiple 'new' payments, but the quickpay one is not the last one?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then no link is shown for the Quickpay payment — deliberately, and I've expanded the comment to say why.

The link is Sylius' sylius_shop_order_pay, and PayumController::prepareCaptureAction always charges $order->getLastPayment(PaymentInterface::STATE_NEW) — the plugin can't point it at another payment. If we showed the link on an earlier new Quickpay payment, the customer would end up paying the later one, possibly through another gateway entirely. So the panel is only offered on the payment Sylius will actually charge; if that last payment is a Quickpay one, the panel shows there (which is what the test "an earlier payment when a later one awaits payment" pins down).

It's also an edge case Sylius core doesn't produce: OrderPaymentProcessor reuses the last new payment (updates amount/currency) rather than adding a second one, so two new payments only arise in customized setups — and there the link still belongs to whatever payment Sylius would charge, Quickpay or not. Happy to hear if you see a scenario where hiding it is the wrong call.

return null;
}

$tokenValue = $order->getTokenValue();
if (null === $tokenValue || '' === $tokenValue) {
return null;
}

try {
$path = $this->urlGenerator->generate('sylius_shop_order_pay', [
'tokenValue' => $tokenValue,
'_locale' => $order->getLocaleCode(),
]);
} catch (RoutingException) {
// A headless shop without the Sylius shop routes has no url to hand out
return null;
}

$hostname = $order->getChannel()?->getHostname();
if (null !== $hostname && '' !== $hostname) {
return ($this->unsecuredUrls ? 'http://' : 'https://') . $hostname . $path;
}

return $this->urlHelper->getAbsoluteUrl($path);
}
}
17 changes: 17 additions & 0 deletions src/PaymentLink/PaymentLinkProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\PaymentLink;

use Sylius\Component\Core\Model\PaymentInterface;

interface PaymentLinkProviderInterface
{
/**
* The absolute url a customer can open to pay a Quickpay payment that is still awaiting
* payment, or null when the payment cannot be paid that way (it is not a Quickpay payment,
* it is not the order's payment awaiting payment, the order is cancelled, …).
*/
public function provide(PaymentInterface $payment): ?string;
}
9 changes: 9 additions & 0 deletions src/Resources/config/routes/admin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ setono_sylius_quickpay_admin_payment_operations:
id: '\d+'
defaults:
_controller: Setono\SyliusQuickpayPlugin\Controller\Admin\PaymentOperationsAction

# A GET carrying the csrf token, like Sylius' sylius_admin_order_resend_confirmation_email
setono_sylius_quickpay_admin_send_payment_link:
path: /quickpay/payments/{id}/send-payment-link
methods: [GET]
requirements:
id: '\d+'
defaults:
_controller: Setono\SyliusQuickpayPlugin\Controller\Admin\SendPaymentLinkAction
Loading
Loading