Feat/adhesion payante front (#716)

* list only user groups with enabled services in create and edit service forms

* feat: add platform membership payment (wip)

* WIP

* revert mollie api key

* remove all option for visibility in services forms

* add quit platform membership

* remove quit platform feature

* fix good route name for payment

* fix review

* fix review 2
This commit is contained in:
Sarahshr 2024-07-30 17:11:45 +02:00 committed by Hugo Nicolas
parent 32ccd2415e
commit 20251f6caf
No known key found for this signature in database
GPG key ID: 09CB3D93EB8B0E61
42 changed files with 705 additions and 100 deletions

View file

@ -47,8 +47,8 @@ security:
access_control:
- { path: ^/admin, roles: [ROLE_ADMIN, ROLE_GROUP_ADMIN] }
# to synchronize with MyAccountAction
- { path: ^/en/my-account/, roles: ROLE_USER }
- { path: ^/fr/mon-compte/, roles: ROLE_USER }
- { path: ^/en/my-account, roles: MEMBERSHIP_PAID }
- { path: ^/fr/mon-compte, roles: MEMBERSHIP_PAID }
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_ALLOWED_TO_SWITCH, ROLE_GROUP_ADMIN]

View file

@ -5,6 +5,7 @@ App\Entity\Configuration:
features (extends configuration_template):
configuration:
global:
globalName: Echanges de biens et de services
globalServicesEnabled: true
globalPaidMembership: false
notificationsSender:

View file

@ -1,11 +1,14 @@
App\Entity\PlatformOffer:
platform_offer_1:
id: <uuid('9040b3fb-8a01-4bbf-a228-ca9f90db5034')>
platform_offer_template (template):
configuration: '@features'
platform_offer_1 (extends platform_offer_template):
id: <uuid('016b2a27-1037-6d47-bcdc-ec5efbd723f2')>
name: Lorem ipsum
price: 2000
type: !php/enum App\Enum\OfferType::YEARLY
platform_offer_2:
platform_offer_2 (extends platform_offer_template):
name: Aliquet risus
price: 200
type: !php/enum App\Enum\OfferType::MONTHLY

View file

@ -42,6 +42,9 @@ App\Entity\User:
firstname: 'Kevin'
lastname: 'Pirouet'
avatar: '7c732ddb-9c13-45eb-aea0-e614f2340e6d.jpg'
membershipPaid: true
type: !php/enum App\Enum\User\UserType::ADMIN
roles: [ !php/const App\Entity\User::ROLE_ADMIN, !php/const App\Entity\User::MEMBERSHIP_PAID]
admin_apes (extends admin_template):
id: <uuid('1ed69804-eeb9-6e6c-bce0-632c3a6846ba')>

View file

