From 20251f6caff774a6b72dc8739926b625aaa40224 Mon Sep 17 00:00:00 2001 From: Sarahshr <51380592+Sarahshr@users.noreply.github.com> Date: Tue, 30 Jul 2024 17:11:45 +0200 Subject: [PATCH] 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 --- config/packages/security.yaml | 4 +- fixtures/test/configuration.yaml | 1 + fixtures/test/platform_offer.yaml | 9 +- fixtures/test/user.yaml | 3 + .../Admin/PlatformOfferCrudController.php | 4 +- .../Payment/{ => Group}/DoneAction.php | 3 +- .../Payment/{ => Group}/GroupOfferTrait.php | 2 +- .../Payment/{ => Group}/PrepareAction.php | 3 +- .../Payment/PlatformMembership/DoneAction.php | 81 +++++++++++++++++ .../PlatformMembership/PrepareAction.php | 66 ++++++++++++++ .../User/Product/ServiceController.php | 2 + src/Doctrine/Behavior/AbstractOfferEntity.php | 2 +- .../Listener/MembershipPaidListener.php | 38 ++++++++ src/Entity/Configuration.php | 29 +++++- src/Entity/PlatformOffer.php | 19 ++++ src/Entity/Product.php | 2 +- src/Entity/User.php | 90 +++++++++++++++++++ src/Enum/OfferType.php | 4 +- src/Form/Type/Admin/ParametersFormType.php | 4 + .../Type/Product/AbstractProductFormType.php | 37 -------- src/Form/Type/Product/ObjectFormType.php | 52 +++++++++++ src/Form/Type/Product/ServiceFormType.php | 49 ++++++++++ .../Command/Admin/ParametersFormCommand.php | 4 +- src/Payment/PayumManager.php | 51 +++++++---- src/Repository/GroupRepository.php | 19 ++-- src/Repository/PlatformOfferRepository.php | 5 ++ src/Security/Voter/MembershipPaidVoter.php | 34 +++++++ templates/admin/parameters.html.twig | 2 + .../components/group/_modal_offers.html.twig | 2 +- .../group/show/_logged_with_link.html.twig | 3 +- .../group/show/_logged_without_link.html.twig | 3 +- templates/pages/redirect_to_payment.html.twig | 30 +++++++ .../Controller/Payment/PrepareActionTest.php | 2 +- .../Product/ServiceControllerTest.php | 3 - tests/TestReference.php | 2 +- .../Controller/Payment/DoneActionTest.php | 6 +- .../{ => Group}/DoneAction/messages.fr.xlf | 20 ++--- .../PlatformMembership/messages.fr.xlf | 34 +++++++ .../Product/ServiceFormType/messages.fr.xlf | 6 +- translations/parameters/admin.fr.xlf | 5 ++ translations/product/messages.fr.xlf | 5 ++ translations/templates/pages/messages.fr.xlf | 65 ++++++++++++++ 42 files changed, 705 insertions(+), 100 deletions(-) rename src/Controller/Payment/{ => Group}/DoneAction.php (96%) rename src/Controller/Payment/{ => Group}/GroupOfferTrait.php (93%) rename src/Controller/Payment/{ => Group}/PrepareAction.php (95%) create mode 100755 src/Controller/Payment/PlatformMembership/DoneAction.php create mode 100644 src/Controller/Payment/PlatformMembership/PrepareAction.php create mode 100644 src/Doctrine/Listener/MembershipPaidListener.php create mode 100644 src/Security/Voter/MembershipPaidVoter.php create mode 100644 templates/pages/redirect_to_payment.html.twig rename translations/app/Controller/Payment/{ => Group}/DoneAction/messages.fr.xlf (66%) create mode 100644 translations/app/Controller/Payment/PlatformMembership/messages.fr.xlf create mode 100644 translations/templates/pages/messages.fr.xlf diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b75cfbc..dc04dba 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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] diff --git a/fixtures/test/configuration.yaml b/fixtures/test/configuration.yaml index 97062e6..6200b9b 100644 --- a/fixtures/test/configuration.yaml +++ b/fixtures/test/configuration.yaml @@ -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: diff --git a/fixtures/test/platform_offer.yaml b/fixtures/test/platform_offer.yaml index 9a1d756..d37d126 100644 --- a/fixtures/test/platform_offer.yaml +++ b/fixtures/test/platform_offer.yaml @@ -1,11 +1,14 @@ App\Entity\PlatformOffer: - platform_offer_1: - id: + platform_offer_template (template): + configuration: '@features' + + platform_offer_1 (extends platform_offer_template): + id: 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 diff --git a/fixtures/test/user.yaml b/fixtures/test/user.yaml index 5fc4cc2..06c9a3d 100644 --- a/fixtures/test/user.yaml +++ b/fixtures/test/user.yaml @@ -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: diff --git a/src/Controller/Admin/PlatformOfferCrudController.php b/src/Controller/Admin/PlatformOfferCrudController.php index 0c49a27..5a599b5 100644 --- a/src/Controller/Admin/PlatformOfferCrudController.php +++ b/src/Controller/Admin/PlatformOfferCrudController.php @@ -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, ]; diff --git a/src/Controller/Payment/DoneAction.php b/src/Controller/Payment/Group/DoneAction.php similarity index 96% rename from src/Controller/Payment/DoneAction.php rename to src/Controller/Payment/Group/DoneAction.php index 13e2d5d..91dfeeb 100755 --- a/src/Controller/Payment/DoneAction.php +++ b/src/Controller/Payment/Group/DoneAction.php @@ -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())); } diff --git a/src/Controller/Payment/GroupOfferTrait.php b/src/Controller/Payment/Group/GroupOfferTrait.php similarity index 93% rename from src/Controller/Payment/GroupOfferTrait.php rename to src/Controller/Payment/Group/GroupOfferTrait.php index c8b623b..5bf260e 100644 --- a/src/Controller/Payment/GroupOfferTrait.php +++ b/src/Controller/Payment/Group/GroupOfferTrait.php @@ -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; diff --git a/src/Controller/Payment/PrepareAction.php b/src/Controller/Payment/Group/PrepareAction.php similarity index 95% rename from src/Controller/Payment/PrepareAction.php rename to src/Controller/Payment/Group/PrepareAction.php index af5e4bc..f72f83b 100755 --- a/src/Controller/Payment/PrepareAction.php +++ b/src/Controller/Payment/Group/PrepareAction.php @@ -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; diff --git a/src/Controller/Payment/PlatformMembership/DoneAction.php b/src/Controller/Payment/PlatformMembership/DoneAction.php new file mode 100755 index 0000000..d79776b --- /dev/null +++ b/src/Controller/Payment/PlatformMembership/DoneAction.php @@ -0,0 +1,81 @@ + 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'); + } +} diff --git a/src/Controller/Payment/PlatformMembership/PrepareAction.php b/src/Controller/Payment/PlatformMembership/PrepareAction.php new file mode 100644 index 0000000..332a808 --- /dev/null +++ b/src/Controller/Payment/PlatformMembership/PrepareAction.php @@ -0,0 +1,66 @@ + 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')); + } +} diff --git a/src/Controller/User/Product/ServiceController.php b/src/Controller/User/Product/ServiceController.php index dad5958..0316c35 100644 --- a/src/Controller/User/Product/ServiceController.php +++ b/src/Controller/User/Product/ServiceController.php @@ -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|null $images */ diff --git a/src/Doctrine/Behavior/AbstractOfferEntity.php b/src/Doctrine/Behavior/AbstractOfferEntity.php index 0355573..9580150 100644 --- a/src/Doctrine/Behavior/AbstractOfferEntity.php +++ b/src/Doctrine/Behavior/AbstractOfferEntity.php @@ -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.')'; } diff --git a/src/Doctrine/Listener/MembershipPaidListener.php b/src/Doctrine/Listener/MembershipPaidListener.php new file mode 100644 index 0000000..0564b13 --- /dev/null +++ b/src/Doctrine/Listener/MembershipPaidListener.php @@ -0,0 +1,38 @@ +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'))); + } + } +} diff --git a/src/Entity/Configuration.php b/src/Entity/Configuration.php index fa5d509..0b1d091 100644 --- a/src/Entity/Configuration.php +++ b/src/Entity/Configuration.php @@ -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 + */ + #[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 */ public function getGlobals(): array { - /** @var array $globals */ + /** @var array $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']; } /** diff --git a/src/Entity/PlatformOffer.php b/src/Entity/PlatformOffer.php index 7462085..b9ed102 100644 --- a/src/Entity/PlatformOffer.php +++ b/src/Entity/PlatformOffer.php @@ -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; + } } diff --git a/src/Entity/Product.php b/src/Entity/Product.php index 6f63b45..96a5369 100644 --- a/src/Entity/Product.php +++ b/src/Entity/Product.php @@ -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'), ], diff --git a/src/Entity/User.php b/src/Entity/User.php index 50140db..bf25ebf 100755 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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 + */ + public function getUserGroupsConfirmedWithServices(): Collection + { + /** @var Collection $collection */ + $collection = $this->userGroups->filter(fn (UserGroup $userGroup) => !$userGroup->getMembership()->isInvited() && $userGroup->getGroup()->getServicesEnabled()); + + return $collection; + } + /** * @return array */ @@ -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 diff --git a/src/Enum/OfferType.php b/src/Enum/OfferType.php index 611b55f..222acdd 100644 --- a/src/Enum/OfferType.php +++ b/src/Enum/OfferType.php @@ -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'; diff --git a/src/Form/Type/Admin/ParametersFormType.php b/src/Form/Type/Admin/ParametersFormType.php index 034ae0c..173cc84 100755 --- a/src/Form/Type/Admin/ParametersFormType.php +++ b/src/Form/Type/Admin/ParametersFormType.php @@ -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' => [ diff --git a/src/Form/Type/Product/AbstractProductFormType.php b/src/Form/Type/Product/AbstractProductFormType.php index 52dd3b0..1a19598 100644 --- a/src/Form/Type/Product/AbstractProductFormType.php +++ b/src/Form/Type/Product/AbstractProductFormType.php @@ -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 */ diff --git a/src/Form/Type/Product/ObjectFormType.php b/src/Form/Type/Product/ObjectFormType.php index db464e2..1cda787 100644 --- a/src/Form/Type/Product/ObjectFormType.php +++ b/src/Form/Type/Product/ObjectFormType.php @@ -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 diff --git a/src/Form/Type/Product/ServiceFormType.php b/src/Form/Type/Product/ServiceFormType.php index e61dbf9..7597a26 100644 --- a/src/Form/Type/Product/ServiceFormType.php +++ b/src/Form/Type/Product/ServiceFormType.php @@ -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 diff --git a/src/Message/Command/Admin/ParametersFormCommand.php b/src/Message/Command/Admin/ParametersFormCommand.php index 73ba808..d34f2cf 100755 --- a/src/Message/Command/Admin/ParametersFormCommand.php +++ b/src/Message/Command/Admin/ParametersFormCommand.php @@ -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 } diff --git a/src/Payment/PayumManager.php b/src/Payment/PayumManager.php index a7b4bbc..0f0736a 100644 --- a/src/Payment/PayumManager.php +++ b/src/Payment/PayumManager.php @@ -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 */ - private function getGatewayDetails(GroupOffer $groupOffer): array + private function getGatewayDetails(GroupOffer|PlatformOffer $offer): array { - 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(), - ], - ]; + 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) $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(), + ], + ]; + } } /** diff --git a/src/Repository/GroupRepository.php b/src/Repository/GroupRepository.php index a1c1a5f..acd3b48 100644 --- a/src/Repository/GroupRepository.php +++ b/src/Repository/GroupRepository.php @@ -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); + } } diff --git a/src/Repository/PlatformOfferRepository.php b/src/Repository/PlatformOfferRepository.php index 8ae77f5..af8b4ce 100644 --- a/src/Repository/PlatformOfferRepository.php +++ b/src/Repository/PlatformOfferRepository.php @@ -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]); + } } diff --git a/src/Security/Voter/MembershipPaidVoter.php b/src/Security/Voter/MembershipPaidVoter.php new file mode 100644 index 0000000..f97d156 --- /dev/null +++ b/src/Security/Voter/MembershipPaidVoter.php @@ -0,0 +1,34 @@ +getUser(); + + if (!$user instanceof User) { + return false; + } + + return !$this->configurationRepository->getInstanceConfigurationOrCreate()->getPaidMembership() || $user->isMembershipPaid(); + } +} diff --git a/templates/admin/parameters.html.twig b/templates/admin/parameters.html.twig index d6354ca..b2fc959 100644 --- a/templates/admin/parameters.html.twig +++ b/templates/admin/parameters.html.twig @@ -17,6 +17,8 @@

{{ 'parameters.global.h2'|trans }}


+ {{ form_row(form.globalName) }} +

{{ 'parameters.services.h3'|trans }}

{{ form_widget(form.globalServicesEnabled) }} diff --git a/templates/components/group/_modal_offers.html.twig b/templates/components/group/_modal_offers.html.twig index 3d3f2b7..31291b7 100644 --- a/templates/components/group/_modal_offers.html.twig +++ b/templates/components/group/_modal_offers.html.twig @@ -27,7 +27,7 @@
-
+ diff --git a/templates/pages/group/show/_logged_with_link.html.twig b/templates/pages/group/show/_logged_with_link.html.twig index a6eefbc..eb8dc81 100644 --- a/templates/pages/group/show/_logged_with_link.html.twig +++ b/templates/pages/group/show/_logged_with_link.html.twig @@ -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 #} diff --git a/templates/pages/group/show/_logged_without_link.html.twig b/templates/pages/group/show/_logged_without_link.html.twig index 8a6922e..c5ade60 100644 --- a/templates/pages/group/show/_logged_without_link.html.twig +++ b/templates/pages/group/show/_logged_without_link.html.twig @@ -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. #} diff --git a/templates/pages/redirect_to_payment.html.twig b/templates/pages/redirect_to_payment.html.twig new file mode 100644 index 0000000..1d0eeb4 --- /dev/null +++ b/templates/pages/redirect_to_payment.html.twig @@ -0,0 +1,30 @@ +{% extends 'layout/base.html.twig' %} + +{% set i18n_prefix = _self|i18n_prefix %} + +{% block body %} +
+ {% 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 + } %} +
+ +
+ + {% include 'components/group/_modal_offers.html.twig' with { + offers: offers, + prepare_path: 'app_platform_payment_prepare' + } %} +
+{% endblock %} diff --git a/tests/Functional/Controller/Payment/PrepareActionTest.php b/tests/Functional/Controller/Payment/PrepareActionTest.php index a71cb74..d0c139c 100644 --- a/tests/Functional/Controller/Payment/PrepareActionTest.php +++ b/tests/Functional/Controller/Payment/PrepareActionTest.php @@ -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; diff --git a/tests/Functional/Controller/Product/ServiceControllerTest.php b/tests/Functional/Controller/Product/ServiceControllerTest.php index 82e1148..efa7d39 100644 --- a/tests/Functional/Controller/Product/ServiceControllerTest.php +++ b/tests/Functional/Controller/Product/ServiceControllerTest.php @@ -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(); diff --git a/tests/TestReference.php b/tests/TestReference.php index 2bb1347..c590ab2 100644 --- a/tests/TestReference.php +++ b/tests/TestReference.php @@ -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'; } diff --git a/tests/Unit/Controller/Payment/DoneActionTest.php b/tests/Unit/Controller/Payment/DoneActionTest.php index 1d18ce7..5f4b7c9 100644 --- a/tests/Unit/Controller/Payment/DoneActionTest.php +++ b/tests/Unit/Controller/Payment/DoneActionTest.php @@ -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()); } } diff --git a/translations/app/Controller/Payment/DoneAction/messages.fr.xlf b/translations/app/Controller/Payment/Group/DoneAction/messages.fr.xlf similarity index 66% rename from translations/app/Controller/Payment/DoneAction/messages.fr.xlf rename to translations/app/Controller/Payment/Group/DoneAction/messages.fr.xlf index c0b0d1e..4d87247 100644 --- a/translations/app/Controller/Payment/DoneAction/messages.fr.xlf +++ b/translations/app/Controller/Payment/Group/DoneAction/messages.fr.xlf @@ -5,28 +5,28 @@ - - app.controller.payment.done_action.flash.success + + app.controller.payment.group.done_action.flash.success Paiement réussi. Vous êtes désormais membre du groupe %group%. - - app.controller.payment.done_action.status.new + + app.controller.payment.group.done_action.status.new Le paiement est en cours mais n'a pas encore abouti. - - app.controller.payment.done_action.status.failed + + app.controller.payment.group.done_action.status.failed Le paiement a échoué, vous n'avez pas été prélevé·e. - - app.controller.payment.done_action.status.canceled + + app.controller.payment.group.done_action.status.canceled Le paiement a été annulé, vous n'avez pas été prélevé·e. - - app.controller.payment.done_action.status.expired + + app.controller.payment.group.done_action.status.expired Le paiement a expiré, veuillez réessayer. diff --git a/translations/app/Controller/Payment/PlatformMembership/messages.fr.xlf b/translations/app/Controller/Payment/PlatformMembership/messages.fr.xlf new file mode 100644 index 0000000..da777de --- /dev/null +++ b/translations/app/Controller/Payment/PlatformMembership/messages.fr.xlf @@ -0,0 +1,34 @@ + + + +
+ +
+ + + app.controller.payment.platform_membership.done_action.flash.success + Paiement réussi. Vous êtes désormais membre de la plateforme %platform%. + + + + app.controller.payment.platform_membership.done_action.status.new + Le paiement est en cours mais n'a pas encore abouti. + + + + app.controller.payment.platform_membership.done_action.status.failed + Le paiement a échoué, vous n'avez pas été prélevé·e. + + + + app.controller.payment.platform_membership.done_action.status.canceled + Le paiement a été annulé, vous n'avez pas été prélevé·e. + + + + app.controller.payment.platform_membership.done_action.status.expired + Le paiement a expiré, veuillez réessayer. + + +
+
diff --git a/translations/app/Form/Type/Product/ServiceFormType/messages.fr.xlf b/translations/app/Form/Type/Product/ServiceFormType/messages.fr.xlf index 9c405a3..1026009 100644 --- a/translations/app/Form/Type/Product/ServiceFormType/messages.fr.xlf +++ b/translations/app/Form/Type/Product/ServiceFormType/messages.fr.xlf @@ -5,11 +5,15 @@ - + app.form.type.product.service_form_type.form.groups Visible dans mes groupes : + + app.form.type.product.service_form_type.form.no_groups + Vous n'avez aucun groupe avec la disponibilité des services activée + diff --git a/translations/parameters/admin.fr.xlf b/translations/parameters/admin.fr.xlf index d7e5243..9066ee5 100644 --- a/translations/parameters/admin.fr.xlf +++ b/translations/parameters/admin.fr.xlf @@ -7,6 +7,11 @@ + + parameter.global_name + Nom de la plateforme + + parameter.services Services activés diff --git a/translations/product/messages.fr.xlf b/translations/product/messages.fr.xlf index a165e60..5615560 100644 --- a/translations/product/messages.fr.xlf +++ b/translations/product/messages.fr.xlf @@ -105,6 +105,11 @@ Uniquement mes groupes + + product.service.form.visibility + Uniquement mes groupes + + new_product.available diff --git a/translations/templates/pages/messages.fr.xlf b/translations/templates/pages/messages.fr.xlf new file mode 100644 index 0000000..133f00d --- /dev/null +++ b/translations/templates/pages/messages.fr.xlf @@ -0,0 +1,65 @@ + + + +
+ +
+ + + + templates.pages.redirect_to_payment.type.yearly + Adhésion annuel + + + + templates.pages.redirect_to_payment.type.monthly + Adhésion mensuel + + + + templates.pages.redirect_to_payment.type.oneshot + Unique + + + + templates.pages.redirect_to_payment.choose_offer + Rejoindre la plateforme + + + + templates.pages.redirect_to_payment.close + Annuler + + + + templates.pages.redirect_to_payment.membership + Adhésion: + + + + templates.pages.redirect_to_payment.membership.start + À partir de + + + + templates.pages.redirect_to_payment.payment_prepare.form.submit + Accéder au paiement + + + + templates.pages.redirect_payment.title + Bienvenue + + + + templates.pages.redirect_to_payment.text + Pour avoir accès aux prêts/emprunts sur notre plateforme, il faut être adhérent. + + + + templates.pages.redirect_to_payment.button + Payer mon adhésion + + +
+