Skip to content

Commit

Permalink
We are getting there. Missing the callback action
Browse files Browse the repository at this point in the history
  • Loading branch information
loevgaard committed Sep 26, 2024
1 parent 38611d4 commit 90d5048
Show file tree
Hide file tree
Showing 31 changed files with 653 additions and 27 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@
[![Code Coverage][ico-code-coverage]][link-code-coverage]
[![Mutation testing][ico-infection]][link-infection]

A plugin to add age verification to your Sylius store. Right now the plugin only works with [Criipto](https://www.criipto.com/) as the age verification provider.

## Installation

```bash
composer require setono/sylius-age-verification-plugin
```

Add plugin to your `config/bundles.php` file:

```php
return [
// ...
Setono\SyliusAgeVerificationPlugin\SetonoSyliusAgeVerificationPlugin::class => ['all' => true],
// ...
];
```

## Extend entities

### Extend `Customer` entity

### Extend `Product` entity



[ico-version]: https://poser.pugx.org/setono/sylius-age-verification-plugin/v/stable
[ico-license]: https://poser.pugx.org/setono/sylius-age-verification-plugin/license
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/event-dispatcher": "^6.4 || ^7.0",
"symfony/form": "^6.4 || ^7.0",
"symfony/http-client": "^6.4 || ^7.0",
"symfony/http-kernel": "^6.4 || ^7.0"
},
"require-dev": {
Expand Down
47 changes: 47 additions & 0 deletions src/Checker/MinimumAgeChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Checker;

use Setono\SyliusAgeVerificationPlugin\Model\AgeAwareProductInterface;
use Setono\SyliusAgeVerificationPlugin\Model\MinimumAge;
use Sylius\Component\Core\Model\OrderInterface;

final class MinimumAgeChecker implements MinimumAgeCheckerInterface
{
public function __construct(
/** @var list<string> $enabledCountries */
private readonly array $enabledCountries,
) {
}

public function check(OrderInterface $order): ?MinimumAge
{
$countryCode = $order->getShippingAddress()?->getCountryCode();
if (null === $countryCode || !in_array($countryCode, $this->enabledCountries, true)) {
return null;
}

$minimumAge = null;

foreach ($order->getItems() as $item) {
$product = $item->getProduct();
if (!$product instanceof AgeAwareProductInterface) {
continue;
}

$productMinimumAge = $product->getMinimumAge();

if (null === $productMinimumAge) {
continue;
}

if (null === $minimumAge || $productMinimumAge > $minimumAge->value) {
$minimumAge = MinimumAge::from($productMinimumAge);
}
}

return $minimumAge;
}
}
16 changes: 16 additions & 0 deletions src/Checker/MinimumAgeCheckerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Checker;

use Setono\SyliusAgeVerificationPlugin\Model\MinimumAge;
use Sylius\Component\Core\Model\OrderInterface;

interface MinimumAgeCheckerInterface
{
/**
* Returns null if the order is not age restricted else returns the minimum age required
*/
public function check(OrderInterface $order): ?MinimumAge;
}
43 changes: 43 additions & 0 deletions src/Controller/CriiptoCallbackAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Controller;

use Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration\OpenIdConfiguration;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

final class CriiptoCallbackAction
{
public function __construct(
private readonly OpenIdConfiguration $openIdConfiguration,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly string $clientId,
private readonly string $clientSecret,
) {
}

public function __invoke(Request $request): RedirectResponse
{
$client = HttpClient::create();
$response = $client->request('POST', $this->openIdConfiguration->tokenEndpoint, [
'headers' => [
'Authorization' => 'Basic ' . base64_encode(sprintf('%s:%s', urlencode($this->clientId), urlencode($this->clientSecret))),
],
'body' => [
'grant_type' => 'authorization_code',
'code' => $request->query->get('code'),
'client_id' => $this->clientId,
'redirect_uri' => $this->urlGenerator->generate(
name: 'setono_sylius_age_verification_shop_criipto_callback',
referenceType: UrlGeneratorInterface::ABSOLUTE_URL,
),
],
]);

dd($response->getContent(false));

Check failure on line 41 in src/Controller/CriiptoCallbackAction.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: lowest | SF~6.4.0)

ForbiddenCode

src/Controller/CriiptoCallbackAction.php:41:9: ForbiddenCode: You have forbidden the use of dd (see https://psalm.dev/002)

Check failure on line 41 in src/Controller/CriiptoCallbackAction.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: lowest | SF~6.4.0)

ForbiddenCode

src/Controller/CriiptoCallbackAction.php:41:9: ForbiddenCode: You have forbidden the use of dd (see https://psalm.dev/002)

Check failure on line 41 in src/Controller/CriiptoCallbackAction.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.2 | Deps: highest | SF~6.4.0)

ForbiddenCode

src/Controller/CriiptoCallbackAction.php:41:9: ForbiddenCode: You have forbidden the use of dd (see https://psalm.dev/002)
}
}
25 changes: 25 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ public function getConfigTreeBuilder(): TreeBuilder
/** @var ArrayNodeDefinition $rootNode */
$rootNode = $treeBuilder->getRootNode();

/** @psalm-suppress MixedMethodCall,UndefinedInterfaceMethod,PossiblyNullReference */
$rootNode
->addDefaultsIfNotSet()
->children()
->arrayNode('enabled_countries')
->isRequired()
->requiresAtLeastOneElement()
->scalarPrototype()->end()
->end()
->arrayNode('criipto')
->addDefaultsIfNotSet()
->children()
->scalarNode('client_id')
->cannotBeEmpty()
->defaultValue('%env(CRIIPTO_CLIENT_ID)%')
->end()
->scalarNode('client_secret')
->cannotBeEmpty()
->defaultValue('%env(CRIIPTO_CLIENT_SECRET)%')
->end()
->scalarNode('verify_domain')
->cannotBeEmpty()
->defaultValue('%env(CRIIPTO_VERIFY_DOMAIN)%')
;

return $treeBuilder;
}
}
29 changes: 27 additions & 2 deletions src/DependencyInjection/SetonoSyliusAgeVerificationExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,41 @@
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;