@ -66,7 +66,7 @@ final class PlatformOfferCrudController extends AbstractCrudController implement
public function configureFields(string $pageName): iterable
{
$idFIeld = IdField::new('id')
$idField = IdField::new('id')
->setLabel('id')
->hideOnForm();
@ -113,7 +113,7 @@ final class PlatformOfferCrudController extends AbstractCrudController implement
$currencyField,
$panels['tech_information'],
$idFIeld,
$idField,
$updatedAtField,
$createdAtField,
];

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Controller\Payment;
namespace App\Controller\Payment\Group;
use App\Controller\FlashTrait;
use App\Controller\i18nTrait;
@ -67,6 +67,7 @@ final class DoneAction extends AbstractController
$this->addFlashSuccess($this->translator->trans($this->getI18nPrefix().'.flash.success', [
'%group%' => $groupOffer->getGroup()->getName()],
));
$request->getSession()->remove('payment_in_progress');
} else {
$this->addFlashWarning($this->translator->trans($this->getI18nPrefix().'.status.'.$status->getValue()));
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Controller\Payment;
namespace App\Controller\Payment\Group;
use App\Entity\GroupOffer;
use App\Repository\GroupOfferRepository;

View file

@ -2,14 +2,13 @@
declare(strict_types=1);
namespace App\Controller\Payment;
namespace App\Controller\Payment\Group;
use App\Controller\User\MyAccountAction;
use App\Entity\User;
use App\Payment\PayumManager;
use App\Repository\GroupOfferRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
// use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Controller\Payment\PlatformMembership;
use App\Controller\FlashTrait;
use App\Controller\i18nTrait;
use App\Doctrine\Manager\UserManager;
use App\Entity\PaymentToken;
use App\Entity\PlatformOffer;
use App\Entity\User;
use Carbon\CarbonImmutable;
use Payum\Core\Payum;
use Payum\Core\Request\GetHumanStatus;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
#[IsGranted(User::ROLE_USER)]
final class DoneAction extends AbstractController
{
use i18nTrait;
use FlashTrait;
public const ROUTE_NAME = 'app_platform_payment_done';
/**
* @see https://github.com/Payum/Payum/blob/master/docs/symfony/get-it-started.md#payment-is-done
*/
#[Route(
path: '/payment/{id}/done',
name: self::ROUTE_NAME,
requirements: ['id' => Requirement::UUID_V6],
)]
public function __invoke(Request $request, #[MapEntity(expr: 'repository.findOneActive(id)')] PlatformOffer $platformOffer, #[CurrentUser] User $user, Payum $payum, TranslatorInterface $translator, UserManager $userManager, LoggerInterface $logger): Response
{
try {
/** @var PaymentToken $token */
$token = $payum->getHttpRequestVerifier()->verify($request);
} catch (\Exception $e) {
$logger->error($e->getMessage());
throw new UnprocessableEntityHttpException('Cannot verify Payum token.');
}
$gateway = $payum->getGateway($token->getGatewayName());
$status = new GetHumanStatus($token);
$gateway->execute($status);
// Not captured
if (!$status->isCaptured()) {
$this->addFlashWarning($translator->trans($this->getI18nPrefix().'.status.'.$status->getValue()));
}
$user
->setMembershipPaid(true)
->setStartAt(CarbonImmutable::today())
->setPayedAt(CarbonImmutable::now())
;
if (($offerType = $platformOffer->getType())->isRecurring()) {
$user->setEndAt(new CarbonImmutable($offerType->getEndAtInterval()));
}
$userManager->save($user, true);
$this->addFlashSuccess($translator->trans($this->getI18nPrefix().'.flash.success', [
'%platform%' => $platformOffer->getConfiguration()?->getPlatformName()],
));
$request->getSession()->remove('payment_in_progress');
return $this->redirectToRoute('app_user_my_account');
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Payment\PlatformMembership;
use App\Entity\PlatformOffer;
use App\Entity\User;
use App\Payment\PayumManager;
use App\Repository\PlatformOfferRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted(User::ROLE_USER)]
final class PrepareAction extends AbstractController
{
public const ROUTE_NAME = 'app_platform_payment_prepare';
/**
* @see https://github.com/Payum/Payum/blob/master/docs/symfony/get-it-started.md#prepare-order)
*/
#[Route(
path: '/payment/{id}/prepare',
name: self::ROUTE_NAME,
requirements: ['id' => Requirement::UUID_V6],
methods: ['POST'],
)]
public function preparePayment(Request $request, #[MapEntity(expr: 'repository.findOneActive(id)')] PlatformOffer $platformOffer, #[CurrentUser] User $user, PayumManager $payumManager): Response
{
/** @var ?string $token */
$token = $request->request->get('token');
if (!$this->isCsrfTokenValid('payment_prepare', $token)) {
throw new UnprocessableEntityHttpException('Invalid CSRF token');
}
$request->getSession()->set('payment_in_progress', true);
// create and save the payment main reference
$payment = $payumManager->getPayment($platformOffer, $user);
// create the capture token and redirect to the capture action
$captureToken = $payumManager->getCaptureToken($payment, DoneAction::ROUTE_NAME, [
'id' => (string) $platformOffer->getId(),
]);
return $this->redirect($captureToken->getTargetUrl());
}
#[Route(path: [
'en' => '/en/subcription',
'fr' => '/fr/abonnement',
], name: 'redirect_to_payment')]
public function redirectToPayment(PlatformOfferRepository $platformOfferRepository): Response
{
$offers = $platformOfferRepository->findBy(['active' => true]);
return $this->render('pages/redirect_to_payment.html.twig', compact('offers'));
}
}

View file

