Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
[Sylius] Restrict Payment Methods depending on customer group

It could be common to display some payment methods only to customers that belong to a group.

For example, you can add an offline payment method for you B2B customers, and invoice them at the end of the month, and this offline payment should absolutely not be available for other customer! Let's see how to integrate this in a Sylius Application.

1. The Quick and Dirty way

The form displaying payment methods in Sylius retrieve them from the service sylius.payment_methods_resolver.

Let's decorate it !

First, create a class PaymentMethodsResolverDecorator which extend PaymentMethodsResolverInterface, and that have this same interface as first dependency.

// src/Resolver/PaymentMethodsResolverDecorator.php
<?php

declare(strict_types=1);

namespace App\Resolver;

use Sylius\Component\Payment\Model\PaymentInterface;
use Sylius\Component\Payment\Model\PaymentMethodInterface;
use Sylius\Component\Payment\Resolver\PaymentMethodsResolverInterface;

final class PaymentMethodResolverDecorator implements PaymentMethodsResolverInterface
{
    private PaymentMethodsResolverInterface $decorated;

    public function __construct(PaymentMethodsResolverInterface $decorated)
    {
        $this->decorated = $decorated;
    }

    public function getSupportedMethods(PaymentInterface $subject): array
    {
        return $this->decorated->getSupportedMethods($subject);
    }

    public function supports(PaymentInterface $subject): bool
    {
        return $this->decorated->supports($subject);
    }
}

And activate the decoration of service inside your services.yaml

#config/services.yaml
services:
    # ...
    App\Resolver\PaymentMethodResolverDecorator:
        decorates: 'sylius.payment_methods_resolver'

You are now ready to integrate the restriction !

The PaymentInterface passed as subject in getSupportedMethods contains the current order, and the current order contains the customer.

So, if we want exclude the Offline Payment Method named "End of the month" except for customers that belong to "B2B" group, we could code something like this:

//src/Resolver/PaymentMethodsResolverDecorator.php
<?php 
//...
    private const B2B_PAYMENT_METHOD_CODE = 'end_of_month';
    private const B2B_GROUP_CODE = 'b2b';

    public function getSupportedMethods(PaymentInterface $subject): array
    {
        /** @var \App\Entity\Customer\CustomerGroup $customerGroup */
        $customerGroup = $subject->getOrder()->getCustomer()->getGroup();
        $customerGroupCode = null;
        if (null !== $customerGroup) {
            $customerGroupCode = $customerGroup->getCode();
        }
        
        $supportedMethods = $this->decorated->getSupportedMethods($subject);

        foreach ($supportedMethods as $index => $method) {
            if ($method->getCode() === self::B2B_PAYMENT_METHOD_CODE && $customerGroupCode !== self::B2B_GROUP_CODE) {
                // We unset this payment method because customer don't belong to required group
                unset($supportedMethods[$index]);
            }
        }

        return $supportedMethods;
    }
//...

And that's it ! Navigate on your shop, with a non logged user or an user without group, and you'll never aware about the existence of "End Of Month" payment method.

Conversely, all customers in the "B2B" group will be able to use it.

This method is great for a quick restriction, but it's not scalable, there is hardcoded code in source...

Let's see how to do it better.

2. The clean and evolving way

The best way should be something that is configurable form PaymentMethod Form. We should ideally have a CustomerGroup selector, and if no one is selected, the payment method is accessible for everyone.

So, we should alter the form to get the customer group selector.

The class responsible to render it is Sylius\Bundle\PaymentBundle\Form\Type\PaymentMethodType

We could easily extend this form by creating a form type extension, but there is some drawbacks. First, we should add field on PaymentMethod Entity, to carry information of customer groups, and alter view of the form to add this new field. It's definitely not an evolving way, because we could lose template evolution by upgrading Sylius versions.

The Payment Methods offer a Gateway configuration, and everything is stored as a JSON inside the database, and the field already exist.