final class SetonoSyliusAgeVerificationExtension extends Extension
final class SetonoSyliusAgeVerificationExtension extends Extension implements PrependExtensionInterface
{
public function load(array $configs, ContainerBuilder $container): void
{
/** @psalm-suppress PossiblyNullArgument */
/**
* @psalm-suppress PossiblyNullArgument
*
* @var array{enabled_countries: list<string>, criipto: array{client_id: string, client_secret: string, verify_domain: string}} $config
*/
$config = $this->processConfiguration($this->getConfiguration([], $container), $configs);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));

$container->setParameter('setono_sylius_age_verification.enabled_countries', $config['enabled_countries']);
$container->setParameter('setono_sylius_age_verification.criipto.client_id', $config['criipto']['client_id']);
$container->setParameter('setono_sylius_age_verification.criipto.client_secret', $config['criipto']['client_secret']);
$container->setParameter('setono_sylius_age_verification.criipto.verify_domain', $config['criipto']['verify_domain']);

$loader->load('services.xml');
}

public function prepend(ContainerBuilder $container): void
{
$container->prependExtensionConfig('sylius_ui', [
'events' => [
'sylius.shop.checkout.complete.before_navigation' => [
'blocks' => [
'setono_sylius_age_verification__age_verification' => [
'template' => '@SetonoSyliusAgeVerificationPlugin/shop/_age_verification.html.twig',
],
],
],
],
]);
}
}
8 changes: 6 additions & 2 deletions src/Form/Extension/ProductTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@

namespace Setono\SyliusAgeVerificationPlugin\Form\Extension;

use Setono\SyliusAgeVerificationPlugin\Model\MinimumAge;
use Sylius\Bundle\ProductBundle\Form\Type\ProductType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;

