diff --git a/fixtures/test/user.yaml b/fixtures/test/user.yaml index 06c9a3d..ea3b449 100644 --- a/fixtures/test/user.yaml +++ b/fixtures/test/user.yaml @@ -35,6 +35,10 @@ App\Entity\User: devAccount: true address: '@address_loic' avatar: '7c732ddb-9c13-45eb-aea0-e614f2340e6d.jpg' + membershipPaid: true + platformOffer: '@platform_offer_1' + startAt: + endAt: admin_kevin (extends admin_template): id: @@ -42,8 +46,11 @@ App\Entity\User: firstname: 'Kevin' lastname: 'Pirouet' avatar: '7c732ddb-9c13-45eb-aea0-e614f2340e6d.jpg' - membershipPaid: true type: !php/enum App\Enum\User\UserType::ADMIN + membershipPaid: true + platformOffer: '@platform_offer_1' + startAt: + endAt: roles: [ !php/const App\Entity\User::ROLE_ADMIN, !php/const App\Entity\User::MEMBERSHIP_PAID] admin_apes (extends admin_template): diff --git a/helm/chart/templates/cronjobs/cronjobEndPlatformMembership.yaml b/helm/chart/templates/cronjobs/cronjobEndPlatformMembership.yaml new file mode 100644 index 0000000..008b6d2 --- /dev/null +++ b/helm/chart/templates/cronjobs/cronjobEndPlatformMembership.yaml @@ -0,0 +1,148 @@ +{{- if .Values.dailyCronjobs.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "plateforme-ebs" . }}-cronjob-end-membership + labels: + {{- include "plateforme-ebs.labels" . | nindent 4 }} +spec: + schedule: '15 1 * * *' + jobTemplate: + metadata: + annotations: + rollme: {{ randAlphaNum 5 | quote }} + labels: + {{- include "plateforme-ebs.selectorLabels" . | nindent 8 }} + spec: + template: + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 12 }} + {{- end }} + serviceAccountName: {{ include "plateforme-ebs.serviceAccountName" . }} + restartPolicy: Never + containers: + - name: {{ .Chart.Name }}-cronjob-end-membership + image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.php.image.pullPolicy }} + command: ['/bin/sh', '-c'] + args: ['bin/console app:end-platform-membership --env=prod'] + env: + - name: API_ENTRYPOINT_HOST + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-host + - name: JWT_PASSPHRASE + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-passphrase + - name: JWT_PUBLIC_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-public-key + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-secret-key + - name: TRUSTED_HOSTS + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-trusted-hosts + - name: TRUSTED_PROXIES + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-trusted-proxies + - name: APP_ENV + value: "prod" + - name: APP_DEBUG + value: "0" + - name: APP_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-app-secret + - name: CORS_ALLOW_ORIGIN + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-cors-allow-origin + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: database-url + - name: MERCURE_URL + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-url + - name: MERCURE_PUBLIC_URL + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-public-url + - name: MERCURE_JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-jwt-secret + - name: STORAGE_BUCKET + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-bucket + - name: STORAGE_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-endpoint + - name: STORAGE_REGION + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-region + - name: STORAGE_USE_PATH_STYLE_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-use-path-style-endpoint + - name: STORAGE_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-key + - name: STORAGE_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-secret + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"] + startupProbe: + exec: + command: + - docker-healthcheck + failureThreshold: 40 + periodSeconds: 3 + readinessProbe: + exec: + command: + - docker-healthcheck + periodSeconds: 3 + livenessProbe: + exec: + command: + - docker-healthcheck + periodSeconds: 3 + resources: + {{- toYaml .Values.resources.fixtures | nindent 16 }} +{{- end }} diff --git a/helm/chart/templates/cronjobs/cronjobNotifyPlatformMembershipExpiration1.yaml b/helm/chart/templates/cronjobs/cronjobNotifyPlatformMembershipExpiration1.yaml new file mode 100644 index 0000000..06915fe --- /dev/null +++ b/helm/chart/templates/cronjobs/cronjobNotifyPlatformMembershipExpiration1.yaml @@ -0,0 +1,148 @@ +{{- if .Values.dailyCronjobs.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "plateforme-ebs" . }}-cronjob-notify-ms-e-1 + labels: + {{- include "plateforme-ebs.labels" . | nindent 4 }} +spec: + schedule: '20 2 * * *' + jobTemplate: + metadata: + annotations: + rollme: {{ randAlphaNum 5 | quote }} + labels: + {{- include "plateforme-ebs.selectorLabels" . | nindent 8 }} + spec: + template: + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 12 }} + {{- end }} + serviceAccountName: {{ include "plateforme-ebs.serviceAccountName" . }} + restartPolicy: Never + containers: + - name: {{ .Chart.Name }}-cronjob-notify-ms-e-1 + image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.php.image.pullPolicy }} + command: ['/bin/sh', '-c'] + args: ['bin/console app:notify-platform-membership-expiration 1 --env=prod'] + env: + - name: API_ENTRYPOINT_HOST + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-host + - name: JWT_PASSPHRASE + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-passphrase + - name: JWT_PUBLIC_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-public-key + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-secret-key + - name: TRUSTED_HOSTS + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-trusted-hosts + - name: TRUSTED_PROXIES + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-trusted-proxies + - name: APP_ENV + value: "prod" + - name: APP_DEBUG + value: "0" + - name: APP_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-app-secret + - name: CORS_ALLOW_ORIGIN + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-cors-allow-origin + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: database-url + - name: MERCURE_URL + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-url + - name: MERCURE_PUBLIC_URL + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-public-url + - name: MERCURE_JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-jwt-secret + - name: STORAGE_BUCKET + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-bucket + - name: STORAGE_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-endpoint + - name: STORAGE_REGION + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-region + - name: STORAGE_USE_PATH_STYLE_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-use-path-style-endpoint + - name: STORAGE_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-key + - name: STORAGE_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-secret + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"] + startupProbe: + exec: + command: + - docker-healthcheck + failureThreshold: 40 + periodSeconds: 3 + readinessProbe: + exec: + command: + - docker-healthcheck + periodSeconds: 3 + livenessProbe: + exec: + command: + - docker-healthcheck + periodSeconds: 3 + resources: + {{- toYaml .Values.resources.fixtures | nindent 16 }} +{{- end }} diff --git a/helm/chart/templates/cronjobs/cronjobNotifyPlatformMembershipExpiration7.yaml b/helm/chart/templates/cronjobs/cronjobNotifyPlatformMembershipExpiration7.yaml new file mode 100644 index 0000000..9c48b95 --- /dev/null +++ b/helm/chart/templates/cronjobs/cronjobNotifyPlatformMembershipExpiration7.yaml @@ -0,0 +1,148 @@ +{{- if .Values.dailyCronjobs.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "plateforme-ebs" . }}-cronjob-notify-ms-e-7 + labels: + {{- include "plateforme-ebs.labels" . | nindent 4 }} +spec: + schedule: '10 21 * * *' + jobTemplate: + metadata: + annotations: + rollme: {{ randAlphaNum 5 | quote }} + labels: + {{- include "plateforme-ebs.selectorLabels" . | nindent 8 }} + spec: + template: + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 12 }} + {{- end }} + serviceAccountName: {{ include "plateforme-ebs.serviceAccountName" . }} + restartPolicy: Never + containers: + - name: {{ .Chart.Name }}-cronjob-notify-ms-e-7 + image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.php.image.pullPolicy }} + command: ['/bin/sh', '-c'] + args: ['bin/console app:notify-platform-membership-expiration 7 --env=prod'] + env: + - name: API_ENTRYPOINT_HOST + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-host + - name: JWT_PASSPHRASE + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-passphrase + - name: JWT_PUBLIC_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-public-key + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-jwt-secret-key + - name: TRUSTED_HOSTS + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-trusted-hosts + - name: TRUSTED_PROXIES + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-trusted-proxies + - name: APP_ENV + value: "prod" + - name: APP_DEBUG + value: "0" + - name: APP_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-app-secret + - name: CORS_ALLOW_ORIGIN + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-cors-allow-origin + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: database-url + - name: MERCURE_URL + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-url + - name: MERCURE_PUBLIC_URL + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-public-url + - name: MERCURE_JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: mercure-jwt-secret + - name: STORAGE_BUCKET + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-bucket + - name: STORAGE_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-endpoint + - name: STORAGE_REGION + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-region + - name: STORAGE_USE_PATH_STYLE_ENDPOINT + valueFrom: + configMapKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-use-path-style-endpoint + - name: STORAGE_KEY + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-key + - name: STORAGE_SECRET + valueFrom: + secretKeyRef: + name: {{ include "plateforme-ebs" . }} + key: php-storage-secret + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"] + startupProbe: + exec: + command: + - docker-healthcheck + failureThreshold: 40 + periodSeconds: 3 + readinessProbe: + exec: + command: + - docker-healthcheck + periodSeconds: 3 + livenessProbe: + exec: + command: + - docker-healthcheck + periodSeconds: 3 + resources: + {{- toYaml .Values.resources.fixtures | nindent 16 }} +{{- end }} diff --git a/src/Command/EndPlatformMembershipCommand.php b/src/Command/EndPlatformMembershipCommand.php new file mode 100644 index 0000000..374706f --- /dev/null +++ b/src/Command/EndPlatformMembershipCommand.php @@ -0,0 +1,99 @@ +configureCommand(self::DESCRIPTION); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $configuration = $this->configurationRepository->getInstanceConfigurationOrCreate(); + if (!$configuration->getPaidMembership()) { + return Command::SUCCESS; + } + $platform = $configuration->getPlatformName(); + $io->title(self::DESCRIPTION.' ('.$this->environment.' env)'); + $this->memoryReport($io); + + $io->section('Getting concerned membership...'); + $query = $this->userRepository->getExpiredMembership(); + + $io->section('Processing user updates...'); + $count = 0; + /** @var User $user */ + foreach ($query->toIterable() as $user) { + if ($user->getPlatformOffer()?->getType() === OfferType::ONESHOT) { + continue; + } + + $io->comment(\sprintf(' > ending platform membership expiration for user %s (%s)', + $user->getDisplayName(), + $user->getEmail(), + )); + + $user->setMembershipPaid(false) + ->setEndAt(null) + ->setPayedAt(null) + ->setStartAt(null) + ->setPlatformOffer(null); + + $this->userManager->save($user, true); + + $this->appMailer->send(EndPlatformMembershipMail::class, compact('user', 'platform')); + $this->sendSms($user, EndPlatformMembershipMail::class, ['%platform%' => $platform]); + ++$count; + } + + $io->note(\sprintf(' > %d update(s) done.', $count)); + $this->memoryReport($io); + $this->done($io); + + return Command::SUCCESS; + } +} diff --git a/src/Command/NotifyPlatformMembershipExpirationCommand.php b/src/Command/NotifyPlatformMembershipExpirationCommand.php new file mode 100644 index 0000000..f4af551 --- /dev/null +++ b/src/Command/NotifyPlatformMembershipExpirationCommand.php @@ -0,0 +1,95 @@ +configureCommand(self::DESCRIPTION); + $this->addArgument('days', InputArgument::REQUIRED, 'Number of days from tomorrow (1 = notify members expiring tomorrow)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $configuration = $this->configurationRepository->getInstanceConfigurationOrCreate(); + if (!$configuration->getPaidMembership()) { + return Command::SUCCESS; + } + $platform = $configuration->getPlatformName(); + $io->title(self::DESCRIPTION.' ('.$this->environment.' env)'); + $this->memoryReport($io); + + /** @var string $days */ + $days = $input->getArgument('days'); + $days = max(1, (int) $days); + + $io->section(\sprintf('Getting platform membership expiring in %d days...', $days)); + $query = $this->userRepository->getExpiring($days); + $io->section('Sending notifications...'); + $count = 0; + /** @var User $user */ + foreach ($query->toIterable() as $user) { + if ($user->getPlatformOffer()?->getType() === OfferType::ONESHOT) { + continue; + } + + $io->comment(\sprintf(' > notifying platform membership expiration for user %s (%s)', + $user->getDisplayName(), + $user->getEmail(), + )); + + $this->appMailer->send(NotifyPlatformMembershipExpirationMail::class, compact('user', 'days', 'platform')); + $this->sendSms($user, NotifyPlatformMembershipExpirationMail::class, ['%days%' => $days, '%platform%' => $platform]); + ++$count; + } + + $io->note(\sprintf(' > %d notification(s) sent.', $count)); + + $this->memoryReport($io); + $this->done($io); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Admin/AbstractUserCrudController.php b/src/Controller/Admin/AbstractUserCrudController.php index 1943880..3708a71 100755 --- a/src/Controller/Admin/AbstractUserCrudController.php +++ b/src/Controller/Admin/AbstractUserCrudController.php @@ -17,6 +17,7 @@ use App\Flysystem\MediaManager; use App\Helper\CsvExporter; use App\Mailer\AppMailer; use App\Mailer\Email\Admin\PromoteToAdmin\PromoteToAdminEmail; +use App\Repository\ConfigurationRepository; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; @@ -37,10 +38,12 @@ use EasyCorp\Bundle\EasyAdminBundle\Factory\FormFactory; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; +use EasyCorp\Bundle\EasyAdminBundle\Field\DateField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField; +use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Filter\DateTimeFilter; @@ -89,6 +92,7 @@ abstract class AbstractUserCrudController extends AbstractCrudController impleme #[Autowire('%user_base_path%')] private readonly string $userBasePath, AppMailer $mailer, + private readonly ConfigurationRepository $configurationRepository, ) { $this->mailer = $mailer; } @@ -355,6 +359,20 @@ abstract class AbstractUserCrudController extends AbstractCrudController impleme $vacationModeField = BooleanField::new('vacationMode'); $addressField = AssociationField::new('address'); $groupsCountField = AssociationField::new('userGroups')->setLabel('Groups number'); + $membershipPaidField = BooleanField::new('membershipPaid'); + $startAt = DateField::new('startAt'); + $endAt = DateField::new('endAt'); + $expiresInField = IntegerField::new('expiresIn') + ->formatValue(function ($value) { + return $value !== null ? $this->translator->trans($this->getI18nPrefix().'.expires_in.formatted_value', ['%days%' => $value], 'admin') : ''; + }) + ->setFormTypeOptions([ + 'attr' => ['readonly' => 'readonly'], + 'required' => false, + ]) + ; + $payedAt = DateTimeField::new('payedAt'); + $offerField = AssociationField::new('platformOffer'); return compact( 'idField', @@ -378,6 +396,12 @@ abstract class AbstractUserCrudController extends AbstractCrudController impleme 'vacationModeField', 'addressField', 'groupsCountField', + 'membershipPaidField', + 'startAt', + 'endAt', + 'expiresInField', + 'payedAt', + 'offerField', ); } @@ -445,4 +469,9 @@ abstract class AbstractUserCrudController extends AbstractCrudController impleme return $builder; } + + public function platformRequiresGlobalPayment(): bool + { + return $this->configurationRepository->getInstanceConfigurationOrCreate()->getPaidMembership(); + } } diff --git a/src/Controller/Admin/UserCrudController.php b/src/Controller/Admin/UserCrudController.php index 376aae5..a181c1b 100755 --- a/src/Controller/Admin/UserCrudController.php +++ b/src/Controller/Admin/UserCrudController.php @@ -48,16 +48,27 @@ final class UserCrudController extends AbstractUserCrudController 'vacationModeField' => $vacationModeField, 'addressField' => $addressField, 'groupsCountField' => $groupsCountField, + 'membershipPaidField' => $membershipPaidField, + 'startAt' => $startAt, + 'endAt' => $endAt, + 'expiresInField' => $expiresInField, + 'payedAt' => $payedAt, + 'offerField' => $offerField, ] = $this->getFields($pageName); if ($pageName === Crud::PAGE_INDEX) { - return [$emailField, $firstNameField, $lastNameField, $enabledField, $emailConfirmedField, $avatarField, $createdAt, $updatedAt, $loginAt, $groupsCountField]; + $listFields = [$emailField, $firstNameField, $lastNameField, $enabledField, $emailConfirmedField, $avatarField, $createdAt, $updatedAt, $loginAt, $groupsCountField]; + if ($this->platformRequiresGlobalPayment()) { + $listFields[] = $membershipPaidField; + } + + return $listFields; } $panels = $this->getPanels(); if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) { - return [ + $editFields = [ $panels['information'], $emailField, $firstNameField, @@ -74,9 +85,21 @@ final class UserCrudController extends AbstractUserCrudController $enabledField, $emailConfirmedField, ]; + if ($this->platformRequiresGlobalPayment()) { + $editFields = array_merge($editFields, [ + $panels['payment_information'], + $membershipPaidField, + $offerField, + $startAt, + $endAt, + $payedAt, + ]); + } + + return $editFields; } - return [ + $showFields = [ $panels['information'], $emailField, $firstNameField, @@ -97,5 +120,17 @@ final class UserCrudController extends AbstractUserCrudController $updatedAt, $loginAt, ]; + if ($this->platformRequiresGlobalPayment()) { + $showFields = array_merge($showFields, [ + $panels['payment_information'], + $membershipPaidField, + $startAt, + $endAt, + $payedAt, + $expiresInField, + ]); + } + + return $showFields; } } diff --git a/src/Controller/Payment/Group/DoneAction.php b/src/Controller/Payment/Group/DoneAction.php index 91dfeeb..f3d2eb1 100755 --- a/src/Controller/Payment/Group/DoneAction.php +++ b/src/Controller/Payment/Group/DoneAction.php @@ -18,7 +18,7 @@ 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\Attribute\Route; use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; diff --git a/src/Controller/Payment/PlatformMembership/DoneAction.php b/src/Controller/Payment/PlatformMembership/DoneAction.php index d79776b..5921eec 100755 --- a/src/Controller/Payment/PlatformMembership/DoneAction.php +++ b/src/Controller/Payment/PlatformMembership/DoneAction.php @@ -6,11 +6,11 @@ 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 App\Message\Command\Payment\PlatformMembershipPaidCommand; +use App\MessageBus\CommandBusInterface; use Payum\Core\Payum; use Payum\Core\Request\GetHumanStatus; use Psr\Log\LoggerInterface; @@ -19,7 +19,7 @@ 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\Attribute\Route; use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -33,6 +33,14 @@ final class DoneAction extends AbstractController public const ROUTE_NAME = 'app_platform_payment_done'; + public function __construct( + private readonly CommandBusInterface $commandBus, + private readonly Payum $payum, + private readonly TranslatorInterface $translator, + private readonly LoggerInterface $logger, + ) { + } + /** * @see https://github.com/Payum/Payum/blob/master/docs/symfony/get-it-started.md#payment-is-done */ @@ -41,41 +49,37 @@ final class DoneAction extends AbstractController 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 + public function __invoke(Request $request, #[MapEntity(expr: 'repository.findOneActive(id)')] PlatformOffer $platformOffer, #[CurrentUser] User $user): Response { try { /** @var PaymentToken $token */ - $token = $payum->getHttpRequestVerifier()->verify($request); + $token = $this->payum->getHttpRequestVerifier()->verify($request); } catch (\Exception $e) { - $logger->error($e->getMessage()); + $this->logger->error($e->getMessage()); throw new UnprocessableEntityHttpException('Cannot verify Payum token.'); } - $gateway = $payum->getGateway($token->getGatewayName()); - $status = new GetHumanStatus($token); - $gateway->execute($status); + /** @var GetHumanStatus $status */ + $status = $this->commandBus->dispatch(new PlatformMembershipPaidCommand($platformOffer->getId(), $user->getId(), $token)); // Not captured if (!$status->isCaptured()) { - $this->addFlashWarning($translator->trans($this->getI18nPrefix().'.status.'.$status->getValue())); + $this->addFlashWarning($this->translator->trans($this->getI18nPrefix().'.status.'.$status->getValue())); + + return $this->redirectToRoute('app_user_my_account'); } - $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', [ + $this->addFlashSuccess($this->translator->trans($this->getI18nPrefix().'.flash.success', [ '%platform%' => $platformOffer->getConfiguration()?->getPlatformName()], )); + $request->getSession()->remove('payment_in_progress'); + $group = $user->getMyGroupsAsInvited()->first(); + if ($group !== false) { + return $this->redirectToRoute('app_group_show_logged', $group->getRoutingParameters()); + } + return $this->redirectToRoute('app_user_my_account'); } } diff --git a/src/Controller/Security/AccountCreateController.php b/src/Controller/Security/AccountCreateController.php index c541fff..966f362 100644 --- a/src/Controller/Security/AccountCreateController.php +++ b/src/Controller/Security/AccountCreateController.php @@ -19,6 +19,7 @@ use App\Message\Query\Security\GetUserByTokenQuery; use App\MessageBus\CommandBus; use App\MessageBus\QueryBus; use App\MessageHandler\Command\Security\AccountCreateStep1CommandHandler; +use App\Repository\ConfigurationRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; @@ -41,6 +42,7 @@ final class AccountCreateController extends AbstractController private readonly QueryBus $queryBus, private readonly CommandBus $commandBus, private readonly Security $security, + private readonly ConfigurationRepository $configurationRepository, ) { $this->i18nPrefix = $this->getI18nPrefix(); } @@ -97,6 +99,7 @@ final class AccountCreateController extends AbstractController return $this->redirectToRoute('app_login'); } + $configuration = $this->configurationRepository->getInstanceConfigurationOrCreate(); // nominal case: user found and token not expired $form = $this->createForm(AccountCreateStep2FormType::class, $user->setStep2Defaults())->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -110,13 +113,26 @@ final class AccountCreateController extends AbstractController // page group. $group = $user->getMyGroupsAsInvited()->first(); if ($group !== false) { - $this->addFlashSuccess($this->i18nPrefix.'.step2.with_invitation.flash.success'); + // If platform needs payment, redirect to payment + if ($configuration->getPaidMembership()) { + $successMessage = $this->i18nPrefix.'.step2.with_invitation.global_paid_membership.flash.success'; + $this->addFlashSuccess($successMessage); + + return $this->redirectToRoute('redirect_to_payment'); + } + $successMessage = $this->i18nPrefix.'.step2.with_invitation.flash.success'; + $this->addFlashSuccess($successMessage); return $this->redirectToRoute('app_group_show_logged', $group->getRoutingParameters()); } + if ($configuration->getPaidMembership()) { + $successMessage = $this->i18nPrefix.'.step2.global_paid_membership.flash.success'; + } else { + $successMessage = $this->i18nPrefix.'.step2.flash.success'; + } // otherwise go to the address form - $this->addFlashSuccess($this->i18nPrefix.'.step2.flash.success'); + $this->addFlashSuccess($successMessage); return $this->redirectToRoute(MyAccountAction::ROUTE); } diff --git a/src/Doctrine/Listener/MembershipPaidListener.php b/src/Doctrine/Listener/MembershipPaidListener.php index 0564b13..9e158cf 100644 --- a/src/Doctrine/Listener/MembershipPaidListener.php +++ b/src/Doctrine/Listener/MembershipPaidListener.php @@ -24,8 +24,10 @@ final class MembershipPaidListener public function onKernelException(ExceptionEvent $event): void { - /** @var User $user */ $user = $this->security->getUser(); + if (!$user instanceof User) { + return; + } $config = $this->configurationRepository->getInstanceConfigurationOrCreate(); $session = $event->getRequest()->getSession(); /** @var bool $isPaymentInProgress */ diff --git a/src/EasyAdmin/Field/FieldTrait.php b/src/EasyAdmin/Field/FieldTrait.php index 615f251..3c836d5 100644 --- a/src/EasyAdmin/Field/FieldTrait.php +++ b/src/EasyAdmin/Field/FieldTrait.php @@ -30,6 +30,7 @@ trait FieldTrait return [ 'information' => FormField::addPanel('panel.information', 'fas fa-info-circle'), 'tech_information' => FormField::addPanel('panel.tech_information', 'fas fa-history'), + 'payment_information' => FormField::addPanel('panel.payment_information', 'fas fa-dollar-sign'), ]; } } diff --git a/src/Entity/User.php b/src/Entity/User.php index fdfabd2..88c2500 100755 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -17,6 +17,8 @@ use App\Form\Type\User\ChangeLoginFormType; use App\Form\Type\User\ChangePasswordFormType; use App\Form\Type\User\EditProfileFormType; use App\Repository\UserRepository; +use App\Validator\Constraints\User\MembershipPaid; +use Carbon\Carbon; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; @@ -26,6 +28,7 @@ use libphonenumber\PhoneNumberUtil; use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as AssertPhoneNumber; use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Security\Core\User\EquatableInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\Validator\Constraints as SecurityAssert; @@ -42,7 +45,8 @@ use function Symfony\Component\String\u; #[ORM\Table(name: '`user`')] // we also need escaping here #[ORM\EntityListeners([UserListener::class])] #[UniqueEntity('email', groups: [AccountCreateStep1FormType::class, ChangeLoginFormType::class, 'Default'])] -class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageInterface +#[MembershipPaid] +class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageInterface, EquatableInterface { use UserConfirmationTrait; use UserLostPasswordTrait; @@ -272,6 +276,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageIn #[ORM\Column(type: 'boolean', nullable: false)] private bool $membershipPaid = false; + #[ORM\ManyToOne(targetEntity: PlatformOffer::class)] + #[ORM\JoinColumn(referencedColumnName: 'id', onDelete: 'SET NULL')] + private ?PlatformOffer $platformOffer = null; + /** * Starting date of a paying membership. The starting date of a free membership * is stored in the creation date. @@ -992,4 +1000,35 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageIn return $this; } + + public function expiresIn(): ?int + { + $today = Carbon::today(); + if ($this->endAt === null || $this->endAt < $today) { + return null; + } + + $endAt = new Carbon($this->endAt); + + return $today->diffInDays($endAt); + } + + public function getPlatformOffer(): ?PlatformOffer + { + return $this->platformOffer; + } + + public function setPlatformOffer(?PlatformOffer $platformOffer): void + { + $this->platformOffer = $platformOffer; + } + + public function isEqualTo(UserInterface $user): bool + { + if (!$user instanceof self) { + return false; + } + + return $this->email === $user->getUserIdentifier() && $this->password === $user->getPassword(); + } } diff --git a/src/Mailer/Email/Command/EndPlatformMembershipMail.php b/src/Mailer/Email/Command/EndPlatformMembershipMail.php new file mode 100644 index 0000000..de5e26e --- /dev/null +++ b/src/Mailer/Email/Command/EndPlatformMembershipMail.php @@ -0,0 +1,50 @@ + $context + */ + public function getEmail(array $context): TemplatedEmail + { + /** @var ?User $user */ + $user = $context['user'] ?? null; + Assert::isInstanceOf($user, User::class); + + return (new TemplatedEmail()) + ->to($user->getEmail()) + ->priority(Email::PRIORITY_HIGH) + ->subject($this->translator->trans($this->getI18nPrefix().'.subject', [ + '%brand%' => $this->brand, + '%platform%' => $context['platform'], + ], AppMailer::TR_DOMAIN)) + ->htmlTemplate('email/command/end_platform_membership.html.twig') + ->context($context) + ; + } +} diff --git a/src/Mailer/Email/Command/NotifyPlatformMembershipExpirationMail.php b/src/Mailer/Email/Command/NotifyPlatformMembershipExpirationMail.php new file mode 100644 index 0000000..046edf9 --- /dev/null +++ b/src/Mailer/Email/Command/NotifyPlatformMembershipExpirationMail.php @@ -0,0 +1,51 @@ + $context + */ + public function getEmail(array $context): TemplatedEmail + { + /** @var ?User $user */ + $user = $context['user'] ?? null; + Assert::isInstanceOf($user, User::class); + + return (new TemplatedEmail()) + ->to($user->getEmail()) + ->priority(Email::PRIORITY_HIGH) + ->subject($this->translator->trans($this->getI18nPrefix().'.subject', [ + '%brand%' => $this->brand, + '%days%' => $context['days'], + '%platform%' => $context['platform'], + ], AppMailer::TR_DOMAIN)) + ->htmlTemplate('email/command/notify_platform_membership_expiration.html.twig') + ->context($context) + ; + } +} diff --git a/src/Mailer/Email/Payment/PlatformMembershipPaidMail.php b/src/Mailer/Email/Payment/PlatformMembershipPaidMail.php new file mode 100644 index 0000000..b817cbc --- /dev/null +++ b/src/Mailer/Email/Payment/PlatformMembershipPaidMail.php @@ -0,0 +1,50 @@ + $context + */ + public function getEmail(array $context): TemplatedEmail + { + /** @var ?User $user */ + $user = $context['user'] ?? null; + Assert::isInstanceOf($user, User::class); + + return (new TemplatedEmail()) + ->to($user->getEmail()) + ->priority(Email::PRIORITY_HIGH) + ->subject($this->translator->trans($this->getI18nPrefix().'.subject', [ + '%brand%' => $this->brand, + '%platform%' => $context['platform'], + ], AppMailer::TR_DOMAIN)) + ->htmlTemplate('email/payment/platform_membership_paid.html.twig') + ->context($context) + ; + } +} diff --git a/src/Message/Command/Payment/PlatformMembershipPaidCommand.php b/src/Message/Command/Payment/PlatformMembershipPaidCommand.php new file mode 100644 index 0000000..10fa2ff --- /dev/null +++ b/src/Message/Command/Payment/PlatformMembershipPaidCommand.php @@ -0,0 +1,22 @@ +platformOfferRepository->get($message->platformOfferId); + $user = $this->userRepository->get($message->userId); + + $gateway = $this->payum->getGateway($message->paymentToken->getGatewayName()); + $status = new GetHumanStatus($message->paymentToken); + $gateway->execute($status); + + // Not captured + if (!$status->isCaptured()) { + return $status; + } + + $user + ->setMembershipPaid(true) + ->setStartAt(CarbonImmutable::today()) + ->setPayedAt(CarbonImmutable::now()) + ->setPlatformOffer($platformOffer) + ; + if (($offerType = $platformOffer->getType())->isRecurring()) { + $user->setEndAt(new CarbonImmutable($offerType->getEndAtInterval())); + } + + $this->userManager->save($user, true); + + // payment was captured and membership is saved so invalidate the token + $this->payum->getHttpRequestVerifier()->invalidate($message->paymentToken); + + // send confirmation email + $configuration = $this->configurationRepository->getInstanceConfigurationOrCreate(); + $platform = $configuration->getPlatformName(); + $this->mailer->send(PlatformMembershipPaidMail::class, [ + 'platform' => $platform, + 'user' => $user, + ]); + + return $status; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 2307d9f..2515d1c 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -6,9 +6,11 @@ namespace App\Repository; use App\Entity\User; use App\Enum\User\UserType; +use Carbon\Carbon; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; +use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; @@ -147,4 +149,32 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader ->getQuery() ->getSingleScalarResult(); } + + public function getExpiredMembership(): Query + { + $today = Carbon::today(); + $qb = $this + ->createQueryBuilder('u') + ->andWhere('u.endAt < :date') + ->setParameter('date', $today->format('Y-m-d')) + ; + + return $qb->getQuery(); + } + + public function getExpiring(int $days): Query + { + $from = new \DateTimeImmutable(\sprintf('+%d days midnight', $days)); + $to = $from->modify('+ 1 day'); // just add one day for the end limit + + $qb = $this + ->createQueryBuilder('u') + ->andWhere('u.endAt >= :from') + ->andWhere('u.endAt < :to') + ->setParameter('from', $from->format('Y-m-d')) + ->setParameter('to', $to->format('Y-m-d')) + ; + + return $qb->getQuery(); + } } diff --git a/src/Test/ContainerRepositoryTrait.php b/src/Test/ContainerRepositoryTrait.php index a718071..81d7e32 100755 --- a/src/Test/ContainerRepositoryTrait.php +++ b/src/Test/ContainerRepositoryTrait.php @@ -15,6 +15,7 @@ use App\Repository\MenuItemRepository; use App\Repository\MenuRepository; use App\Repository\MessageRepository; use App\Repository\PaymentRepository; +use App\Repository\PlatformOfferRepository; use App\Repository\ProductAvailabilityRepository; use App\Repository\ProductRepository; use App\Repository\ServiceRequestRepository; @@ -113,4 +114,9 @@ trait ContainerRepositoryTrait { return self::getContainer()->get(ServiceRequestRepository::class); } + + public function getPlatformOfferRepository(): PlatformOfferRepository + { + return self::getContainer()->get(PlatformOfferRepository::class); + } } diff --git a/src/Validator/Constraints/User/MembershipPaid.php b/src/Validator/Constraints/User/MembershipPaid.php new file mode 100644 index 0000000..e19d61c --- /dev/null +++ b/src/Validator/Constraints/User/MembershipPaid.php @@ -0,0 +1,18 @@ +isMembershipPaid()) { + return; + } + + $platformOffer = $value->getPlatformOffer(); + if (null === $platformOffer) { + $this->context->buildViolation($constraint->message) + ->atPath('platformOffer') + ->addViolation(); + + return; + } + if (null === $value->getStartAt()) { + $this->context->buildViolation($constraint->message) + ->atPath('startAt') + ->addViolation(); + } + + match ($platformOffer->getType()) { + OfferType::YEARLY, OfferType::MONTHLY => $this->checkEndAt($value, $constraint), + OfferType::ONESHOT => null, + }; + } + + private function checkEndAt(User $value, MembershipPaid $constraint): void + { + if (null === $value->getEndAt()) { + $this->context->buildViolation($constraint->message) + ->atPath('endAt') + ->addViolation(); + } + } +} diff --git a/templates/email/command/end_platform_membership.html.twig b/templates/email/command/end_platform_membership.html.twig new file mode 100644 index 0000000..26212ad --- /dev/null +++ b/templates/email/command/end_platform_membership.html.twig @@ -0,0 +1,11 @@ +{% trans_default_domain 'email' %} + +{% set i18n_prefix = _self|i18n_prefix %} + +

