Skip to content

Send a payment link to the customer from the admin order view - #126

Merged
loevgaard merged 2 commits into
2.xfrom
admin-payment-link
Aug 17, 2026
Merged

Send a payment link to the customer from the admin order view#126
loevgaard merged 2 commits into
2.xfrom
admin-payment-link

Conversation

@loevgaard

Copy link
Copy Markdown
Member

Stacked on #125.

Every Quickpay payment 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 — for phone/invoice orders or after an abandoned checkout.

Design note: the link is Sylius' own sylius_shop_order_pay url (built for the order's channel hostname), 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, it can be shown/copied/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). PaymentLinkProvider offers it for the order's last payment in state new on a Quickpay method of a non-cancelled order — exactly the payment Sylius' own "Pay" button in the customer account pays — and null otherwise (incl. headless shops without the route).

  • SendPaymentLinkAction: POST, CSRF token = payment id (Sylius admin convention), sends email/payment_link.html.twig via sylius.email_sender in the order's locale, flashes the outcome (flashes domain)
  • Panel + email + flashes translated in all 16 locales; rendering verified through the booted test app (panel, email in the order's locale, nothing for ineligible payments)
  • Declares sylius/mailer-bundle, symfony/routing, symfony/security-csrf, which the plugin now uses directly

100 tests, PHPStan max, ECS, Rector, normalize, dependency analyser green.

Closes #107

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.78049% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.37%. Comparing base (de9c05c) to head (7116f3c).

Files with missing lines Patch % Lines
src/Controller/Admin/SendPaymentLinkAction.php 95.45% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##                2.x     #126      +/-   ##
============================================
+ Coverage     85.99%   87.37%   +1.37%     
- Complexity      154      182      +28     
============================================
  Files            20       23       +3     
  Lines           614      689      +75     
============================================
+ Hits            528      602      +74     
- Misses           86       87       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@loevgaard
loevgaard force-pushed the checkout-payment-method-logos branch 2 times, most recently from bf9aaeb to 6877f47 Compare August 17, 2026 09:59
Comment thread src/Twig/PaymentLinkExtension.php Outdated
public function getFunctions(): array
{
return [
new TwigFunction('setono_sylius_quickpay_payment_link', $this->paymentLink(...)),

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.

Use a Twig runtime

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.

Done — back to Twig runtimes (PaymentLinkRuntime here, and PaymentMethodLogoRuntime in #125 for consistency), registered with twig.runtime.

For context on why they had been folded into the extensions: with twig/twig ^2.15 || ^3.0, the lowest-deps job resolves Twig 2.15, whose TwigFunction constructor docblock is callable|null — the runtime form [SomeRuntime::class, 'method'] isn't a PHP callable, so PHPStan (level max) rejected it there. Twig only documents callable|array{class-string, string}|null from 3.9. Rather than raise the plugin's Twig floor over a docblock, phpstan.neon (in #125) now ignores exactly that error under src/Twig/* with an explanatory comment — reportUnmatchedIgnoredErrors is already false, so it's silent on the highest-deps side. If you'd rather bump to twig/twig ^3.9 instead, say so and I'll swap.

if ($container->hasExtension('sylius_mailer')) {
$container->prependExtensionConfig('sylius_mailer', [
'emails' => [
SendPaymentLinkAction::EMAIL_CODE => [

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.

These codes are usually not defined on controller actions. Check what Sylius does

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.

Done. Sylius keeps them in an interface of constants (Sylius\Bundle\CoreBundle\Mailer\Emails) and sends through email managers (OrderEmailManager etc.), so this mirrors that: Mailer\Emails::PAYMENT_LINK holds the code (used by the sylius_mailer prepend), and a new Mailer\PaymentLinkEmailManager (sendPaymentLinkEmail(PaymentInterface $payment, string $paymentLink)) wraps sylius.email_sender — the controller now only resolves the link, delegates to the manager and flashes. Both are unit-tested.


// Sylius pays the order's last payment awaiting payment, so that is the only one the link
// can be for; anything else would show a link that pays a different payment
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.

@loevgaard
loevgaard force-pushed the checkout-payment-method-logos branch from 6877f47 to d36afdf Compare August 17, 2026 11:29
}

$session = $request->getSession();
\assert($session instanceof Session);

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.

Don't use \assert. Instead check if $session actually is what you expect

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.

Done — no more \assert. The action now takes the RequestStack and gets the flash bag through Sylius' own FlashBagProvider::getFlashBag($this->requestStack), exactly as ResendOrderConfirmationEmailAction does (it checks the bag is a FlashBagInterface and fails loudly otherwise).

Comment thread tests/Twig/PaymentLinkRuntimeTest.php Outdated
use Setono\SyliusQuickpayPlugin\Twig\PaymentLinkRuntime;
use Sylius\Component\Core\Model\PaymentInterface;

final class PaymentLinkRuntimeTest extends TestCase

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.

Use \Twig\Test\IntegrationTestCase to test the Twig extension

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.

Done — tests/Twig/PaymentLinkExtensionTest now extends Twig\Test\IntegrationTestCase: it builds a real Twig environment with the extension, a FactoryRuntimeLoader for PaymentLinkRuntime, and the real PaymentLinkProvider on top of a real Symfony UrlGenerator; the cases live in Fixtures/PaymentLink/payment_link.test (a Quickpay payment awaiting payment → the channel-hostname pay url; a completed one → nothing). Same treatment for the logo extension in #125. The test class implements both getFixturesDir() (Twig < 3.13 — the lowest supported is 2.15) and getFixturesDirectory(), so it runs across the matrix.

Comment on lines +12 to +17
<form action="{{ path('setono_sylius_quickpay_admin_send_payment_link', {'id': payment.id}) }}" method="post" style="margin-top: .75em;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token(payment.id) }}">
<button type="submit" class="ui icon labeled tiny fluid button">
<i class="send icon"></i> {{ 'setono_sylius_quickpay.ui.send_payment_link'|trans({'%email%': payment.order.customer.email|default('')}) }}
</button>
</form>

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.

Is this how Sylius does it? Sylius has the resend confirmation email functionality. Please verify

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.

Verified against Sylius' resend-order-confirmation-email and aligned to it. Sylius renders _resendEmail.html.twig as an <a class="ui icon labeled tiny fluid button"> whose href is a GET route with the csrf token as a query parameter (path('sylius_admin_order_resend_confirmation_email', {'id': order.id, '_csrf_token': csrf_token(order.id)}); route methods: [GET]), and the action reads $request->query->get('_csrf_token'), flashes and redirects. The send button is now exactly that shape (link, not a form): route setono_sylius_quickpay_admin_send_payment_link is GET /admin/quickpay/payments/{id}/send-payment-link with _csrf_token in the query, token id = payment id, sylius_test_html_attribute('send-quickpay-payment-link') on the link. Clicked through it on the running test app after the change — flash + redirect as before.

@loevgaard
loevgaard force-pushed the checkout-payment-method-logos branch from d36afdf to d0bd832 Compare August 17, 2026 11:45
@loevgaard
loevgaard force-pushed the admin-payment-link branch 2 times, most recently from 9db425d to b8f83bb Compare August 17, 2026 11:49
Base automatically changed from checkout-payment-method-logos to 2.x August 17, 2026 12:01
Every Quickpay payment still awaiting payment now shows a Payment link panel
on the admin order view: a copy-able link and a button that emails it to the
customer — for phone or invoice orders, or after an abandoned checkout.

The link is Sylius' own sylius_shop_order_pay url built for the order's
channel hostname, 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, it can be shown and
sent as often as needed, and a payment whose Quickpay payment was never
created works too. PaymentLinkProvider offers it for the order's last payment
in state new on a Quickpay method of a non-cancelled order — the payment
Sylius' own "Pay" button pays — and null otherwise (including a headless shop
without the route).

SendPaymentLinkAction (POST, CSRF token = payment id as in Sylius' admin)
emails email/payment_link.html.twig through sylius.email_sender in the
order's locale and flashes the outcome. Translations for the panel, the
email and the flashes in all 16 locales; sylius/mailer-bundle,
symfony/routing and symfony/security-csrf are now declared since the plugin
uses them directly.

Closes #107
One plugin, one Twig extension: setono_sylius_quickpay_payment_method_logos
and setono_sylius_quickpay_payment_link now live on Twig/PaymentExtension,
backed by a single Twig/PaymentRuntime carrying both providers, and are
covered by one IntegrationTestCase running both fixtures.
@loevgaard
loevgaard merged commit 9eb3aa5 into 2.x Aug 17, 2026
29 of 30 checks passed
@loevgaard
loevgaard deleted the admin-payment-link branch August 17, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Send/resend a Quickpay payment link from the admin order view

1 participant