fix: platform membership fixes (#740)

This commit is contained in:
JacquesDurand 2024-09-30 09:42:55 +02:00 committed by Hugo Nicolas
parent 97a839c491
commit eea58804cd
No known key found for this signature in database
GPG key ID: 09CB3D93EB8B0E61
43 changed files with 1668 additions and 35 deletions

View file

@ -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: <date_create_immutable('-1 year - 1 day')>
endAt: <date_create_immutable('-1 day')>
admin_kevin (extends admin_template):
id: <uuid('1ed69804-eeb9-6c32-990b-632c3a6846ba')>
@ -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: <date_create_immutable('-1 year + 7 day')>
endAt: <date_create_immutable('+7 day midnight')>
roles: [ !php/const App\Entity\User::ROLE_ADMIN, !php/const App\Entity\User::MEMBERSHIP_PAID]
admin_apes (extends admin_template):

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Doctrine\Manager\UserManager;
use App\Entity\User;
use App\Enum\OfferType;
use App\Mailer\AppMailer;
use App\Mailer\Email\Command\EndPlatformMembershipMail;
use App\Notifier\SmsNotifier;
use App\Notifier\SmsNotifierTrait;
use App\Repository\ConfigurationRepository;
use App\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(name: self::CMD, description: self::DESCRIPTION)]
class EndPlatformMembershipCommand extends Command
{
use CommandTrait;
use SmsNotifierTrait;
public const CMD = 'app:end-platform-membership';
public const DESCRIPTION = 'Check overdue platform membership and set user as unpaid';
public function __construct(
private readonly UserRepository $userRepository,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
private readonly AppMailer $appMailer,
private readonly ConfigurationRepository $configurationRepository,
private readonly TranslatorInterface $translator,
private readonly SmsNotifier $notifier,
#[Autowire('%brand%')]
private readonly string $brand,
private readonly UserManager $userManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this->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;
}
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Enum\OfferType;
use App\Mailer\AppMailer;
use App\Mailer\Email\Command\NotifyPlatformMembershipExpirationMail;
use App\Notifier\SmsNotifier;
use App\Notifier\SmsNotifierTrait;
use App\Repository\ConfigurationRepository;
use App\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(name: self::CMD, description: self::DESCRIPTION)]
class NotifyPlatformMembershipExpirationCommand extends Command
{
use CommandTrait;
use SmsNotifierTrait;
public const CMD = 'app:notify-platform-membership-expiration';
public const DESCRIPTION = 'Notify expiring platform membership.';
public function __construct(
private readonly UserRepository $userRepository,
private readonly TranslatorInterface $translator,
private readonly AppMailer $appMailer,
private readonly SmsNotifier $notifier,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
#[Autowire('%brand%')]
private readonly string $brand,
private readonly ConfigurationRepository $configurationRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this->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;
}
}

View file

@ -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();
}
}

View file

@ -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 [
return $editFields;
}
$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;
}
}

View file

@ -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;

View file

@ -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');
}
}

View file

@ -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);
}

View file

@ -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 */

View file