But, every payment plugin can create their own Gateway configuration, so we have to do some black magic to add our field on every GatewayConfig.

Let's start by creating a GatewayConfigurationExtension :

// src/Form/Extension/GatewayConfigurationExtension.php
<?php
    
declare(strict_types=1);

namespace App\Form\Extension;

use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormBuilderInterface;

final class GatewayConfigurationExtension extends AbstractTypeExtension 
{
    public static function getExtendedTypes(): iterable
    {
        // TODO
        return [];
    }
    
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        // TODO
    }
}

Next, we want extend every GatewayConfigurationType provided by plugins.

As we can see, for a Form Extension, we should return the class name of extended types. We cannot provide them here, as we don't want to update this class each time we had a new plugin.

To handle that, we'll use the power of CompilerPass.

Let's modify this class first :

// src/Form/Extension/GatewayConfigurationExtension.php
<?php
// ...

    public static array $extendedTypes = [];
    
    public static function getExtendedTypes(): iterable
    {
        return self::$extendedTypes;
    }

    public function setExtendedTypes(array $extendedTypes): void
    {
        self::$extendedTypes = $extendedTypes;
    }

We add a static property that carry all extendedTypes we want.

Then, the CompilerPass :

// src/CompilerPass/GatewayConfigurationExtensionCompilerPass.php
<?php

declare(strict_types=1);

namespace App\CompilerPass;

use App\Form\Extension\GatewayConfigurationExtension;
use Sylius\Bundle\PayumBundle\Form\Type\GatewayConfigType;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final class GatewayConfigurationExtensionCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        $gatewayServices = $container->findTaggedServiceIds('sylius.gateway_configuration_type');
        $gatewayTypes = [GatewayConfigType::class];

        foreach ($gatewayServices as $service => $definitions) {
            $gatewayTypes[] = \get_class($container->get($service));
        }
        GatewayConfigurationExtension::$extendedTypes = $gatewayTypes;
        
        $definition = $container->getDefinition(GatewayConfigurationExtension::class);
        $definition->addMethodCall('setExtendedTypes', [$gatewaysTypes]);  
    }
}

Let me explain. We retrieve all services that are tagged as sylius.gateway_configuration_type. This is the tag to apply when plugins want to provide a custom configuration in their PaymentMethod.

We then retrieve the class name for all of these services. And we set the static property with theses class names.

We also define a methodCall that call our function setExtendedTypes, this is used later when the container is compiled.

The hard coded GatewayConfigType::class is here to let us handle payment methods without GatewayConfiguration provided. We'll see why in the form extension.

Then, we modify the Kernel class to register this CompilerPass :

// src/Kernel.php
<?php
// ...
    protected function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(new GatewayConfigurationExtensionCompilerPass());
    }

At this point, we are still not ready to alter our forms.

In effect, if we try to build the container, an error is raised :

The getExtendedTypes() method for service "App\Form\Extension\GatewayConfigurationExtension" does not return any extended types.

It's because the other Compilerpass that register form extensions is processed before our own CompilerPass. To bypass this, we need more black magic !

Let's rework the build method in Kernel.

// src/Kernel.php
<?php
// ...
    protected function build(ContainerBuilder $container): void
    {
        $passesBeforeOptimization = $container->getCompiler()->getPassConfig()->getBeforeOptimizationPasses();
        $passTypeToRegisterLater = [FormPass::class];
        $passToRegisterLater = [];

        foreach ($passesBeforeOptimization as $index => $compilerPass) {
            $compilerClass = \get_class($compilerPass);
            if (\in_array($compilerClass, $passTypeToRegisterLater, true)) {
                unset($passesBeforeOptimization[$index]);
                $passToRegisterLater[] = $compilerPass;
            }
        }
        $container->getCompiler()->getPassConfig()->setBeforeOptimizationPasses($passesBeforeOptimization);

        $container->addCompilerPass(new GatewayConfigurationExtensionCompilerPass());
        
        foreach ($passToRegisterLater as $compilerPass) {
            $container->addCompilerPass($compilerPass);
        }
    }