{{ (i18n_prefix ~ '.h1')|trans }}

+ +

{{ (i18n_prefix ~ '.p1')|trans({'%endAt%': user.endAt, '%platform%': platform}) }}

+ +{{ (i18n_prefix ~ '.loginLink')|trans }} + +

{{ brand }}

diff --git a/templates/email/command/notify_platform_membership_expiration.html.twig b/templates/email/command/notify_platform_membership_expiration.html.twig new file mode 100644 index 0000000..75f40a0 --- /dev/null +++ b/templates/email/command/notify_platform_membership_expiration.html.twig @@ -0,0 +1,9 @@ +{% trans_default_domain 'email' %} + +{% set i18n_prefix = _self|i18n_prefix %} + +

{{ (i18n_prefix ~ '.h1')|trans }}

+ +

{{ (i18n_prefix ~ '.p1')|trans({'%platform%': platform, '%days%': days}) }}

+ +

{{ brand }}

diff --git a/templates/email/payment/platform_membership_paid.html.twig b/templates/email/payment/platform_membership_paid.html.twig new file mode 100644 index 0000000..350a029 --- /dev/null +++ b/templates/email/payment/platform_membership_paid.html.twig @@ -0,0 +1,9 @@ +{% trans_default_domain 'email' %} + +{% set i18n_prefix = _self|i18n_prefix %} + +