@ -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'),
];
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Mailer\Email\Command;
use App\Controller\i18nTrait;
use App\Entity\User;
use App\Mailer\AppMailer;
use App\Mailer\Email\EmailInterface;
use App\Mailer\Email\EmailTrait;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\Translation\TranslatorInterface;
use Webmozart\Assert\Assert;
class EndPlatformMembershipMail implements EmailInterface
{
use EmailTrait;
use i18nTrait;
public function __construct(
private readonly TranslatorInterface $translator,
#[Autowire('%brand%')]
private readonly string $brand,
) {
}
/**
* @param array<string, mixed> $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)
;
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Mailer\Email\Command;
use App\Controller\i18nTrait;
use App\Entity\User;
use App\Mailer\AppMailer;
use App\Mailer\Email\EmailInterface;
use App\Mailer\Email\EmailTrait;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\Translation\TranslatorInterface;
use Webmozart\Assert\Assert;
class NotifyPlatformMembershipExpirationMail implements EmailInterface
{
use EmailTrait;
use i18nTrait;
public function __construct(
private readonly TranslatorInterface $translator,
#[Autowire('%brand%')]
private readonly string $brand,
) {
}
/**
* @param array<string, mixed> $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)
;
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Mailer\Email\Payment;
use App\Controller\i18nTrait;
use App\Entity\User;
use App\Mailer\AppMailer;
use App\Mailer\Email\EmailInterface;
use App\Mailer\Email\EmailTrait;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\Translation\TranslatorInterface;
use Webmozart\Assert\Assert;
class PlatformMembershipPaidMail implements EmailInterface
{
use EmailTrait;
use i18nTrait;
public function __construct(
private readonly TranslatorInterface $translator,
#[Autowire('%brand%')]
private readonly string $brand,
) {
}
/**
* @param array<string, mixed> $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)
;
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Message\Command\Payment;
use App\Entity\PaymentToken;
use App\MessageHandler\Command\Payment\PlatformMembershipPaidCommandHandler;
use Symfony\Component\Uid\Uuid;
/**
* @see PlatformMembershipPaidCommandHandler
*/
final class PlatformMembershipPaidCommand
{
public function __construct(
public readonly Uuid $platformOfferId,
public readonly Uuid $userId,
public readonly PaymentToken $paymentToken,
) {
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\MessageHandler\Command\Payment;
use App\Doctrine\Manager\UserManager;
use App\Mailer\AppMailer;
use App\Mailer\Email\Payment\PlatformMembershipPaidMail;
use App\Message\Command\Payment\PlatformMembershipPaidCommand;
use App\Repository\ConfigurationRepository;
use App\Repository\PlatformOfferRepository;
use App\Repository\UserRepository;
use Carbon\CarbonImmutable;
use Payum\Core\Payum;
use Payum\Core\Request\GetHumanStatus;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class PlatformMembershipPaidCommandHandler
{
public function __construct(
private readonly PlatformOfferRepository $platformOfferRepository,
private readonly UserRepository $userRepository,
private readonly UserManager $userManager,
private readonly Payum $payum,
private readonly AppMailer $mailer,
private readonly ConfigurationRepository $configurationRepository,
) {
}
public function __invoke(PlatformMembershipPaidCommand $message): GetHumanStatus
{
$platformOffer = $this->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;
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints\User;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_CLASS)]
class MembershipPaid extends Constraint
{
public string $message = 'validator.user.membership_paid';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints\User;
use App\Entity\User;
use App\Enum\OfferType;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class MembershipPaidValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof MembershipPaid) {
throw new UnexpectedTypeException($constraint, MembershipPaid::class);
}
if (!$value instanceof User) {
throw new UnexpectedValueException($value, User::class);
}
if (!$value->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();
}
}
}

View file

@ -0,0 +1,11 @@
{% trans_default_domain 'email' %}
{% set i18n_prefix = _self|i18n_prefix %}
<p>{{ (i18n_prefix ~ '.h1')|trans }}</p>
<p>{{ (i18n_prefix ~ '.p1')|trans({'%endAt%': user.endAt, '%platform%': platform}) }}</p>
<a href="{{ url('app_login') }}">{{ (i18n_prefix ~ '.loginLink')|trans }}</a>
<p>{{ brand }}</p>

View file

@ -0,0 +1,9 @@
{% trans_default_domain 'email' %}
{% set i18n_prefix = _self|i18n_prefix %}
<p>{{ (i18n_prefix ~ '.h1')|trans }}</p>
<p>{{ (i18n_prefix ~ '.p1')|trans({'%platform%': platform, '%days%': days}) }}</p>
<p>{{ brand }}</p>

View file

@ -0,0 +1,9 @@
{% trans_default_domain 'email' %}
{% set i18n_prefix = _self|i18n_prefix %}
<p>{{ (i18n_prefix ~ '.h1')|trans }}</p>
<p>{{ (i18n_prefix ~ '.p1')|trans({'%startAt%': user.startAt, '%endAt%': user.endAt, '%platform%': platform}) }}</p>
<p>{{ brand }}</p>

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Command;
use App\Command\EndPlatformMembershipCommand;
use App\Test\ContainerRepositoryTrait;
use App\Test\ContainerTrait;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
final class EndPlatformMembershipCommandTest extends KernelTestCase
{
use ContainerTrait;
use ContainerRepositoryTrait;
use RefreshDatabaseTrait;
public function testExecute(): void
{
$kernel = self::bootKernel();
$this->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);
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Command;
use App\Command\NotifyPlatformMembershipExpirationCommand;
use App\Test\ContainerRepositoryTrait;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
final class NotifyPlatformMembershipExpirationCommandTest extends KernelTestCase
{
use ContainerRepositoryTrait;
use RefreshDatabaseTrait;
public function testExecute(): void
{
$kernel = self::bootKernel();
// 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(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);
}
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\MessageHandler\Payment;
use App\Entity\Payment;
use App\Entity\PaymentToken;
use App\Entity\User;
use App\Message\Command\Payment\PlatformMembershipPaidCommand;
use App\MessageHandler\Command\Payment\PlatformMembershipPaidCommandHandler;
use App\Test\ContainerRepositoryTrait;
use App\Tests\TestReference;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class PlatformMembershipPaidCommandHandlerTest extends KernelTestCase
{
use RefreshDatabaseTrait;
use ContainerRepositoryTrait;
/**
* Just to test when the status is not "captured".
*/
public function testDoneStatusFailed(): 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);
$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());
}
}

View file

@ -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);
}
}

View file

@ -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;

View file

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Controller\Payment\PlatformMembership;
use App\Controller\Payment\PlatformMembership\DoneAction;
use App\Entity\PaymentToken;
use App\Entity\PlatformOffer;
use App\Entity\User;
use App\MessageBus\CommandBusInterface;
use Payum\Core\Payum;
use Payum\Core\Request\GetHumanStatus;
use Payum\Core\Security\HttpRequestVerifierInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBag;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\Translation\TranslatorInterface;
class DoneActionTest extends TestCase
{
/**
* Too complicated, the controller should be refactored.
*/
public function testUnprocessableEntityHttpException(): void
{
$httpRequestVerifierInterface = $this->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;
}
}

View file

@ -373,6 +373,11 @@
<target>Nombre de groupes</target>
</trans-unit>
<trans-unit id="mFV6gsn" resname="Membership Paid">
<source>Membership Paid</source>
<target>Abonnement à la plateforme payé</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="Zs247Lc" resname="app.controller.admin.user_crud_controller.expires_in.formatted_value">
<source>app.controller.admin.user_crud_controller.expires_in.formatted_value</source>
<target>%days% jour(s).</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -7,7 +7,7 @@
<body>
<trans-unit id="HkHjuo7" resname="app.controller.payment.platform_membership.done_action.flash.success">
<source>app.controller.payment.platform_membership.done_action.flash.success</source>
<target>Paiement réussi. Vous êtes désormais membre de la plateforme %platform%.</target>
<target>Paiement réussi. Vous êtes désormais membre de la plateforme %platform%. Veuillez renseigner votre adresse afin de pouvoir utiliser les différents services.</target>
</trans-unit>
<trans-unit id="iikpmn8" resname="app.controller.payment.platform_membership.done_action.status.new">

View file

@ -15,11 +15,21 @@
<target>Votre compte a bien été créé. Veuillez renseigner votre adresse afin de pouvoir utiliser les différents services.</target>
</trans-unit>
<trans-unit id="" resname="app.controller.security.account_create_controller.step2.with_invitation.flash.success">
<trans-unit id="jaHXivi" resname="app.controller.security.account_create_controller.step2.global_paid_membership.flash.success">
<source>app.controller.security.account_create_controller.step2.global_paid_membership.flash.success</source>
<target>Votre compte a bien été créé. Pour accéder à la plateforme, veuillez payer l'adhésion.</target>
</trans-unit>
<trans-unit id="ueGTY73" resname="app.controller.security.account_create_controller.step2.with_invitation.flash.success">
<source>app.controller.security.account_create_controller.step2.with_invitation.flash.success</source>
<target>Votre compte a bien été créé. Vous pouvez accepter l'invitation sur la page du groupe.</target>
</trans-unit>
<trans-unit id="MWzA9dr" resname="app.controller.security.account_create_controller.step2.with_invitation.global_paid_membership.flash.success">
<source>app.controller.security.account_create_controller.step2.with_invitation.global_paid_membership.flash.success</source>
<target>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.</target>
</trans-unit>
<trans-unit id="jDAjgu1" resname="app.controller.security.account_create_controller.step2.user_not_found.warning">
<source>app.controller.security.account_create_controller.step2.user_not_found.warning</source>
<target>Aucun·e utilisateur·rice correspondant à ce code n'a été trouvé·e. Si votre compte est déjà confirmé veuillez vous connecter.</target>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="DdQRDUh" resname="app.mailer.email.command.end_platform_membership_mail.subject">
<source>app.mailer.email.command.end_platform_membership_mail.subject</source>
<target>%brand% : Expiration de votre adhésion à la plateforme %platform%.</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="6AMBY16" resname="app.mailer.email.command.notify_platform_membership_expiration_mail.subject">
<source>app.mailer.email.command.notify_platform_membership_expiration_mail.subject</source>
<target>%brand% : expiration de l'adhésion à la plateforme %platform% dans %days% jour(s).</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="P5fekcX" resname="app.mailer.email.payment.platform_membership_paid_mail.subject">
<source>app.mailer.email.command.platform_membership_paid_mail.subject</source>
<target>%brand% : Confirmation de votre adhésion à la plateforme %platform%.</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="dMFTkCX" resname="templates.email.command.end_platform_membership.h1">
<source>emplates.email.command.end_platform_membership.h1</source>
<target>Bonjour !</target>
</trans-unit>
<trans-unit id="nAjYzR4" resname="templates.email.command.end_platform_membership.p1">
<source>templates.email.command.end_platform_membership.p1</source>
<target>Votre adhésion à la plateforme %platform% a expiré le %endAt%.
Connectez-vous à votre compte pour renouveler votre adhésion. </target>
</trans-unit>
<trans-unit id="kcvM4iP" resname="templates.email.command.end_platform_membership.loginLink">
<source>templates.email.command.end_platform_membership.loginLink</source>
<target>Me connecter</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="cmeQr4r" resname="templates.email.command.notify_membership_expiration.h1">
<source>templates.email.command.notify_membership_expiration.h1</source>
<target>Bonjour !</target>
</trans-unit>
<trans-unit id="YUhVmRj" resname="templates.email.command.notify_membership_expiration.p1">
<source>templates.email.command.notify_membership_expiration.p1</source>
<target>Votre adhésion à la plateform %platform% expire dans %days% jour(s).</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="n6g8FMV" resname="templates.email.payment.platform_membership_paid.h1">
<source>emplates.email.payment.platform_membership_paid.h1</source>
<target>Bonjour !</target>
</trans-unit>
<trans-unit id="8yoMsLT" resname="templates.email.payment.platform_membership_paid.p1">
<source>templates.email.payment.platform_membership_paid.p1</source>
<target>Votre adhésion à la plateforme %platform% a bien été pris en compte le %startAt%.
Il expirera le %endAt%</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="qTjiw69" resname="validator.user.membership_paid">
<source>validator.user.membership_paid</source>
<target>Veuillez remplir ce champ</target>
</trans-unit>
</body>
</file>
</xliff>