-
Notifications
You must be signed in to change notification settings - Fork 2
Send a payment link to the customer from the admin order view #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()])); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(), | ||
| ]); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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, andPayumController::prepareCaptureActionalways charges$order->getLastPayment(PaymentInterface::STATE_NEW)— the plugin can't point it at another payment. If we showed the link on an earliernewQuickpay 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:
OrderPaymentProcessorreuses the lastnewpayment (updates amount/currency) rather than adding a second one, so twonewpayments 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.