@ -9,6 +9,7 @@ use App\Controller\User\MyAccountAction;
use App\Doctrine\Manager\ProductManager;
use App\Entity\Product;
use App\Entity\User;
use App\Enum\Product\ProductVisibility;
use App\Form\Type\Product\ServiceFormType;
use App\MessageBus\QueryBus;
use App\Repository\ConfigurationRepository;
@ -56,6 +57,7 @@ final class ServiceController extends AbstractController
{
if ($this->configurationRepository->getServicesParameter()) {
$product = $this->productManager->initService($user);
$product->setVisibility(ProductVisibility::RESTRICTED);
$form = $this->getForm($product, $request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var array<UploadedFile>|null $images */

View file

@ -64,7 +64,7 @@ abstract class AbstractOfferEntity implements \Stringable
#[ORM\Column(type: 'boolean', nullable: false)]
protected bool $active = true;
public function __toString()
public function __toString(): string
{
return $this->name.' ('.$this->type->value.')';
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Doctrine\Listener;
use App\Entity\User;
use App\Repository\ConfigurationRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\Routing\RouterInterface;
#[AsEventListener(event: ExceptionEvent::class, method: 'onKernelException')]
final class MembershipPaidListener
{
public function __construct(
private readonly ConfigurationRepository $configurationRepository,
private readonly Security $security,
private readonly RouterInterface $router,
) {
}
public function onKernelException(ExceptionEvent $event): void
{
/** @var User $user */
$user = $this->security->getUser();
$config = $this->configurationRepository->getInstanceConfigurationOrCreate();
$session = $event->getRequest()->getSession();
/** @var bool $isPaymentInProgress */
$isPaymentInProgress = $session->get('payment_in_progress');
if ($config->getPaidMembership() && !$user->isMembershipPaid() && !$isPaymentInProgress) {
$event->setResponse(new RedirectResponse($this->router->generate('redirect_to_payment')));
}
}
}

View file

@ -8,6 +8,8 @@ use App\Doctrine\Behavior\TimestampableEntity;
use App\Enum\ConfigurationType;
use App\Message\Command\Admin\ParametersFormCommand;
use App\Repository\ConfigurationRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@ -25,6 +27,13 @@ class Configuration
#[Assert\NotBlank]
protected ConfigurationType $type = ConfigurationType::INSTANCE;
/**
* @var Collection<int, PlatformOffer>
*/
#[ORM\OneToMany(mappedBy: 'configuration', targetEntity: PlatformOffer::class, orphanRemoval: true)]
#[ORM\OrderBy(['price' => 'ASC'])]
private Collection $offers;
/**
* Associative array to store parameters.
*
@ -33,6 +42,11 @@ class Configuration
#[ORM\Column(type: 'json')]
private array $configuration = [];
public function __construct()
{
$this->offers = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@ -76,28 +90,35 @@ class Configuration
/** end of basic getters and setters ------------------------------------------------ */
/**
* @return bool[]
* @return array<string, bool|string>
*/
public function getGlobals(): array
{
/** @var array<string, bool> $globals */
/** @var array<string, bool|string> $globals */
$globals = $this->configuration['global'] ?? [];
return $globals;
}
public function getPlatformName(): string
{
$globals = $this->getGlobals();
return (string) ($globals['globalName'] ?? '');
}
public function getServicesEnabled(): bool
{
$globals = $this->getGlobals();
return $globals['globalServicesEnabled'];
return (bool) $globals['globalServicesEnabled'];
}
public function getPaidMembership(): bool
{
$globals = $this->getGlobals();
return $globals['globalPaidMembership'];
return (bool) $globals['globalPaidMembership'];
}
/**

View file

@ -13,4 +13,23 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Index(columns: ['type'])]
class PlatformOffer extends AbstractOfferEntity
{
/**
* Related platform.
*/
#[ORM\ManyToOne(inversedBy: 'offers')]
#[ORM\OrderBy(['createdAt' => 'ASC'])]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Configuration $configuration;
public function getConfiguration(): ?Configuration
{
return $this->configuration;
}
public function setConfiguration(?Configuration $configuration): self
{
$this->configuration = $configuration;
return $this;
}
}

View file

@ -156,7 +156,7 @@ class Product implements \Stringable, ImagesInterface
*/
#[ORM\ManyToMany(targetEntity: Group::class, inversedBy: 'products')]
#[Assert\When(
expression: '!this.getVisibility().isPublic()',
expression: '!this.getVisibility().isPublic() && !this.getOwner().getUserGroupsConfirmedWithServices().isEmpty()',
constraints: [
new Assert\Count(min: 1, minMessage: 'app.entity.product.groups.constraints.count.min_message'),
],

View file

@ -51,6 +51,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageIn
final public const ROLE_USER = 'ROLE_USER';
final public const ROLE_ADMIN = 'ROLE_ADMIN';
final public const ROLE_GROUP_ADMIN = 'ROLE_GROUP_ADMIN';
final public const MEMBERSHIP_PAID = 'MEMBERSHIP_PAID';
private const EMAIL_MAX_LENGTH = 180;
private const NAME_LENGTH = 180;
@ -265,6 +266,32 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageIn
#[Assert\IsTrue(groups: [AccountCreateStep2FormType::class])]
public bool $gdpr = true;
/**
* Paid for membership of the platform.
*/
#[ORM\Column(type: 'boolean', nullable: false)]
private bool $membershipPaid = false;
/**
* Starting date of a paying membership. The starting date of a free membership
* is stored in the creation date.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
protected ?\DateTimeImmutable $startAt = null;
/**
* Ending date of the paying membership. If it only set for recurring membership.
* For one-shot payments, only the start date is filled.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
protected ?\DateTimeImmutable $endAt = null;
/**
* Date of the last payment of this membership.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
protected ?\DateTimeImmutable $payedAt = null;
/**
* Local cache to store groups (extracted from related userGroups).
*
@ -474,6 +501,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageIn
}
}
if ($this->isMembershipPaid()) {
$roles[] = self::MEMBERSHIP_PAID;
}
return array_unique($roles);
}
@ -671,6 +702,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageIn
return $collection;
}
/**
* @return Collection<int, UserGroup>
*/
public function getUserGroupsConfirmedWithServices(): Collection
{
/** @var Collection<int, UserGroup> $collection */
$collection = $this->userGroups->filter(fn (UserGroup $userGroup) => !$userGroup->getMembership()->isInvited() && $userGroup->getGroup()->getServicesEnabled());
return $collection;
}
/**
* @return array<int, string>
*/
@ -714,6 +756,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageIn
return $this;
}
public function isMembershipPaid(): bool
{
return $this->membershipPaid;
}
public function setMembershipPaid(bool $membershipPaid): self
{
$this->membershipPaid = $membershipPaid;
return $this;
}
public function getStartAt(): ?\DateTimeImmutable
{
return $this->startAt;
}
public function setStartAt(?\DateTimeImmutable $startAt): self
{
$this->startAt = $startAt;
return $this;
}
public function getEndAt(): ?\DateTimeImmutable
{
return $this->endAt;
}
public function setEndAt(?\DateTimeImmutable $endAt): self
{
$this->endAt = $endAt;
return $this;
}
public function getPayedAt(): ?\DateTimeImmutable
{
return $this->payedAt;
}
public function setPayedAt(?\DateTimeImmutable $payedAt): self
{
$this->payedAt = $payedAt;
return $this;
}
// —— end of basic 'etters —————————————————————————————————————————————————
public function promoteToAdmin(): self

View file

@ -9,7 +9,7 @@ enum OfferType: string
use AsArrayTrait;
// The user only to pay once to access the group/platform. In his case the end date is
// not set and the membership is valid until it is deleted or a end date is
// not set and the membership is valid until it is deleted or an end date is
// set. The end date can always be set manually in case of a problem.
case ONESHOT = 'oneshot';
@ -18,7 +18,7 @@ enum OfferType: string
// try a group/platform on the short period before taking a longer subscription.
case MONTHLY = 'monthly';
// Subscription valid for one year. An email will be send a few days before
// Subscription valid for one year. An email will be sent a few days before
// the end of the membership
case YEARLY = 'yearly';

View file

@ -25,6 +25,10 @@ final class ParametersFormType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('globalName', TextType::class, [
'label' => 'parameter.global_name',
])
->add('globalServicesEnabled', CheckboxType::class, [
'label' => 'parameter.services',
'label_attr' => [

View file

@ -6,18 +6,14 @@ namespace App\Form\Type\Product;
use App\Controller\i18nTrait;
use App\Entity\Category;
use App\Entity\Group;
use App\Entity\Product;
use App\Entity\User;
use App\Enum\Product\ProductType;
use App\Enum\Product\ProductVisibility;
use App\Flysystem\MediaManager;
use App\Repository\CategoryRepository;
use App\Repository\GroupRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
@ -36,7 +32,6 @@ abstract class AbstractProductFormType extends AbstractType
public function __construct(
private readonly MediaManager $mediaManager,
private readonly GroupRepository $groupRepository,
private readonly Security $security,
) {
}
@ -87,38 +82,6 @@ abstract class AbstractProductFormType extends AbstractType
'attr' => ['class' => 'btn-sm btn-primary'],
]);
// only if the user has groups
if (!$user->getUserGroupsConfirmed()->isEmpty()) {
$builder
->add('visibility', EnumType::class, [
'class' => ProductVisibility::class,
'label' => 'product.form.visibility',
'expanded' => true,
'label_attr' => [
'class' => 'radio-inline text-black fs-6 fw-normal',
],
// check if we can do simpler (@see productvisibility_controller.js)
'choice_attr' => [
0 => [
'data-productvisibility-target' => ProductVisibility::PUBLIC->value,
'data-action' => 'click->productvisibility#hideGroups',
],
1 => [
'data-productvisibility-target' => ProductVisibility::RESTRICTED->value,
'data-action' => 'click->productvisibility#showGroups',
],
],
])
->add('groups', EntityType::class, [
'class' => Group::class,
'query_builder' => $this->groupRepository->getUserGroups($user),
'label' => $i18nPrefix.'.form.groups',
'expanded' => true,
'multiple' => true,
'required' => false,
]);
}
// remove all group associations if public to avoid side effects
$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
/** @var Product $product */

View file

@ -4,8 +4,16 @@ declare(strict_types=1);
namespace App\Form\Type\Product;
use App\Entity\Group;
use App\Entity\Product;
use App\Entity\User;
use App\Enum\Product\ProductType;
use App\Enum\Product\ProductVisibility;
use App\Flysystem\MediaManager;
use App\Repository\GroupRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@ -13,10 +21,22 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
final class ObjectFormType extends AbstractProductFormType
{
public function __construct(
MediaManager $mediaManager,
private readonly GroupRepository $groupRepository,
private readonly Security $security
) {
parent::__construct($mediaManager, $security);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
parent::buildForm($builder, $options);
$i18nPrefix = $this->getI18nPrefix();
/** @var User $user */
$user = $this->security->getUser();
$builder
->add('age', TextType::class, [
'label' => 'object.form.age',
@ -34,6 +54,38 @@ final class ObjectFormType extends AbstractProductFormType
'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
'required' => false,
]);
// only if the user is connected and has groups
if (!$user->getUserGroupsConfirmed()->isEmpty()) {
$builder
->add('visibility', EnumType::class, [
'class' => ProductVisibility::class,
'label' => 'product.form.visibility',
'expanded' => true,
'label_attr' => [
'class' => 'radio-inline text-black fs-6 fw-normal',
],
// check if we can do simpler (@see productvisibility_controller.js)
'choice_attr' => [
0 => [
'data-productvisibility-target' => ProductVisibility::PUBLIC->value,
'data-action' => 'click->productvisibility#hideGroups',
],
1 => [
'data-productvisibility-target' => ProductVisibility::RESTRICTED->value,
'data-action' => 'click->productvisibility#showGroups',
],
],
])
->add('groups', EntityType::class, [
'class' => Group::class,
'query_builder' => $this->groupRepository->getUserGroups($user),
'label' => $i18nPrefix.'.form.groups',
'expanded' => true,
'multiple' => true,
'required' => false,
]);
}
}
public function getType(): ProductType

View file

@ -4,24 +4,73 @@ declare(strict_types=1);
namespace App\Form\Type\Product;
use App\Entity\Group;
use App\Entity\Product;
use App\Entity\User;
use App\Enum\Product\ProductType;
use App\Enum\Product\ProductVisibility;
use App\Flysystem\MediaManager;
use App\Repository\GroupRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ServiceFormType extends AbstractProductFormType
{
public function __construct(
MediaManager $mediaManager,
private readonly GroupRepository $groupRepository,
private readonly Security $security
) {
parent::__construct($mediaManager, $security);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
parent::buildForm($builder, $options);
$i18nPrefix = $this->getI18nPrefix();
/** @var User $user */
$user = $this->security->getUser();
$builder
->add('duration', TextType::class, [
'label' => 'new_service.form.serviceDuration',
'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
'required' => false,
]);
// only if the user is connected and has groups
if (!$user->getUserGroupsConfirmed()->isEmpty()) {
$groups = $this->groupRepository->getUserGroupsWithEnabledServices($user);
$builder
->add('visibility', ChoiceType::class, [
'label' => 'product.form.visibility',
'expanded' => true,
'label_attr' => [
'class' => 'radio-inline text-black fs-6 fw-normal',
],
'choices' => [
0 => [
'product.service.form.visibility' => ProductVisibility::RESTRICTED,
],
],
'multiple' => false,
'data' => ProductVisibility::RESTRICTED,
'disabled' => true,
])
->add('groups', EntityType::class, [
'class' => Group::class,
'query_builder' => $groups,
'label' => [] === $groups->getQuery()->getResult() ? $i18nPrefix.'.form.no_groups' : $i18nPrefix.'.form.groups',
'expanded' => true,
'multiple' => true,
'required' => false,
]);
}
}
public function getType(): ProductType

View file

@ -18,6 +18,9 @@ final class ParametersFormCommand extends AbstractFormCommand
final public const ALL = 'all';
// global section —————————————————————————————————————————————
#[Assert\NotBlank()]
public ?string $globalName = null;
#[Assert\Type('bool')]
public bool $globalServicesEnabled = true;
@ -79,7 +82,6 @@ final class ParametersFormCommand extends AbstractFormCommand
public function hydrate(Configuration $configuration): self
{
$instanceConfiguration = $configuration->getConfiguration();
// dd($instanceConfiguration);
foreach (array_keys(get_class_vars($this::class)) as $classVar) {
$this->{$classVar} = $instanceConfiguration[$this->getSection($classVar)][$classVar]; // @phpstan-ignore-line
}

View file

@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Payment;
use App\Controller\Payment\PrepareAction;
use App\Controller\Payment\Group\PrepareAction;
use App\Entity\GroupOffer;
use App\Entity\Payment;
use App\Entity\PlatformOffer;
use App\Entity\User;
use App\Enum\Payment\PaymentMethod;
use Payum\Core\Payum;
@ -29,20 +30,27 @@ final class PayumManager
*
* @see PrepareAction
*/
public function getPayment(GroupOffer $groupOffer, User $user): Payment
public function getPayment(GroupOffer|PlatformOffer $offer, User $user): Payment
{
$storage = $this->payum->getStorage(Payment::class);
/** @var Payment $payment */
$payment = $storage->create();
$payment->setNumber(uniqid('payum_', true));
$payment->setCurrencyCode($groupOffer->getCurrency());
$payment->setTotalAmount($groupOffer->getPrice());
$payment->setDescription($groupOffer->getGroup()->getName().' / '.$groupOffer->getName());
$payment->setCurrencyCode($offer->getCurrency());
$payment->setTotalAmount($offer->getPrice());
if ($offer instanceof GroupOffer) {
$payment->setDescription($offer->getGroup()->getName().' / '.$offer->getName());
}
if ($offer instanceof PlatformOffer) {
$payment->setDescription($offer->getConfiguration()?->getPlatformName().' / '.$offer->getName());
}
$payment->setClientId((string) $user->getId());
$payment->setClientEmail($user->getEmail());
$payment->setUser($user);
$payment->setDetails($this->getGatewayDetails($groupOffer));
$payment->setDetails($this->getGatewayDetails($offer));
$storage->update($payment);
return $payment;
@ -61,16 +69,27 @@ final class PayumManager
*
* @return array<string, mixed>
*/
private function getGatewayDetails(GroupOffer $groupOffer): array
private function getGatewayDetails(GroupOffer|PlatformOffer $offer): array
{
if ($offer instanceof GroupOffer) {
return [
// method must be set as the default value is not retrieved from the gateway configuration
'method' => PaymentMethod::CREDITCARD->value,
'metadata' => [
'groupId' => (string) $groupOffer->getGroup()->getId(),
'groupOfferId' => (string) $groupOffer->getId(),
'groupId' => (string) $offer->getGroup()->getId(),
'groupOfferId' => (string) $offer->getId(),
],
];
} else {
return [
// method must be set as the default value is not retrieved from the gateway configuration
'method' => PaymentMethod::CREDITCARD->value,
'metadata' => [
'platformId' => (string) $offer->getConfiguration()?->getId(),
'platformOfferId' => (string) $offer->getId(),
],
];
}
}
/**

View file

@ -80,13 +80,11 @@ final class GroupRepository extends ServiceEntityRepository
return $qb->orderBy('g.name', 'ASC')->getQuery();
}
public function getUserGroups(User $user): QueryBuilder
public function getUserGroupsWithEnabledServices(User $user): QueryBuilder
{
return $this->createQueryBuilder('g')
->from(UserGroup::class, 'ug')
->andWhere('g = ug.group')
->andWhere('ug.user = :user')
->setParameter('user', $user);
return $this->getUserGroups($user)
->andWhere('g.servicesEnabled = :enabled')
->setParameter('enabled', true);
}
/**
@ -135,4 +133,13 @@ final class GroupRepository extends ServiceEntityRepository
}
$this->getEntityManager()->flush();
}
public function getUserGroups(User $user): QueryBuilder
{
return $this->createQueryBuilder('g')
->from(UserGroup::class, 'ug')
->andWhere('g = ug.group')
->andWhere('ug.user = :user')
->setParameter('user', $user);
}
}

View file

@ -32,4 +32,9 @@ class PlatformOfferRepository extends ServiceEntityRepository
{
return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('Platform offer not found.');
}
public function findOneActive(string $id): ?PlatformOffer
{
return $this->findOneBy(['id' => $id, 'active' => true]);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\User;
use App\Repository\ConfigurationRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class MembershipPaidVoter extends Voter
{
public function __construct(
private readonly ConfigurationRepository $configurationRepository,
) {
}
protected function supports(string $attribute, mixed $subject): bool
{
return User::MEMBERSHIP_PAID === $attribute;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return !$this->configurationRepository->getInstanceConfigurationOrCreate()->getPaidMembership() || $user->isMembershipPaid();
}
}

View file

@ -17,6 +17,8 @@
<h2 class="h3 fw-bold mt-3">{{ 'parameters.global.h2'|trans }}</h2>
<hr/>
{{ form_row(form.globalName) }}
<h2 class="h5 fw-bold my-3">{{ 'parameters.services.h3'|trans }}</h2>
{{ form_widget(form.globalServicesEnabled) }}

View file

@ -27,7 +27,7 @@
</span>
</div>
<div class="col col-lg-5 d-flex align-items-center justify-content-center mt-3 mt-lg-0">
<form action="{{ path('app_payment_prepare', {id: offer.id}) }}" method="post">
<form action="{{ path(prepare_path, {id: offer.id}) }}" method="post">
<input type="hidden" name="token" value="{{ csrf_token('payment_prepare') }}"/>
<input class="btn btn-primary" type="submit"
value="{{ (i18n_prefix ~ '.payment_prepare.form.submit')|trans }}">

View file

@ -5,7 +5,8 @@
{% if not group.activeOffers.isEmpty %}
{% include 'components/group/_first_offer.html.twig' with {group} %}
{% include 'components/group/_modal_offers.html.twig' with {
offers: group.activeOffers
offers: group.activeOffers,
prepare_path: 'app_payment_prepare'
} %}
{# 2.1.2 otherwise it is a free group #}

View file

@ -20,7 +20,8 @@
{% include 'components/group/_first_offer.html.twig' with {group} %}
{% include 'components/group/_modal_offers.html.twig' with {
offers: group.activeOffers
offers: group.activeOffers,
prepare_path: 'app_payment_prepare'
} %}
{# 1.3 group is not correclty configured, it doesn't have any offer configured yet. #}

View file

@ -0,0 +1,30 @@
{% extends 'layout/base.html.twig' %}
{% set i18n_prefix = _self|i18n_prefix %}
{% block body %}
<div class="">
{% include 'components/layout/_title_3.html.twig' with {
name: (i18n_prefix ~ '.title')|trans
} %}
{% include 'components/layout/_text.html.twig' with {
text: (i18n_prefix ~ '.text')|trans
} %}
<div class="d-grid col col-lg-4 mx-auto">
<button
type="button"
class="btn btn-primary my-2 mb-4"
data-bs-toggle="modal"
data-bs-target="#offersModal"
>
<i class="bi bi-currency-euro"></i>
{{ (i18n_prefix ~ '.button')|trans }}
</button>
</div>
{% include 'components/group/_modal_offers.html.twig' with {
offers: offers,
prepare_path: 'app_platform_payment_prepare'
} %}
</div>
{% endblock %}

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Functional\Controller\Payment;
use App\Controller\Payment\PrepareAction;
use App\Controller\Payment\Group\PrepareAction;
use App\Test\KernelTrait;
use App\Tests\TestReference;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;

View file

@ -35,8 +35,6 @@ class ServiceControllerTest extends WebTestCase
$form->getName().'[name]' => 'jardinage',
$form->getName().'[description]' => 'description',
$form->getName().'[duration]' => '1 jour',
$form->getName().'[visibility]' => 'restricted',
$form->getName().'[groups]' => [TestReference::GROUP_1],
]);
$container = $client->getContainer();
@ -49,7 +47,6 @@ class ServiceControllerTest extends WebTestCase
self::assertSame('jardinage', $editedService->getName());
self::assertSame('description', $editedService->getDescription());
self::assertSame('1 jour', $editedService->getDuration());
self::assertSame('restricted', $editedService->getVisibility()->value);
self::assertResponseRedirects();
$client->followRedirect();

View file

@ -129,5 +129,5 @@ final class TestReference
final public const PAYMENT_USER_16_1 = '1edcefc9-45b3-6a3e-b4a6-db137f56da56';
// platform offer
final public const PLATFORM_OFFER_1 = '9040b3fb-8a01-4bbf-a228-ca9f90db5034';
final public const PLATFORM_OFFER_1 = '016b2a27-1037-6d47-bcdc-ec5efbd723f2';
}

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Unit\Controller\Payment;
use App\Controller\Payment\DoneAction;
use App\Controller\Payment\Group\DoneAction;
use App\Entity\Group;
use App\Entity\GroupOffer;
use App\Entity\PaymentToken;
@ -103,6 +103,7 @@ final class DoneActionTest extends TestCase
);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Cannot verify Payum token');
$doneAction->__invoke(new Request(), TestReference::UUID_404, $this->getUser());
}
@ -153,7 +154,8 @@ final class DoneActionTest extends TestCase
$container->method('get')->willReturn($requesStack);
$doneAction->setContainer($container);
$this->expectException(\Error::class); // or more mock are needed. To cleanup later
$this->expectException(\Error::class); // or more mock are needed. To clean up later
$doneAction->__invoke(new Request(), TestReference::UUID_404, $this->getUser());
}
}

View file

@ -5,28 +5,28 @@
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="HVbbDui" resname="app.controller.payment.done_action.flash.success">
<source>app.controller.payment.done_action.flash.success</source>
<trans-unit id="HVbbDui" resname="app.controller.payment.group.done_action.flash.success">
<source>app.controller.payment.group.done_action.flash.success</source>
<target>Paiement réussi. Vous êtes désormais membre du groupe %group%.</target>
</trans-unit>
<trans-unit id="ipYvZb8" resname="app.controller.payment.done_action.status.new">
<source>app.controller.payment.done_action.status.new</source>
<trans-unit id="ipYvZb8" resname="app.controller.payment.group.done_action.status.new">
<source>app.controller.payment.group.done_action.status.new</source>
<target>Le paiement est en cours mais n'a pas encore abouti.</target>
</trans-unit>
<trans-unit id="K3ZL3UV" resname="app.controller.payment.done_action.status.failed">
<source>app.controller.payment.done_action.status.failed</source>
<trans-unit id="K3ZL3UV" resname="app.controller.payment.group.done_action.status.failed">
<source>app.controller.payment.group.done_action.status.failed</source>
<target>Le paiement a échoué, vous n'avez pas été prélevé·e.</target>
</trans-unit>
<trans-unit id="AVy18N2" resname="app.controller.payment.done_action.status.canceled">
<source>app.controller.payment.done_action.status.canceled</source>
<trans-unit id="AVy18N2" resname="app.controller.payment.group.done_action.status.canceled">
<source>app.controller.payment.group.done_action.status.canceled</source>
<target>Le paiement a été annulé, vous n'avez pas été prélevé·e.</target>
</trans-unit>
<trans-unit id="D8DSDe6" resname="app.controller.payment.done_action.status.expired">
<source>app.controller.payment.done_action.status.expired</source>
<trans-unit id="D8DSDe6" resname="app.controller.payment.group.done_action.status.expired">
<source>app.controller.payment.group.done_action.status.expired</source>
<target>Le paiement a expiré, veuillez réessayer.</target>
</trans-unit>
</body>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="HkHjuo7" resname="app.controller.payment.platform_membership.done_action.flash.success">
<source>app.controller.payment.platform_membership.done_action.flash.success</source>
<target>Paiement réussi. Vous êtes désormais membre de la plateforme %platform%.</target>
</trans-unit>
<trans-unit id="iikpmn8" resname="app.controller.payment.platform_membership.done_action.status.new">
<source>app.controller.payment.platform_membership.done_action.status.new</source>
<target>Le paiement est en cours mais n'a pas encore abouti.</target>
</trans-unit>
<trans-unit id="K3Zg5gn" resname="app.controller.payment.platform_membership.done_action.status.failed">
<source>app.controller.payment.platform_membership.done_action.status.failed</source>
<target>Le paiement a échoué, vous n'avez pas été prélevé·e.</target>
</trans-unit>
<trans-unit id="AVy1f5d" resname="app.controller.payment.platform_membership.done_action.status.canceled">
<source>app.controller.payment.platform_membership.done_action.status.canceled</source>
<target>Le paiement a été annulé, vous n'avez pas été prélevé·e.</target>
</trans-unit>
<trans-unit id="D8fvt2w" resname="app.controller.payment.platform_membership.done_action.status.expired">
<source>app.controller.payment.platform_membership.done_action.status.expired</source>
<target>Le paiement a expiré, veuillez réessayer.</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -5,11 +5,15 @@
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="" resname="app.form.type.product.service_form_type.form.groups">
<trans-unit id="pLw9zsf" resname="app.form.type.product.service_form_type.form.groups">
<source>app.form.type.product.service_form_type.form.groups</source>
<target>Visible dans mes groupes :</target>
</trans-unit>
<trans-unit id="bMv5dw8" resname="app.form.type.product.service_form_type.form.no_groups">
<source>app.form.type.product.service_form_type.form.no_groups</source>
<target>Vous n'avez aucun groupe avec la disponibilité des services activée</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -7,6 +7,11 @@
<body>
<!-- global section -->
<trans-unit id="qlPklB7" resname="parameter.global_name">
<source>parameter.global_name</source>
<target>Nom de la plateforme</target>
</trans-unit>
<trans-unit id="qlPbwB3" resname="parameter.services">
<source>parameter.services</source>
<target>Services activés</target>

View file

@ -105,6 +105,11 @@
<target>Uniquement mes groupes</target>
</trans-unit>
<trans-unit id="BaSmLK9" resname="product.service.form.visibility">
<source>product.service.form.visibility</source>
<target>Uniquement mes groupes</target>
</trans-unit>
<!-- product forms -->
<trans-unit id="X4i3dq6" resname="new_product.available">
<source>new_product.available</source>

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="Xwpl7p9" resname="templates.pages.redirect_to_payment.type.yearly">
<source>templates.pages.redirect_to_payment.type.yearly</source>
<target>Adhésion annuel</target>
</trans-unit>
<trans-unit id="HJFwmkE" resname="templates.pages.redirect_to_payment.type.monthly">
<source>templates.pages.redirect_to_payment.type.monthly</source>
<target>Adhésion mensuel</target>
</trans-unit>
<trans-unit id="Pwx2GJU" resname="templates.pages.redirect_to_payment.type.oneshot">
<source>templates.pages.redirect_to_payment.type.oneshot</source>
<target>Unique</target>
</trans-unit>
<trans-unit id="v4xDplQ" resname="templates.pages.redirect_to_payment.choose_offer">
<source>templates.pages.redirect_to_payment.choose_offer</source>
<target>Rejoindre la plateforme</target>
</trans-unit>
<trans-unit id="DMvoLvx" resname="templates.pages.redirect_to_payment.close">
<source>templates.pages.redirect_to_payment.close</source>
<target>Annuler</target>
</trans-unit>
<trans-unit id="1w4Jeyy" resname="templates.pages.redirect_to_payment.membership">
<source>templates.pages.redirect_to_payment.membership</source>
<target>Adhésion: </target>
</trans-unit>
<trans-unit id="Ee6vfs4" resname="templates.pages.redirect_to_payment.membership.start">
<source>templates.pages.redirect_to_payment.membership.start</source>
<target>À partir de </target>
</trans-unit>
<trans-unit id="QwsvcUp" resname="templates.pages.redirect_to_payment.payment_prepare.form.submit">
<source>templates.pages.redirect_to_payment.payment_prepare.form.submit</source>
<target>Accéder au paiement</target>
</trans-unit>
<trans-unit id="QsIj9kp" resname="templates.pages.redirect_to_payment.title">
<source>templates.pages.redirect_payment.title</source>
<target>Bienvenue</target>
</trans-unit>
<trans-unit id="wSc8oud" resname="templates.pages.redirect_to_payment.text">
<source>templates.pages.redirect_to_payment.text</source>
<target>Pour avoir accès aux prêts/emprunts sur notre plateforme, il faut être adhérent.</target>
</trans-unit>
<trans-unit id="wSc6sFt" resname="templates.pages.redirect_to_payment.button">
<source>templates.pages.redirect_to_payment.button</source>
<target>Payer mon adhésion</target>
</trans-unit>
</body>
</file>
</xliff>