{{ (i18n_prefix ~ '.h1')|trans }}

+ +

{{ (i18n_prefix ~ '.p1')|trans({'%startAt%': user.startAt, '%endAt%': user.endAt, '%platform%': platform}) }}

+ +

{{ brand }}

diff --git a/tests/Integration/Command/EndPlatformMembershipCommandTest.php b/tests/Integration/Command/EndPlatformMembershipCommandTest.php new file mode 100644 index 0000000..3c651ad --- /dev/null +++ b/tests/Integration/Command/EndPlatformMembershipCommandTest.php @@ -0,0 +1,51 @@ +fixDoctrineBug($kernel->getContainer()); + + // temporarily set global configuration as globalPaidMembership = true + $configuration = $this->getConfigurationRepository()->getInstanceConfigurationOrCreate(); + $newConfig = $configuration->getConfiguration(); + $newConfig['global']['globalPaidMembership'] = true; + $configuration->setConfiguration($newConfig); + $this->getConfigurationRepository()->save($configuration, true); + + $application = new Application($kernel); + $command = $application->find(EndPlatformMembershipCommand::CMD); + $commandTester = new CommandTester($command); + $commandTester->execute([]); + $commandTester->assertCommandIsSuccessful(); + self::assertEmailCount(1); + self::assertNotificationCount(1); + $output = $commandTester->getDisplay(); + self::assertStringContainsString(\sprintf('%d update', 1), $output); + + // already deleted + $commandTester->execute([]); + $commandTester->assertCommandIsSuccessful(); + self::assertEmailCount(1); // not +1 + self::assertNotificationCount(1); + $output = $commandTester->getDisplay(); + self::assertStringContainsString(\sprintf('%d update', 0), $output); + } +} diff --git a/tests/Integration/Command/NotifyPlatformMembershipExpirationCommandTest.php b/tests/Integration/Command/NotifyPlatformMembershipExpirationCommandTest.php new file mode 100644 index 0000000..11846ea --- /dev/null +++ b/tests/Integration/Command/NotifyPlatformMembershipExpirationCommandTest.php @@ -0,0 +1,44 @@ +getConfigurationRepository()->getInstanceConfigurationOrCreate(); + $newConfig = $configuration->getConfiguration(); + $newConfig['global']['globalPaidMembership'] = true; + $configuration->setConfiguration($newConfig); + $this->getConfigurationRepository()->save($configuration, true); + + $application = new Application($kernel); + $command = $application->find(NotifyPlatformMembershipExpirationCommand::CMD); + $commandTester = new CommandTester($command); + + // in one week + $commandTester->execute([ + 'days' => 7, + ]); + $commandTester->assertCommandIsSuccessful(); + $output = $commandTester->getDisplay(); + self::assertStringContainsString(\sprintf('%d notification', 1), $output); + self::assertStringContainsString(\sprintf('notifying platform membership expiration for user %s', 'Kevin'), $output); + self::assertEmailCount(1); + self::assertNotificationCount(1); + } +} diff --git a/tests/Integration/MessageHandler/Payment/PlatformMembershipPaidCommandHandlerTest.php b/tests/Integration/MessageHandler/Payment/PlatformMembershipPaidCommandHandlerTest.php new file mode 100644 index 0000000..818a4f9 --- /dev/null +++ b/tests/Integration/MessageHandler/Payment/PlatformMembershipPaidCommandHandlerTest.php @@ -0,0 +1,75 @@ +get(PlatformMembershipPaidCommandHandler::class); + self::assertInstanceOf(PlatformMembershipPaidCommandHandler::class, $handler); + + $platformOffer = $this->getPlatformOfferRepository()->get(TestReference::PLATFORM_OFFER_1); + $user = $this->getUserRepository()->get(TestReference::ADMIN_LOIC); + + $message = new PlatformMembershipPaidCommand($platformOffer->getId(), $user->getId(), $this->getToken($user)); + $status = $handler($message); + self::assertTrue($status->isNew()); + } + + private function getToken(User $user): PaymentToken + { + $token = new PaymentToken(); + $token->setGatewayName('offline'); + $payment = new Payment(); + $payment->setUser($user); + $token->setDetails($payment); + + return $token; + } + + /** + * No error 500 if the user is already a member. + */ + public function testDoneAlreadyMember(): void + { + self::bootKernel(); + $handler = self::getContainer()->get(PlatformMembershipPaidCommandHandler::class); + self::assertInstanceOf(PlatformMembershipPaidCommandHandler::class, $handler); + + $platformOffer = $this->getPlatformOfferRepository()->get(TestReference::PLATFORM_OFFER_1); + $user = $this->getUserRepository()->get(TestReference::ADMIN_LOIC); + $user->setMembershipPaid(false); // juste for the test + $this->getUserManager()->save($user, true); + $payment = $this->getPaymentRepository()->get(TestReference::PAYMENT_USER_16_1); + $token = new PaymentToken(); + $token->setGatewayName('offline'); + $payment->setUser($user); + $token->setDetails($payment); + + $message = new PlatformMembershipPaidCommand($platformOffer->getId(), $user->getId(), $token); + $status = $handler($message); + self::assertTrue($status->isCaptured()); + self::assertEmailCount(1); + self::assertTrue($user->isMembershipPaid()); + } +} diff --git a/tests/Integration/Search/MeilisearchTest.php b/tests/Integration/Search/MeilisearchTest.php index b10ce0c..32bf5c2 100644 --- a/tests/Integration/Search/MeilisearchTest.php +++ b/tests/Integration/Search/MeilisearchTest.php @@ -23,6 +23,6 @@ final class MeilisearchTest extends KernelTestCase $meilisearch->indexProducts([$object, $service]); $searchDto = new Search('vélo'); $results = $meilisearch->search($searchDto); - self::assertNotEmpty($results->getHitsCount()); + self::assertNotEmpty($results); } } diff --git a/tests/Unit/Controller/Payment/DoneActionTest.php b/tests/Unit/Controller/Payment/Group/DoneActionTest.php similarity index 99% rename from tests/Unit/Controller/Payment/DoneActionTest.php rename to tests/Unit/Controller/Payment/Group/DoneActionTest.php index 5f4b7c9..e95b7a9 100644 --- a/tests/Unit/Controller/Payment/DoneActionTest.php +++ b/tests/Unit/Controller/Payment/Group/DoneActionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\Unit\Controller\Payment; +namespace App\Tests\Unit\Controller\Payment\Group; use App\Controller\Payment\Group\DoneAction; use App\Entity\Group; diff --git a/tests/Unit/Controller/Payment/PlatformMembership/DoneActionTest.php b/tests/Unit/Controller/Payment/PlatformMembership/DoneActionTest.php new file mode 100644 index 0000000..a360600 --- /dev/null +++ b/tests/Unit/Controller/Payment/PlatformMembership/DoneActionTest.php @@ -0,0 +1,149 @@ +getMockBuilder(HttpRequestVerifierInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $httpRequestVerifierInterface->method('verify') + ->willThrowException(new UnprocessableEntityHttpException()); + + $payum = $this->getMockBuilder(Payum::class) + ->disableOriginalConstructor() + ->getMock(); + $payum->method('getHttpRequestVerifier') + ->willReturn($httpRequestVerifierInterface); + + $translator = $this->getMockBuilder(TranslatorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $doneAction = new DoneAction( + $this->getCommandBus(), + $payum, + $translator, + $this->getLogger(), + ); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot verify Payum token'); + + $doneAction->__invoke(new Request(), $this->getPlatformOffer(), $this->getUser()); + } + + /** + * All this to test a line :/. + */ + public function testFlashWarning(): void + { + $httpRequestVerifierInterface = $this->getMockBuilder(HttpRequestVerifierInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $httpRequestVerifierInterface->method('verify') + ->willReturn(new PaymentToken()); + + $payum = $this->getMockBuilder(Payum::class) + ->disableOriginalConstructor() + ->getMock(); + $payum->method('getHttpRequestVerifier') + ->willReturn($httpRequestVerifierInterface); + + $translator = $this->getMockBuilder(TranslatorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $comandBus = $this->getCommandBus(); + $comandBus->method('dispatch')->willReturn(new GetHumanStatus(new PaymentToken())); + + $doneAction = new DoneAction( + $this->getCommandBus(), + $payum, + $translator, + $this->getLogger(), + ); + + // set session! + $session = $this->getMockBuilder(FlashBagAwareSessionInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $session->method('getFlashBag')->willReturn(new FlashBag()); + + $requesStack = $this->getMockBuilder(RequestStack::class) + ->disableOriginalConstructor() + ->getMock(); + $requesStack->method('getSession')->willReturn($session); + $container = $this->getMockBuilder(ContainerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $container->method('get')->willReturn($requesStack); + $doneAction->setContainer($container); + + $this->expectException(\Error::class); // or more mock are needed. To clean up later + + $doneAction->__invoke(new Request(), $this->getPlatformOffer(), $this->getUser()); + } + + /** + * @return CommandBusInterface&MockObject + */ + private function getCommandBus(): MockObject + { + return $this->getMockBuilder(CommandBusInterface::class) + ->disableOriginalConstructor() + ->getMock(); + } + + private function getLogger(): MockObject&LoggerInterface + { + return $this->getMockBuilder(LoggerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + } + + private function getUser(): User + { + return (new User()) + ->setId($this->getUuid()); + } + + private function getUuid(): Uuid + { + return Uuid::v6(); + } + + private function getPlatformOffer(): PlatformOffer + { + $platformOffer = new PlatformOffer(); + $platformOffer->setId($this->getUuid()); + + return $platformOffer; + } +} diff --git a/translations/admin.fr.xlf b/translations/admin.fr.xlf index db23fa1..f32a92f 100644 --- a/translations/admin.fr.xlf +++ b/translations/admin.fr.xlf @@ -373,6 +373,11 @@ Nombre de groupes + + Membership Paid + Abonnement à la plateforme payé + + diff --git a/translations/app/Controller/Admin/UserCrudController/admin.fr.xlf b/translations/app/Controller/Admin/UserCrudController/admin.fr.xlf new file mode 100644 index 0000000..2c0ae11 --- /dev/null +++ b/translations/app/Controller/Admin/UserCrudController/admin.fr.xlf @@ -0,0 +1,15 @@ + + + +
+ +
+ + + app.controller.admin.user_crud_controller.expires_in.formatted_value + %days% jour(s). + + + +
+
diff --git a/translations/app/Controller/Payment/PlatformMembership/messages.fr.xlf b/translations/app/Controller/Payment/PlatformMembership/messages.fr.xlf index da777de..18c12b2 100644 --- a/translations/app/Controller/Payment/PlatformMembership/messages.fr.xlf +++ b/translations/app/Controller/Payment/PlatformMembership/messages.fr.xlf @@ -7,7 +7,7 @@ app.controller.payment.platform_membership.done_action.flash.success - Paiement réussi. Vous êtes désormais membre de la plateforme %platform%. + Paiement réussi. Vous êtes désormais membre de la plateforme %platform%. Veuillez renseigner votre adresse afin de pouvoir utiliser les différents services. diff --git a/translations/app/Controller/Security/AccountCreateController/messages.fr.xlf b/translations/app/Controller/Security/AccountCreateController/messages.fr.xlf index eac3165..cbee186 100644 --- a/translations/app/Controller/Security/AccountCreateController/messages.fr.xlf +++ b/translations/app/Controller/Security/AccountCreateController/messages.fr.xlf @@ -15,11 +15,21 @@ Votre compte a bien été créé. Veuillez renseigner votre adresse afin de pouvoir utiliser les différents services. - + + app.controller.security.account_create_controller.step2.global_paid_membership.flash.success + Votre compte a bien été créé. Pour accéder à la plateforme, veuillez payer l'adhésion. + + + app.controller.security.account_create_controller.step2.with_invitation.flash.success Votre compte a bien été créé. Vous pouvez accepter l'invitation sur la page du groupe. + + app.controller.security.account_create_controller.step2.with_invitation.global_paid_membership.flash.success + Votre compte a bien été créé. Pour accéder à la plateforme, veuillez payer l'adhésion. Vous pourrez alors accepter l'invitation sur la page du groupe. + + app.controller.security.account_create_controller.step2.user_not_found.warning Aucun·e utilisateur·rice correspondant à ce code n'a été trouvé·e. Si votre compte est déjà confirmé veuillez vous connecter. diff --git a/translations/app/Mailer/Email/Command/EndPlatformMembershipMail/email.fr.xlf b/translations/app/Mailer/Email/Command/EndPlatformMembershipMail/email.fr.xlf new file mode 100644 index 0000000..0a23d65 --- /dev/null +++ b/translations/app/Mailer/Email/Command/EndPlatformMembershipMail/email.fr.xlf @@ -0,0 +1,15 @@ + + + +
+ +
+ + + app.mailer.email.command.end_platform_membership_mail.subject + %brand% : Expiration de votre adhésion à la plateforme %platform%. + + + +
+
diff --git a/translations/app/Mailer/Email/Command/NotifyPlatformMembershipExpirationMail/email.fr.xlf b/translations/app/Mailer/Email/Command/NotifyPlatformMembershipExpirationMail/email.fr.xlf new file mode 100644 index 0000000..99deb01 --- /dev/null +++ b/translations/app/Mailer/Email/Command/NotifyPlatformMembershipExpirationMail/email.fr.xlf @@ -0,0 +1,15 @@ + + + +
+ +
+ + + app.mailer.email.command.notify_platform_membership_expiration_mail.subject + %brand% : expiration de l'adhésion à la plateforme %platform% dans %days% jour(s). + + + +
+
diff --git a/translations/app/Mailer/Email/Payment/PlatformMembershipPaidMail/email.fr.xlf b/translations/app/Mailer/Email/Payment/PlatformMembershipPaidMail/email.fr.xlf new file mode 100644 index 0000000..599506c --- /dev/null +++ b/translations/app/Mailer/Email/Payment/PlatformMembershipPaidMail/email.fr.xlf @@ -0,0 +1,15 @@ + + + +
+ +
+ + + app.mailer.email.command.platform_membership_paid_mail.subject + %brand% : Confirmation de votre adhésion à la plateforme %platform%. + + + +
+
diff --git a/translations/templates/email/command/end_platform_membership/email.fr.xlf b/translations/templates/email/command/end_platform_membership/email.fr.xlf new file mode 100644 index 0000000..7b19e79 --- /dev/null +++ b/translations/templates/email/command/end_platform_membership/email.fr.xlf @@ -0,0 +1,26 @@ + + + +
+ +
+ + + emplates.email.command.end_platform_membership.h1 + Bonjour ! + + + + templates.email.command.end_platform_membership.p1 + Votre adhésion à la plateforme %platform% a expiré le %endAt%. + Connectez-vous à votre compte pour renouveler votre adhésion. + + + + templates.email.command.end_platform_membership.loginLink + Me connecter + + + +
+
diff --git a/translations/templates/email/command/notify_platform_membership_expiration/email.fr.xlf b/translations/templates/email/command/notify_platform_membership_expiration/email.fr.xlf new file mode 100644 index 0000000..d235ff3 --- /dev/null +++ b/translations/templates/email/command/notify_platform_membership_expiration/email.fr.xlf @@ -0,0 +1,20 @@ + + + +
+ +
+ + + templates.email.command.notify_membership_expiration.h1 + Bonjour ! + + + + templates.email.command.notify_membership_expiration.p1 + Votre adhésion à la plateform %platform% expire dans %days% jour(s). + + + +
+
diff --git a/translations/templates/email/payment/platform_membership_paid/email.fr.xlf b/translations/templates/email/payment/platform_membership_paid/email.fr.xlf new file mode 100644 index 0000000..6ec8626 --- /dev/null +++ b/translations/templates/email/payment/platform_membership_paid/email.fr.xlf @@ -0,0 +1,21 @@ + + + +
+ +
+ + + emplates.email.payment.platform_membership_paid.h1 + Bonjour ! + + + + templates.email.payment.platform_membership_paid.p1 + Votre adhésion à la plateforme %platform% a bien été pris en compte le %startAt%. + Il expirera le %endAt% + + + +
+
diff --git a/translations/user/validators.fr.xlf b/translations/user/validators.fr.xlf new file mode 100644 index 0000000..232d9a5 --- /dev/null +++ b/translations/user/validators.fr.xlf @@ -0,0 +1,16 @@ + + + +
+ +
+ + + + validator.user.membership_paid + Veuillez remplir ce champ + + + +
+