With this manipulation, we retrieve the CompilerPass responsible to register form extension (FormPass), and we unset it. We then register our CompilerPass, and after that, we re-register CompilerPass unsetted.

We can now alter our forms. Go back to our Extension :

// src/Form/Extension/GatewayConfigurationExtension.php
<?php
// ...
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $customerGroupFormOptions = [
            'entry_type' => CustomerGroupCodeChoiceType::class,
            'required' => false,
            'prototype' => true,
            'allow_add' => true,
            'allow_delete' => true,
            'by_reference' => false,
            'label' => 'Customer Groups',
        ];

        if ($builder->getName() === 'config') {
            // This form has a gateway config. Easy, just add field
            $builder->add('customer_group', CollectionType::class, $customerGroupFormOptions);
            return;
        }
    
        $builder->addEventListener(FormEvents::PRE_SET_DATA, static function (FormEvent $event) use ($customerGroupFormOptions) {
            /** @var \Symfony\Component\Form\Form $form */
            $form = $event->getForm();
            if ($form->has('config')) {
                // already handle (see above)
                return;
            }
            // We are on a PaymentMethod without GatewayConfig (like offline method). 
            // Let's add config key, and customer_group inside.
            $form->add('config', FormType::class);
            $configForm = $form->get('config');
            $configForm->add('customer_group', CollectionType::class, $customerGroupFormOptions);
        });
    }

And, VOILA 🥳 We got customer groups selector inside our payment methods form.

image

We can finally implement the logic into the Decorator described in part 1.

// src/Resolver/PaymentMethodsResolverDecorator.php
<?php
// ...

    public function getSupportedMethods(PaymentInterface $subject): array
    {
        /** @var \App\Entity\Customer\CustomerGroup $customerGroup */
        $customerGroup = $subject->getOrder()->getCustomer()->getGroup();
        $customerGroupCode = null;
        if (null !== $customerGroup) {
            $customerGroupCode = $customerGroup->getCode();
        }

        $supportedMethods = $this->decorated->getSupportedMethods($subject);

        /** @var \App\Entity\Payment\PaymentMethod $method */
        foreach ($supportedMethods as $index => $method) {
            if (null === $method->getGatewayConfig()) {
                continue;
            }
            $config = $method->getGatewayConfig()->getConfig();
            if (!\array_key_exists('customer_group', $config) || $config['customer_group'] === []) {
                // No customer_groups setted on config
                continue;
            }

            if (!\in_array($customerGroupCode, $config['customer_group'], true)) {
                // CustomerGroup is not in configured groups for this payment method.
                // Unset it
                unset($supportedMethods[$index]);
            }
        }

        return $supportedMethods;
    }

3. Conclusion

We saw 2 methods to do the "same thing". The first part took me around 20 minutes to code. The second part took me around 4 hours.

And we get the same result (for the final customer 🙃)

You may decide to use one or other, or even a third method (share it with me if you have an other 😉).
Like usual, it depends of your need, and your Customer need.

@davidgorges

This comment has been minimized.

Copy link

@davidgorges davidgorges commented May 8, 2021

Thanks a lot Jibé! This was a great article and exactly what I was looking for!
One thing that surprises me is the compiler pass. Did you try setting a higher priority instead of changing the kernel?

@Jibbarth

This comment has been minimized.

Copy link
Owner Author

@Jibbarth Jibbarth commented May 10, 2021

Hey @davidgorges !

Thanks for the feedback, really appreciate 🤗

For the compilerpass, I think you are right. I remember that only passing the flag PassConfig::TYPE_BEFORE_OPTIMIZATION was not enough, but by re-reading docs, it's seem possible to add another priority 😅

Did you find which priority we should put here ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment