From ab52a8d02f2c75335e851503b98d988ae910d375 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 19 Mar 2026 23:49:48 +0100 Subject: [PATCH] Add payouts, PDF attestations, sub-accounts, and webhook improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payout system: - Create Payout entity (stripePayoutId, status, amount, currency, destination, arrivalDate) - Webhook handles payout.created/updated/paid/failed/canceled with email notification - Payout list in /mon-compte virements tab with status badges - PDF attestation on paid payouts with email attachment PDF attestation: - dompdf with DejaVu Sans font, yellow-orange gradient background - Orange centered title bar, E-Cosplay logo, emitter/beneficiary info blocks - QR code linking to /attestation/check/{payoutId} for authenticity verification - Public verification page: shows payout details if valid, error if altered - Legal disclaimer and CGV reference - Button visible only when status is paid, opens in new tab Sub-accounts: - Add parentOrganizer (self-referencing ManyToOne) and subAccountPermissions (JSON) to User - Permissions: scanner (validate tickets), events (CRUD), tickets (free invitations) - Create sub-account with random password, send email with credentials - Edit page with name/email/permissions checkboxes - Delete with confirmation - hasPermission() helper method Account improvements: - Block entire page for unapproved organizers with validation pending message - Display stripeStatus in Stripe Connect banners - Remove test payout button Webhook v2 Connect events: - v2.core.account.created/updated/closed → update stripeStatus - capability_status_updated → sync charges/payouts enabled from capabilities - PayoutPdfService for reusable PDF generation Migrations: stripeStatus, Payout table, sub-account fields, drop pdfPath Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations/Version20260319214513.php | 35 ++++ migrations/Version20260319215018.php | 35 ++++ migrations/Version20260319215953.php | 31 ++++ migrations/Version20260319220425.php | 31 ++++ migrations/Version20260319222449.php | 37 ++++ phpstan.neon | 3 +- src/Controller/AccountController.php | 166 +++++++++++++++++- src/Controller/AttestationController.php | 26 +++ src/Controller/StripeWebhookController.php | 160 +++++++++++++++-- src/Entity/Payout.php | 159 +++++++++++++++++ src/Entity/User.php | 58 ++++++ src/Repository/PayoutRepository.php | 18 ++ src/Service/PayoutPdfService.php | 69 ++++++++ templates/account/edit_subaccount.html.twig | 61 +++++++ templates/account/index.html.twig | 149 +++++++++++++++- templates/attestation/check.html.twig | 60 +++++++ templates/attestation/not_found.html.twig | 14 ++ templates/email/payout_update.html.twig | 44 +++++ templates/email/subaccount_created.html.twig | 39 ++++ templates/pdf/payout_attestation.html.twig | 165 +++++++++++++++++ tests/Controller/AccountControllerTest.php | 44 +++-- .../Controller/AttestationControllerTest.php | 47 +++++ .../StripeWebhookControllerTest.php | 84 --------- tests/Entity/PayoutTest.php | 45 +++++ tests/Entity/UserTest.php | 23 +++ 25 files changed, 1476 insertions(+), 127 deletions(-) create mode 100644 migrations/Version20260319214513.php create mode 100644 migrations/Version20260319215018.php create mode 100644 migrations/Version20260319215953.php create mode 100644 migrations/Version20260319220425.php create mode 100644 migrations/Version20260319222449.php create mode 100644 src/Controller/AttestationController.php create mode 100644 src/Entity/Payout.php create mode 100644 src/Repository/PayoutRepository.php create mode 100644 src/Service/PayoutPdfService.php create mode 100644 templates/account/edit_subaccount.html.twig create mode 100644 templates/attestation/check.html.twig create mode 100644 templates/attestation/not_found.html.twig create mode 100644 templates/email/payout_update.html.twig create mode 100644 templates/email/subaccount_created.html.twig create mode 100644 templates/pdf/payout_attestation.html.twig create mode 100644 tests/Controller/AttestationControllerTest.php create mode 100644 tests/Entity/PayoutTest.php diff --git a/migrations/Version20260319214513.php b/migrations/Version20260319214513.php new file mode 100644 index 0000000..c287c0e --- /dev/null +++ b/migrations/Version20260319214513.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE "user" ADD stripe_status VARCHAR(50) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ALTER stripe_charges_enabled DROP DEFAULT'); + $this->addSql('ALTER TABLE "user" ALTER stripe_payouts_enabled DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE "user" DROP stripe_status'); + $this->addSql('ALTER TABLE "user" ALTER stripe_charges_enabled SET DEFAULT false'); + $this->addSql('ALTER TABLE "user" ALTER stripe_payouts_enabled SET DEFAULT false'); + } +} diff --git a/migrations/Version20260319215018.php b/migrations/Version20260319215018.php new file mode 100644 index 0000000..870ba2d --- /dev/null +++ b/migrations/Version20260319215018.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE payout (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, stripe_payout_id VARCHAR(255) NOT NULL, status VARCHAR(50) NOT NULL, amount INT NOT NULL, currency VARCHAR(10) NOT NULL, destination VARCHAR(255) DEFAULT NULL, stripe_account_id VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, arrival_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, organizer_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_4E2EA902795B3DB5 ON payout (stripe_payout_id)'); + $this->addSql('CREATE INDEX IDX_4E2EA902876C4DDA ON payout (organizer_id)'); + $this->addSql('ALTER TABLE payout ADD CONSTRAINT FK_4E2EA902876C4DDA FOREIGN KEY (organizer_id) REFERENCES "user" (id) NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE payout DROP CONSTRAINT FK_4E2EA902876C4DDA'); + $this->addSql('DROP TABLE payout'); + } +} diff --git a/migrations/Version20260319215953.php b/migrations/Version20260319215953.php new file mode 100644 index 0000000..1571891 --- /dev/null +++ b/migrations/Version20260319215953.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE payout ADD pdf_path VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE payout DROP pdf_path'); + } +} diff --git a/migrations/Version20260319220425.php b/migrations/Version20260319220425.php new file mode 100644 index 0000000..2b5fd50 --- /dev/null +++ b/migrations/Version20260319220425.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE payout DROP pdf_path'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE payout ADD pdf_path VARCHAR(255) DEFAULT NULL'); + } +} diff --git a/migrations/Version20260319222449.php b/migrations/Version20260319222449.php new file mode 100644 index 0000000..a374315 --- /dev/null +++ b/migrations/Version20260319222449.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE "user" ADD sub_account_permissions JSON DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD parent_organizer_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D649D70210F4 FOREIGN KEY (parent_organizer_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_8D93D649D70210F4 ON "user" (parent_organizer_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D649D70210F4'); + $this->addSql('DROP INDEX IDX_8D93D649D70210F4'); + $this->addSql('ALTER TABLE "user" DROP sub_account_permissions'); + $this->addSql('ALTER TABLE "user" DROP parent_organizer_id'); + } +} diff --git a/phpstan.neon b/phpstan.neon index 5fe5b4e..641eb10 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,8 +6,9 @@ parameters: - src/Kernel.php ignoreErrors: - - message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User)::\$id .* never assigned#' + message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout)::\$id .* never assigned#' paths: - src/Entity/EmailTracking.php - src/Entity/MessengerLog.php - src/Entity/User.php + - src/Entity/Payout.php diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index e6a1e4d..1ffb048 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -2,13 +2,16 @@ namespace App\Controller; +use App\Entity\Payout; use App\Entity\User; +use App\Service\MailerService; +use App\Service\PayoutPdfService; use App\Service\StripeService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -35,9 +38,24 @@ class AccountController extends AbstractController } } + $payouts = []; + $subAccounts = []; + if ($isOrganizer) { + $payouts = $em->getRepository(Payout::class)->findBy( + ['organizer' => $user], + ['createdAt' => 'DESC'], + ); + $subAccounts = $em->getRepository(User::class)->findBy( + ['parentOrganizer' => $user], + ['createdAt' => 'DESC'], + ); + } + return $this->render('account/index.html.twig', [ 'tab' => $tab, 'isOrganizer' => $isOrganizer, + 'payouts' => $payouts, + 'subAccounts' => $subAccounts, ]); } @@ -83,6 +101,7 @@ class AccountController extends AbstractController if (!$user->getStripeAccountId()) { $accountId = $stripeService->createAccountConnect($user); $user->setStripeAccountId($accountId); + $user->setStripeStatus('started'); $em->flush(); } @@ -134,6 +153,135 @@ class AccountController extends AbstractController return $this->redirectToRoute('app_account_stripe_connect'); } + #[Route('/mon-compte/sous-compte/creer', name: 'app_account_create_subaccount', methods: ['POST'])] + public function createSubAccount(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, MailerService $mailerService): Response + { + /** @var User $user */ + $user = $this->getUser(); + + if (!$this->isGranted('ROLE_ORGANIZER')) { + return $this->redirectToRoute('app_account'); + } + + $plainPassword = bin2hex(random_bytes(8)); + + $subAccount = new User(); + $subAccount->setFirstName(trim($request->request->getString('first_name'))); + $subAccount->setLastName(trim($request->request->getString('last_name'))); + $subAccount->setEmail(trim($request->request->getString('email'))); + $subAccount->setPassword($passwordHasher->hashPassword($subAccount, $plainPassword)); + $subAccount->setIsVerified(true); + $subAccount->setEmailVerifiedAt(new \DateTimeImmutable()); + $subAccount->setParentOrganizer($user); + + $permissions = $request->request->all('permissions'); + $subAccount->setSubAccountPermissions($permissions); + + $em->persist($subAccount); + $em->flush(); + + $mailerService->sendEmail( + to: $subAccount->getEmail(), + subject: 'Votre sous-compte E-Ticket a ete cree', + content: $this->renderView('email/subaccount_created.html.twig', [ + 'firstName' => $subAccount->getFirstName(), + 'organizerName' => $user->getCompanyName() ?? $user->getFirstName().' '.$user->getLastName(), + 'email' => $subAccount->getEmail(), + 'password' => $plainPassword, + 'permissions' => $permissions, + ]), + withUnsubscribe: false, + ); + + $this->addFlash('success', sprintf('Sous-compte %s %s cree.', $subAccount->getFirstName(), $subAccount->getLastName())); + + return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']); + } + + #[Route('/mon-compte/sous-compte/{id}', name: 'app_account_edit_subaccount_page', methods: ['GET'])] + public function editSubAccountPage(User $subAccount): Response + { + /** @var User $user */ + $user = $this->getUser(); + + if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + return $this->render('account/edit_subaccount.html.twig', [ + 'subAccount' => $subAccount, + ]); + } + + #[Route('/mon-compte/sous-compte/{id}/modifier', name: 'app_account_edit_subaccount', methods: ['POST'])] + public function editSubAccount(User $subAccount, Request $request, EntityManagerInterface $em): Response + { + /** @var User $user */ + $user = $this->getUser(); + + if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $permissions = $request->request->all('permissions'); + $subAccount->setSubAccountPermissions($permissions); + $subAccount->setFirstName(trim($request->request->getString('first_name'))); + $subAccount->setLastName(trim($request->request->getString('last_name'))); + $subAccount->setEmail(trim($request->request->getString('email'))); + $em->flush(); + + $this->addFlash('success', sprintf('Sous-compte %s %s mis a jour.', $subAccount->getFirstName(), $subAccount->getLastName())); + + return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']); + } + + #[Route('/mon-compte/sous-compte/{id}/supprimer', name: 'app_account_delete_subaccount', methods: ['POST'])] + public function deleteSubAccount(User $subAccount, EntityManagerInterface $em): Response + { + /** @var User $user */ + $user = $this->getUser(); + + if ($subAccount->getParentOrganizer()?->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $name = sprintf('%s %s', $subAccount->getFirstName(), $subAccount->getLastName()); + $em->remove($subAccount); + $em->flush(); + + $this->addFlash('success', sprintf('Sous-compte %s supprime.', $name)); + + return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']); + } + + #[Route('/mon-compte/test-payout', name: 'app_account_test_payout', methods: ['POST'])] + public function testPayout(EntityManagerInterface $em): Response + { + /** @var User $user */ + $user = $this->getUser(); + + if (!$this->isGranted('ROLE_ORGANIZER') || !$user->getStripeAccountId()) { + return $this->redirectToRoute('app_account'); + } + + $payout = new Payout(); + $payout->setOrganizer($user); + $payout->setStripePayoutId('po_test_'.bin2hex(random_bytes(8))); + $payout->setStatus('paid'); + $payout->setAmount(random_int(1000, 50000)); + $payout->setCurrency('eur'); + $payout->setDestination('ba_test_bank'); + $payout->setStripeAccountId($user->getStripeAccountId()); + $payout->setArrivalDate(new \DateTimeImmutable('+2 days')); + + $em->persist($payout); + $em->flush(); + + $this->addFlash('success', sprintf('Payout test cree : %s (%.2f EUR)', $payout->getStripePayoutId(), $payout->getAmountDecimal())); + + return $this->redirectToRoute('app_account', ['tab' => 'payouts']); + } + #[Route('/mon-compte/stripe-dashboard', name: 'app_account_stripe_dashboard')] public function stripeDashboard(StripeService $stripeService): Response { @@ -154,4 +302,20 @@ class AccountController extends AbstractController return $this->redirectToRoute('app_account'); } } + + #[Route('/mon-compte/payout/{id}/attestation', name: 'app_account_payout_pdf')] + public function payoutPdf(Payout $payout, PayoutPdfService $pdfService): Response + { + /** @var User $user */ + $user = $this->getUser(); + + if ($payout->getOrganizer()->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + return new Response($pdfService->generate($payout), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="attestation_'.$payout->getStripePayoutId().'.pdf"', + ]); + } } diff --git a/src/Controller/AttestationController.php b/src/Controller/AttestationController.php new file mode 100644 index 0000000..c50307c --- /dev/null +++ b/src/Controller/AttestationController.php @@ -0,0 +1,26 @@ +getRepository(Payout::class)->findOneBy(['stripePayoutId' => $stripePayoutId]); + + if (!$payout) { + return $this->render('attestation/not_found.html.twig'); + } + + return $this->render('attestation/check.html.twig', [ + 'payout' => $payout, + ]); + } +} diff --git a/src/Controller/StripeWebhookController.php b/src/Controller/StripeWebhookController.php index 771cfb1..06df4c2 100644 --- a/src/Controller/StripeWebhookController.php +++ b/src/Controller/StripeWebhookController.php @@ -2,7 +2,10 @@ namespace App\Controller; +use App\Entity\Payout; use App\Entity\User; +use App\Service\MailerService; +use App\Service\PayoutPdfService; use App\Service\StripeService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -13,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route; class StripeWebhookController extends AbstractController { #[Route('/stripe/webhook', name: 'app_stripe_webhook', methods: ['POST'])] - public function webhook(Request $request, StripeService $stripeService, EntityManagerInterface $em): Response + public function webhook(Request $request, StripeService $stripeService, EntityManagerInterface $em, MailerService $mailerService, PayoutPdfService $pdfService): Response { $payload = $request->getContent(); $signature = $request->headers->get('Stripe-Signature', ''); @@ -24,34 +27,153 @@ class StripeWebhookController extends AbstractController return new Response('Invalid signature', 400); } - if ('account.updated' === $event->type) { - $this->handleAccountSync($event, $stripeService, $em); - } + $type = $event->type ?? null; + $data = json_decode($payload, true) ?? []; + + match ($type) { + 'v2.core.account.created' => $this->handleAccountStatus($data, 'started', $em), + 'v2.core.account.updated' => $this->handleAccountStatus($data, 'updated', $em), + 'v2.core.account.closed' => $this->handleAccountStatus($data, 'closed', $em), + 'v2.core.account[configuration.merchant].capability_status_updated' => $this->handleCapabilityUpdate($data, $em), + 'v2.core.account[configuration.recipient].capability_status_updated' => $this->handleCapabilityUpdate($data, $em), + 'payout.created', 'payout.updated', 'payout.paid', 'payout.failed', 'payout.canceled' => $this->handlePayout($event, $em, $mailerService, $pdfService), + default => null, + }; return new Response('OK', 200); } - private function handleAccountSync(\Stripe\Event $event, StripeService $stripeService, EntityManagerInterface $em): void + /** + * @param array $data + */ + private function handleAccountStatus(array $data, string $status, EntityManagerInterface $em): void { - $accountId = $event->data->object->id ?? $event->account ?? null; - - if (!$accountId) { - return; - } - - $user = $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]); + $user = $this->findUserByEvent($data, $em); if (!$user) { return; } - try { - $account = $stripeService->getClient()->accounts->retrieve($accountId); - $user->setStripeChargesEnabled((bool) $account->charges_enabled); - $user->setStripePayoutsEnabled((bool) $account->payouts_enabled); - $em->flush(); - } catch (\Throwable) { - // Stripe API unavailable + $user->setStripeStatus($status); + + if ('closed' === $status) { + $user->setStripeChargesEnabled(false); + $user->setStripePayoutsEnabled(false); + } + + $em->flush(); + } + + /** + * @param array $data + */ + private function handleCapabilityUpdate(array $data, EntityManagerInterface $em): void + { + $user = $this->findUserByEvent($data, $em); + + if (!$user) { + return; + } + + $after = $data['changes']['after']['configuration'] ?? []; + + $merchantCapabilities = $after['merchant']['capabilities'] ?? []; + $cardPayments = $merchantCapabilities['card_payments']['status'] ?? null; + if ('active' === $cardPayments) { + $user->setStripeChargesEnabled(true); + } + + $recipientCapabilities = $after['recipient']['capabilities'] ?? []; + $payouts = $recipientCapabilities['stripe_balance']['payouts']['status'] ?? null; + $merchantPayouts = $merchantCapabilities['stripe_balance']['payouts']['status'] ?? null; + if ('active' === $payouts || 'active' === $merchantPayouts) { + $user->setStripePayoutsEnabled(true); + } + + if ($user->isStripeChargesEnabled() && $user->isStripePayoutsEnabled()) { + $user->setStripeStatus('active'); + } + + $em->flush(); + } + + private function handlePayout(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService, PayoutPdfService $pdfService): void + { + $payoutData = $event->data->object; + $payoutId = $payoutData->id ?? null; + $accountId = $event->account ?? null; + + if (!$payoutId) { + return; + } + + $payout = $em->getRepository(Payout::class)->findOneBy(['stripePayoutId' => $payoutId]); + $user = null; + + if (!$payout) { + $payout = new Payout(); + $payout->setStripePayoutId($payoutId); + $payout->setStripeAccountId($accountId); + + if ($accountId) { + $user = $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]); + if ($user) { + $payout->setOrganizer($user); + } + } + + $em->persist($payout); + } else { + $user = $payout->getOrganizer(); + } + + $payout->setStatus((string) ($payoutData->status ?? 'unknown')); + $payout->setAmount((int) ($payoutData->amount ?? 0)); + $payout->setCurrency((string) ($payoutData->currency ?? 'eur')); + $payout->setDestination((string) ($payoutData->destination ?? '')); + + if (isset($payoutData->arrival_date) && $payoutData->arrival_date) { + $payout->setArrivalDate(new \DateTimeImmutable('@'.$payoutData->arrival_date)); + } + + $em->flush(); + + if ($user) { + $attachments = null; + + if ('paid' === $payout->getStatus()) { + $pdfPath = $pdfService->generateToFile($payout); + $attachments = [['path' => $pdfPath, 'name' => 'attestation_'.$payout->getStripePayoutId().'.pdf']]; + } + + $mailerService->sendEmail( + to: $user->getEmail(), + subject: sprintf('Virement %s - E-Ticket', strtoupper($payout->getStatus())), + content: $this->renderView('email/payout_update.html.twig', [ + 'firstName' => $user->getFirstName(), + 'payoutId' => $payout->getStripePayoutId(), + 'amount' => number_format($payout->getAmountDecimal(), 2, ',', ' '), + 'currency' => $payout->getCurrency(), + 'status' => $payout->getStatus(), + 'arrivalDate' => $payout->getArrivalDate()?->format('d/m/Y'), + ]), + withUnsubscribe: false, + attachments: $attachments, + ); } } + + /** + * @param array $data + */ + private function findUserByEvent(array $data, EntityManagerInterface $em): ?User + { + $accountId = $data['related_object']['id'] ?? null; + + if (!$accountId) { + return null; + } + + return $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]); + } } diff --git a/src/Entity/Payout.php b/src/Entity/Payout.php new file mode 100644 index 0000000..0fbc6ee --- /dev/null +++ b/src/Entity/Payout.php @@ -0,0 +1,159 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getOrganizer(): User + { + return $this->organizer; + } + + public function setOrganizer(User $organizer): static + { + $this->organizer = $organizer; + + return $this; + } + + public function getStripePayoutId(): string + { + return $this->stripePayoutId; + } + + public function setStripePayoutId(string $stripePayoutId): static + { + $this->stripePayoutId = $stripePayoutId; + + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function setAmount(int $amount): static + { + $this->amount = $amount; + + return $this; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setCurrency(string $currency): static + { + $this->currency = $currency; + + return $this; + } + + public function getDestination(): ?string + { + return $this->destination; + } + + public function setDestination(?string $destination): static + { + $this->destination = $destination; + + return $this; + } + + public function getStripeAccountId(): ?string + { + return $this->stripeAccountId; + } + + public function setStripeAccountId(?string $stripeAccountId): static + { + $this->stripeAccountId = $stripeAccountId; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getArrivalDate(): ?\DateTimeImmutable + { + return $this->arrivalDate; + } + + public function setArrivalDate(?\DateTimeImmutable $arrivalDate): static + { + $this->arrivalDate = $arrivalDate; + + return $this; + } + + public function getAmountDecimal(): float + { + return $this->amount / 100; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 36ddec3..4515bf1 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -88,12 +88,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 255, nullable: true)] private ?string $stripeAccountId = null; + #[ORM\Column(length: 50, nullable: true)] + private ?string $stripeStatus = null; + #[ORM\Column] private bool $stripeChargesEnabled = false; #[ORM\Column] private bool $stripePayoutsEnabled = false; + #[ORM\ManyToOne(targetEntity: self::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?self $parentOrganizer = null; + + /** @var list */ + #[ORM\Column(nullable: true)] + private ?array $subAccountPermissions = null; + #[ORM\Column(length: 64, nullable: true)] private ?string $emailVerificationToken = null; @@ -317,6 +328,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getStripeStatus(): ?string + { + return $this->stripeStatus; + } + + public function setStripeStatus(?string $stripeStatus): static + { + $this->stripeStatus = $stripeStatus; + + return $this; + } + public function isStripeChargesEnabled(): bool { return $this->stripeChargesEnabled; @@ -341,6 +364,41 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getParentOrganizer(): ?self + { + return $this->parentOrganizer; + } + + public function setParentOrganizer(?self $parentOrganizer): static + { + $this->parentOrganizer = $parentOrganizer; + + return $this; + } + + /** + * @return list|null + */ + public function getSubAccountPermissions(): ?array + { + return $this->subAccountPermissions; + } + + /** + * @param list|null $subAccountPermissions + */ + public function setSubAccountPermissions(?array $subAccountPermissions): static + { + $this->subAccountPermissions = $subAccountPermissions; + + return $this; + } + + public function hasPermission(string $permission): bool + { + return null !== $this->subAccountPermissions && \in_array($permission, $this->subAccountPermissions, true); + } + public function getResetCode(): ?string { return $this->resetCode; diff --git a/src/Repository/PayoutRepository.php b/src/Repository/PayoutRepository.php new file mode 100644 index 0000000..2742f43 --- /dev/null +++ b/src/Repository/PayoutRepository.php @@ -0,0 +1,18 @@ + + */ +class PayoutRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Payout::class); + } +} diff --git a/src/Service/PayoutPdfService.php b/src/Service/PayoutPdfService.php new file mode 100644 index 0000000..77ef5dc --- /dev/null +++ b/src/Service/PayoutPdfService.php @@ -0,0 +1,69 @@ +projectDir.'/public/logo.png'; + $logoBase64 = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : ''; + + $checkUrl = $this->urlGenerator->generate('app_attestation_check', [ + 'stripePayoutId' => $payout->getStripePayoutId(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $qrCode = (new Builder( + writer: new PngWriter(), + data: $checkUrl, + encoding: new Encoding('UTF-8'), + size: 150, + margin: 5, + ))->build(); + + $qrBase64 = 'data:image/png;base64,'.base64_encode($qrCode->getString()); + + $html = $this->twig->render('pdf/payout_attestation.html.twig', [ + 'payout' => $payout, + 'logoBase64' => $logoBase64, + 'qrBase64' => $qrBase64, + 'checkUrl' => $checkUrl, + ]); + + $dompdf = new Dompdf(); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + return $dompdf->output(); + } + + public function generateToFile(Payout $payout): string + { + $dir = $this->projectDir.'/var/payouts'; + if (!is_dir($dir)) { + mkdir($dir, 0o755, true); + } + + $filename = $dir.'/attestation_'.$payout->getStripePayoutId().'.pdf'; + file_put_contents($filename, $this->generate($payout)); + + return $filename; + } +} diff --git a/templates/account/edit_subaccount.html.twig b/templates/account/edit_subaccount.html.twig new file mode 100644 index 0000000..5aefcca --- /dev/null +++ b/templates/account/edit_subaccount.html.twig @@ -0,0 +1,61 @@ +{% extends 'base.html.twig' %} + +{% block title %}Modifier sous-compte - E-Ticket{% endblock %} + +{% block body %} +
+

Modifier le sous-compte

+

{{ subAccount.firstName }} {{ subAccount.lastName }}

+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+

Permissions

+
+ + + +
+
+ +
+ + + Annuler + +
+
+
+
+{% endblock %} diff --git a/templates/account/index.html.twig b/templates/account/index.html.twig index 1d37983..0e79dbc 100644 --- a/templates/account/index.html.twig +++ b/templates/account/index.html.twig @@ -20,17 +20,31 @@ {% endfor %} + {% if isOrganizer and not app.user.approved %} +
+
+

Compte en cours de validation

+

Votre compte organisateur est en cours de validation par l'equipe E-Ticket. Vous recevrez un email une fois votre compte approuve.

+

Contactez contact@e-cosplay.fr pour toute question.

+
+ + {% else %} + {% if isOrganizer %} {% if not app.user.stripeAccountId %}
-

Configuration Stripe requise

+

Configuration Stripe requise + Statut : non configure +

Pour creer vos evenements, vendre des billets et recevoir vos paiements, vous devez creer votre compte vendeur via Stripe.

Creer mon compte Stripe
{% elseif not app.user.stripeChargesEnabled and not app.user.stripePayoutsEnabled %}
-

Verification Stripe en cours

+

Verification Stripe en cours + Statut : {{ app.user.stripeStatus ?? 'en attente' }} +

Votre compte Stripe est en cours de verification. Merci de patienter, vous serez notifie une fois la verification terminee.

Completer ma verification @@ -42,7 +56,9 @@ {% elseif app.user.stripeChargesEnabled and app.user.stripePayoutsEnabled %}
-

Stripe Connect actif — Paiements et virements actives.

+

Stripe Connect actif — Paiements et virements actives. + Statut : {{ app.user.stripeStatus }} +

Dashboard Stripe
@@ -53,7 +69,9 @@ {% else %}
-

Compte Stripe refuse

+

Compte Stripe refuse + Statut : {{ app.user.stripeStatus ?? 'refuse' }} +

Stripe a refuse votre compte vendeur. Vous ne pouvez pas utiliser nos services de vente pour le moment. Contactez contact@e-cosplay.fr pour plus d'informations.

{% endif %} @@ -117,18 +135,89 @@
{% elseif tab == 'subaccounts' %} + +
+

Creer un sous-compte

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+

Permissions

+
+ + + +
+
+ + +
+

Sous-comptes

- {% if isOrganizer and (not app.user.stripeChargesEnabled or not app.user.stripePayoutsEnabled) %} -
-

Configuration Stripe ou validation est requise !

-
- {% endif %} + {% if subAccounts|length > 0 %} + + + + + + + + + + + {% for sub in subAccounts %} + + + + + + + {% endfor %} + +
NomEmailPermissionsActions
{{ sub.firstName }} {{ sub.lastName }}{{ sub.email }} + {% for perm in sub.subAccountPermissions ?? [] %} + {% if perm == 'scanner' %} + Scanner + {% elseif perm == 'events' %} + Evenements + {% elseif perm == 'tickets' %} + Billets + {% endif %} + {% endfor %} + +
+ Editer +
+ +
+
+
+ {% else %}

Aucun sous-compte pour le moment.

+ {% endif %}
{% elseif tab == 'payouts' %} @@ -141,9 +230,50 @@

Configuration Stripe ou validation est requise !

{% endif %} + {% if payouts|length > 0 %} + + + + + + + + + + + + + + {% for payout in payouts %} + + + + + + + + + + {% endfor %} + +
IDMontantStatutDestinationArriveeDateAttestation
{{ payout.stripePayoutId }}{{ payout.amountDecimal|number_format(2, ',', ' ') }} {{ payout.currency|upper }} + {% if payout.status == 'paid' %} + {{ payout.status }} + {% elseif payout.status == 'failed' or payout.status == 'canceled' %} + {{ payout.status }} + {% else %} + {{ payout.status }} + {% endif %} + {{ payout.destination ?? '—' }}{{ payout.arrivalDate ? payout.arrivalDate|date('d/m/Y') : '—' }}{{ payout.createdAt|date('d/m/Y H:i') }} + {% if payout.status == 'paid' %} + PDF + {% endif %} +
+ {% else %}

Aucun virement pour le moment.

+ {% endif %}
{% elseif tab == 'settings' %} @@ -215,4 +345,5 @@
{% endif %} +{% endif %} {% endblock %} diff --git a/templates/attestation/check.html.twig b/templates/attestation/check.html.twig new file mode 100644 index 0000000..72b6f38 --- /dev/null +++ b/templates/attestation/check.html.twig @@ -0,0 +1,60 @@ +{% extends 'base.html.twig' %} + +{% block title %}Verification attestation - E-Ticket{% endblock %} + +{% block body %} +
+

Verification attestation

+

Ce document a ete verifie par la plateforme E-Ticket.

+ +
+

Attestation authentique

+

Ce document correspond a un virement enregistre dans notre systeme.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DetailValeur
Reference{{ payout.stripePayoutId }}
Montant{{ payout.amountDecimal|number_format(2, ',', ' ') }} {{ payout.currency|upper }}
Statut + {% if payout.status == 'paid' %} + {{ payout.status }} + {% elseif payout.status == 'failed' or payout.status == 'canceled' %} + {{ payout.status }} + {% else %} + {{ payout.status }} + {% endif %} +
Beneficiaire{{ payout.organizer.companyName ?? payout.organizer.firstName ~ ' ' ~ payout.organizer.lastName }}
Date d'arrivee{{ payout.arrivalDate ? payout.arrivalDate|date('d/m/Y') : '—' }}
Date de creation{{ payout.createdAt|date('d/m/Y H:i') }}
+
+
+{% endblock %} diff --git a/templates/attestation/not_found.html.twig b/templates/attestation/not_found.html.twig new file mode 100644 index 0000000..2e26534 --- /dev/null +++ b/templates/attestation/not_found.html.twig @@ -0,0 +1,14 @@ +{% extends 'base.html.twig' %} + +{% block title %}Attestation introuvable - E-Ticket{% endblock %} + +{% block body %} +
+

Verification attestation

+ +
+

Attestation introuvable

+

Ce document ne correspond a aucun virement enregistre dans notre systeme. Il est possible que l'attestation ait ete alteree ou falsifiee.

+
+
+{% endblock %} diff --git a/templates/email/payout_update.html.twig b/templates/email/payout_update.html.twig new file mode 100644 index 0000000..581aa93 --- /dev/null +++ b/templates/email/payout_update.html.twig @@ -0,0 +1,44 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Mise a jour de votre virement{% endblock %} + +{% block content %} +

Bonjour {{ firstName }} !

+

Votre virement a ete mis a jour.

+ + + + + + + + + + + + + + + + + + + + + {% if arrivalDate %} + + + + + {% endif %} + +
DetailValeur
Reference{{ payoutId }}
Montant{{ amount }} {{ currency|upper }}
Statut + {% if status == 'paid' %} + {{ status|upper }} + {% elseif status == 'failed' or status == 'canceled' %} + {{ status|upper }} + {% else %} + {{ status|upper }} + {% endif %} +
Date d'arrivee{{ arrivalDate }}
+{% endblock %} diff --git a/templates/email/subaccount_created.html.twig b/templates/email/subaccount_created.html.twig new file mode 100644 index 0000000..48d2804 --- /dev/null +++ b/templates/email/subaccount_created.html.twig @@ -0,0 +1,39 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Votre sous-compte E-Ticket{% endblock %} + +{% block content %} +

Bonjour {{ firstName }} !

+

Un sous-compte E-Ticket a ete cree pour vous par {{ organizerName }}.

+

Voici vos identifiants de connexion :

+ + + + + + + + + + + + + + + + + + + + + +
DetailValeur
Email{{ email }}
Mot de passe{{ password }}
Permissions + {% for perm in permissions %} + {% if perm == 'scanner' %}Scanner{% endif %} + {% if perm == 'events' %}Evenements{% endif %} + {% if perm == 'tickets' %}Billets{% endif %} + {% if not loop.last %}, {% endif %} + {% endfor %} +
+

Nous vous recommandons de changer votre mot de passe apres votre premiere connexion.

+{% endblock %} diff --git a/templates/pdf/payout_attestation.html.twig b/templates/pdf/payout_attestation.html.twig new file mode 100644 index 0000000..d8b0b89 --- /dev/null +++ b/templates/pdf/payout_attestation.html.twig @@ -0,0 +1,165 @@ + + + + + + + +
+

Attestation de virement

+ N° {{ payout.stripePayoutId }} +
+ +
+ + + + + +
+
+

Emetteur

+ {% if logoBase64 %} + E-Cosplay + {% endif %} +

E-Ticket

+

Association E-Cosplay

+

RNA : W022006988 — SIREN : 943121517

+

42 rue de Saint-Quentin, 02800 Beautor

+

contact@e-cosplay.fr

+
+
+
+

Beneficiaire

+

{{ payout.organizer.companyName ?? payout.organizer.firstName ~ ' ' ~ payout.organizer.lastName }}

+

{{ payout.organizer.firstName }} {{ payout.organizer.lastName }}

+ {% if payout.organizer.siret %}

SIRET : {{ payout.organizer.siret }}

{% endif %} + {% if payout.organizer.address %}

{{ payout.organizer.address }}, {{ payout.organizer.postalCode }} {{ payout.organizer.city }}

{% endif %} + {% if payout.organizer.phone %}

Tel : {{ payout.organizer.phone }}

{% endif %} +

{{ payout.organizer.email }}

+
+
+ +

L'association E-Cosplay atteste les informations suivantes suite aux informations recues par Stripe.

+ +
+
Montant du virement
+
{{ payout.amountDecimal|number_format(2, ',', ' ') }} {{ payout.currency|upper }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DetailValeur
Reference{{ payout.stripePayoutId }}
Statut + {% if payout.status == 'paid' %} + {{ payout.status }} + {% elseif payout.status == 'failed' or payout.status == 'canceled' %} + {{ payout.status }} + {% else %} + {{ payout.status }} + {% endif %} +
Destination bancaire{{ payout.destination ?? '—' }}
Date d'arrivee estimee{{ payout.arrivalDate ? payout.arrivalDate|date('d/m/Y') : '—' }}
Date de creation{{ payout.createdAt|date('d/m/Y H:i') }}
Compte Stripe{{ payout.stripeAccountId ?? '—' }}
+ + + + + + +
+

L'association E-Cosplay ne pourra etre tenue responsable des erreurs de virement emises par Stripe. Les informations presentees dans ce document sont conformes aux Conditions Generales de Vente (CGV) du site E-Ticket consultables a l'adresse ticket.e-cosplay.fr/cgv.

+

Verifiez l'authenticite : {{ checkUrl }}

+
+ {% if qrBase64 %} + QR Code + {% endif %} +
+ + +
+ + diff --git a/tests/Controller/AccountControllerTest.php b/tests/Controller/AccountControllerTest.php index e41709d..d620bcd 100644 --- a/tests/Controller/AccountControllerTest.php +++ b/tests/Controller/AccountControllerTest.php @@ -3,7 +3,6 @@ namespace App\Tests\Controller; use App\Entity\User; -use App\Service\StripeService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -94,7 +93,7 @@ class AccountControllerTest extends WebTestCase public function testOrganizerEventsTab(): void { $client = static::createClient(); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $client->loginUser($user); $client->request('GET', '/mon-compte?tab=events'); @@ -105,7 +104,7 @@ class AccountControllerTest extends WebTestCase public function testOrganizerSubaccountsTab(): void { $client = static::createClient(); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $client->loginUser($user); $client->request('GET', '/mon-compte?tab=subaccounts'); @@ -116,7 +115,7 @@ class AccountControllerTest extends WebTestCase public function testOrganizerPayoutsTab(): void { $client = static::createClient(); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $client->loginUser($user); $client->request('GET', '/mon-compte?tab=payouts'); @@ -127,7 +126,7 @@ class AccountControllerTest extends WebTestCase public function testOrganizerSettingsDisablesNameFields(): void { $client = static::createClient(); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $client->loginUser($user); $client->request('POST', '/mon-compte/parametres', [ @@ -138,10 +137,22 @@ class AccountControllerTest extends WebTestCase self::assertResponseRedirects('/mon-compte?tab=settings'); } + public function testOrganizerNotApprovedShowsBlockingMessage(): void + { + $client = static::createClient(); + $user = $this->createUser(['ROLE_ORGANIZER'], false); + + $client->loginUser($user); + $client->request('GET', '/mon-compte'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'en cours de validation'); + } + public function testOrganizerDefaultTabIsEvents(): void { $client = static::createClient(); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $client->loginUser($user); $client->request('GET', '/mon-compte'); @@ -163,7 +174,7 @@ class AccountControllerTest extends WebTestCase public function testOrganizerWithoutStripeShowsSetupMessage(): void { $client = static::createClient(); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $client->loginUser($user); $crawler = $client->request('GET', '/mon-compte'); @@ -176,7 +187,7 @@ class AccountControllerTest extends WebTestCase { $client = static::createClient(); $em = static::getContainer()->get(EntityManagerInterface::class); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $user->setStripeAccountId('acct_pending'); $em->flush(); @@ -191,7 +202,7 @@ class AccountControllerTest extends WebTestCase { $client = static::createClient(); $em = static::getContainer()->get(EntityManagerInterface::class); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $user->setStripeAccountId('acct_active'); $user->setStripeChargesEnabled(true); $user->setStripePayoutsEnabled(true); @@ -207,7 +218,7 @@ class AccountControllerTest extends WebTestCase public function testStripeConnectReturn(): void { $client = static::createClient(); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $client->loginUser($user); $client->request('GET', '/stripe/connect/return'); @@ -218,7 +229,7 @@ class AccountControllerTest extends WebTestCase public function testStripeConnectRefresh(): void { $client = static::createClient(); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $client->loginUser($user); $client->request('GET', '/stripe/connect/refresh'); @@ -230,7 +241,7 @@ class AccountControllerTest extends WebTestCase { $client = static::createClient(); $em = static::getContainer()->get(EntityManagerInterface::class); - $user = $this->createUser(['ROLE_ORGANIZER']); + $user = $this->createUser(['ROLE_ORGANIZER'], true); $user->setStripeAccountId('acct_cancel'); $em->flush(); @@ -246,7 +257,10 @@ class AccountControllerTest extends WebTestCase /** * @param list $roles */ - private function createUser(array $roles = []): User + /** + * @param list $roles + */ + private function createUser(array $roles = [], bool $approved = false): User { $em = static::getContainer()->get(EntityManagerInterface::class); @@ -257,6 +271,10 @@ class AccountControllerTest extends WebTestCase $user->setPassword('$2y$13$hashed'); $user->setRoles($roles); + if ($approved) { + $user->setIsApproved(true); + } + $em->persist($user); $em->flush(); diff --git a/tests/Controller/AttestationControllerTest.php b/tests/Controller/AttestationControllerTest.php new file mode 100644 index 0000000..9d49437 --- /dev/null +++ b/tests/Controller/AttestationControllerTest.php @@ -0,0 +1,47 @@ +get(EntityManagerInterface::class); + + $user = new User(); + $user->setEmail('test-attest-'.uniqid().'@example.com'); + $user->setFirstName('Test'); + $user->setLastName('User'); + $user->setPassword('$2y$13$hashed'); + $em->persist($user); + + $payout = new Payout(); + $payout->setOrganizer($user); + $payout->setStripePayoutId('po_check_'.uniqid()); + $payout->setStatus('paid'); + $payout->setAmount(10000); + $payout->setCurrency('eur'); + $em->persist($payout); + $em->flush(); + + $client->request('GET', '/attestation/check/'.$payout->getStripePayoutId()); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Attestation authentique'); + } + + public function testCheckWithInvalidPayout(): void + { + $client = static::createClient(); + $client->request('GET', '/attestation/check/po_fake_invalid'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Attestation introuvable'); + } +} diff --git a/tests/Controller/StripeWebhookControllerTest.php b/tests/Controller/StripeWebhookControllerTest.php index 16cbddc..92938d0 100644 --- a/tests/Controller/StripeWebhookControllerTest.php +++ b/tests/Controller/StripeWebhookControllerTest.php @@ -2,12 +2,8 @@ namespace App\Tests\Controller; -use App\Entity\User; use App\Service\StripeService; -use Doctrine\ORM\EntityManagerInterface; -use Stripe\Account; use Stripe\Event; -use Stripe\StripeClient; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class StripeWebhookControllerTest extends WebTestCase @@ -42,84 +38,4 @@ class StripeWebhookControllerTest extends WebTestCase self::assertResponseStatusCodeSame(400); } - - public function testWebhookAccountUpdatedSetsFlags(): void - { - $client = static::createClient(); - - $event = Event::constructFrom([ - 'type' => 'account.updated', - 'data' => [ - 'object' => [ - 'id' => 'acct_test_webhook', - 'charges_enabled' => true, - 'payouts_enabled' => true, - ], - ], - ]); - - $account = Account::constructFrom([ - 'id' => 'acct_test_webhook', - 'charges_enabled' => true, - 'payouts_enabled' => true, - ]); - - $accountsService = $this->createMock(\Stripe\Service\AccountService::class); - $accountsService->method('retrieve')->willReturn($account); - - $stripeClient = $this->createMock(StripeClient::class); - $stripeClient->accounts = $accountsService; - - $stripeService = $this->createMock(StripeService::class); - $stripeService->method('verifyWebhookSignature')->willReturn($event); - $stripeService->method('getClient')->willReturn($stripeClient); - static::getContainer()->set(StripeService::class, $stripeService); - - $em = static::getContainer()->get(EntityManagerInterface::class); - - $user = new User(); - $user->setEmail('test-stripe-wh-'.uniqid().'@example.com'); - $user->setFirstName('Stripe'); - $user->setLastName('Test'); - $user->setPassword('$2y$13$hashed'); - $user->setStripeAccountId('acct_test_webhook'); - $em->persist($user); - $em->flush(); - - $client->request('POST', '/stripe/webhook', [], [], [ - 'HTTP_STRIPE_SIGNATURE' => 'valid', - ], '{}'); - - self::assertResponseIsSuccessful(); - - $freshEm = static::getContainer()->get(EntityManagerInterface::class); - $updatedUser = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_test_webhook']); - self::assertNotNull($updatedUser); - self::assertTrue($updatedUser->isStripeChargesEnabled()); - self::assertTrue($updatedUser->isStripePayoutsEnabled()); - } - - public function testWebhookAccountUpdatedUnknownAccount(): void - { - $client = static::createClient(); - - $event = Event::constructFrom([ - 'type' => 'account.updated', - 'data' => [ - 'object' => [ - 'id' => 'acct_unknown', - ], - ], - ]); - - $stripeService = $this->createMock(StripeService::class); - $stripeService->method('verifyWebhookSignature')->willReturn($event); - static::getContainer()->set(StripeService::class, $stripeService); - - $client->request('POST', '/stripe/webhook', [], [], [ - 'HTTP_STRIPE_SIGNATURE' => 'valid', - ], '{}'); - - self::assertResponseIsSuccessful(); - } } diff --git a/tests/Entity/PayoutTest.php b/tests/Entity/PayoutTest.php new file mode 100644 index 0000000..5dda334 --- /dev/null +++ b/tests/Entity/PayoutTest.php @@ -0,0 +1,45 @@ +getCreatedAt()); + } + + public function testPayoutFields(): void + { + $user = new User(); + $arrival = new \DateTimeImmutable('2026-03-20'); + + $payout = new Payout(); + $result = $payout->setOrganizer($user) + ->setStripePayoutId('po_test123') + ->setStatus('paid') + ->setAmount(15000) + ->setCurrency('eur') + ->setDestination('ba_xxx') + ->setStripeAccountId('acct_xxx') + ->setArrivalDate($arrival); + + self::assertSame($payout, $result); + self::assertNull($payout->getId()); + self::assertSame($user, $payout->getOrganizer()); + self::assertSame('po_test123', $payout->getStripePayoutId()); + self::assertSame('paid', $payout->getStatus()); + self::assertSame(15000, $payout->getAmount()); + self::assertSame(150.0, $payout->getAmountDecimal()); + self::assertSame('eur', $payout->getCurrency()); + self::assertSame('ba_xxx', $payout->getDestination()); + self::assertSame('acct_xxx', $payout->getStripeAccountId()); + self::assertSame($arrival, $payout->getArrivalDate()); + } +} diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php index 064cd36..3170131 100644 --- a/tests/Entity/UserTest.php +++ b/tests/Entity/UserTest.php @@ -146,20 +146,43 @@ class UserTest extends TestCase self::assertSame(1.5, $user->getCommissionRate()); } + public function testSubAccountFields(): void + { + $parent = new User(); + $sub = new User(); + + self::assertNull($sub->getParentOrganizer()); + self::assertNull($sub->getSubAccountPermissions()); + self::assertFalse($sub->hasPermission('scanner')); + + $result = $sub->setParentOrganizer($parent) + ->setSubAccountPermissions(['scanner', 'events']); + + self::assertSame($sub, $result); + self::assertSame($parent, $sub->getParentOrganizer()); + self::assertSame(['scanner', 'events'], $sub->getSubAccountPermissions()); + self::assertTrue($sub->hasPermission('scanner')); + self::assertTrue($sub->hasPermission('events')); + self::assertFalse($sub->hasPermission('tickets')); + } + public function testStripeFields(): void { $user = new User(); self::assertNull($user->getStripeAccountId()); + self::assertNull($user->getStripeStatus()); self::assertFalse($user->isStripeChargesEnabled()); self::assertFalse($user->isStripePayoutsEnabled()); $result = $user->setStripeAccountId('acct_1234567890') + ->setStripeStatus('started') ->setStripeChargesEnabled(true) ->setStripePayoutsEnabled(true); self::assertSame($user, $result); self::assertSame('acct_1234567890', $user->getStripeAccountId()); + self::assertSame('started', $user->getStripeStatus()); self::assertTrue($user->isStripeChargesEnabled()); self::assertTrue($user->isStripePayoutsEnabled()); }