final class ProductTypeExtension extends AbstractTypeExtension
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('minimumAge', IntegerType::class, [
$cases = array_map(static fn (MinimumAge $minimumAge) => $minimumAge->value, MinimumAge::cases());

$builder->add('minimumAge', ChoiceType::class, [
'choices' => array_combine($cases, $cases),
'label' => 'setono_sylius_age_verification.form.product.minimum_age',
'help' => 'setono_sylius_age_verification.form.product.minimum_age_help',
'required' => false,
Expand Down
14 changes: 14 additions & 0 deletions src/Model/AgeAwareCustomerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Model;

interface AgeAwareCustomerInterface
{
public function isOver(int $age): bool;

public function setIsOverAgeCheckedAt(\DateTimeInterface $ageCheckedAt): void;

public function setIsOver(int $age): void;
}
45 changes: 45 additions & 0 deletions src/Model/AgeAwareCustomerTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Model;

use Doctrine\ORM\Mapping as ORM;

trait AgeAwareCustomerTrait
{
/** @ORM\Column(type="datetime", nullable=true) */
#[ORM\Column(type: 'datetime', nullable: true)]
protected ?\DateTimeInterface $isOverAgeCheckedAt = null;

/** @ORM\Column(type="integer", nullable=true, options={"unsigned"=true}) */
#[ORM\Column(type: 'integer', nullable: true, options: ['unsigned' => true])]
protected ?int $isOver = null;

// todo test this
public function isOver(int $age): bool
{
if ($this->isOverAgeCheckedAt === null || $this->isOver === null) {
return false;
}

if ($age <= $this->isOver) {
return true;
}

$now = new \DateTimeImmutable();
$diff = $now->diff($this->isOverAgeCheckedAt);

return $age <= ($this->isOver + $diff->y);
}

public function setIsOverAgeCheckedAt(?\DateTimeInterface $isOverAgeCheckedAt): void
{
$this->isOverAgeCheckedAt = $isOverAgeCheckedAt;
}

public function setIsOver(?int $age): void
{
$this->isOver = $age;
}
}
18 changes: 18 additions & 0 deletions src/Model/MinimumAge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\Model;

enum MinimumAge: int
{
case MINIMUM_15 = 15;
case MINIMUM_16 = 16;
case MINIMUM_18 = 18;
case MINIMUM_21 = 21;

public function asScope(): string
{
return 'is_over_' . $this->value;
}
}
17 changes: 17 additions & 0 deletions src/OpenIdConfiguration/OpenIdConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration;

/**
* @final
*
* This is non-final because we define it as a lazy service
*/
class OpenIdConfiguration
{
public function __construct(public readonly string $authorizationEndpoint, public readonly string $tokenEndpoint)
{
}
}
37 changes: 37 additions & 0 deletions src/OpenIdConfiguration/OpenIdConfigurationFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration;

use Symfony\Component\HttpClient\HttpClient;
use Webmozart\Assert\Assert;

final class OpenIdConfigurationFactory implements OpenIdConfigurationFactoryInterface
{
public function __construct(private readonly string $verifyDomain)
{
}

public function create(): OpenIdConfiguration
{
$client = HttpClient::create();
$openIdConfiguration = $client
->request('GET', sprintf('https://%s/.well-known/openid-configuration', $this->verifyDomain))
->toArray()
;

Assert::keyExists($openIdConfiguration, 'token_endpoint');
Assert::keyExists($openIdConfiguration, 'authorization_endpoint');

/** @var mixed $tokenEndpoint */
$tokenEndpoint = $openIdConfiguration['token_endpoint'];
Assert::stringNotEmpty($tokenEndpoint);

/** @var mixed $authorizationEndpoint */
$authorizationEndpoint = $openIdConfiguration['authorization_endpoint'];
Assert::stringNotEmpty($authorizationEndpoint);

return new OpenIdConfiguration($authorizationEndpoint, $tokenEndpoint);
}
}
10 changes: 10 additions & 0 deletions src/OpenIdConfiguration/OpenIdConfigurationFactoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusAgeVerificationPlugin\OpenIdConfiguration;

interface OpenIdConfigurationFactoryInterface
{
public function create(): OpenIdConfiguration;
}
Loading

0 comments on commit 90d5048

Please sign in to comment.