Kaynağa Gözat

Resolve merge conflict in SubdomainService.php

Olivier Massot 4 ay önce
ebeveyn
işleme
64d8e9c2c6
71 değiştirilmiş dosya ile 4262 ekleme ve 181 silme
  1. 13 0
      .env
  2. 6 6
      config/packages/docker/messenger.yaml
  3. 4 2
      config/packages/doctrine.yaml
  4. 3 0
      config/secrets/docker/docker.DATABASE_DOLIBARR_URL.76f96a.php
  5. 1 0
      config/secrets/docker/docker.list.php
  6. 3 0
      config/secrets/prod/prod.DATABASE_DOLIBARR_URL.76f96a.php
  7. 1 0
      config/secrets/prod/prod.list.php
  8. 3 0
      config/secrets/staging/staging.DATABASE_DOLIBARR_URL.76f96a.php
  9. 1 0
      config/secrets/staging/staging.list.php
  10. 3 0
      config/secrets/test/test.DATABASE_DOLIBARR_URL.76f96a.php
  11. 1 0
      config/secrets/test/test.list.php
  12. 3 2
      config/services.yaml
  13. 9 1
      env/.env.docker
  14. 8 0
      env/.env.prod
  15. 8 0
      env/.env.staging
  16. 8 0
      env/.env.test
  17. 8 0
      env/.env.test1
  18. 8 0
      env/.env.test2
  19. 8 0
      env/.env.test3
  20. 8 0
      env/.env.test4
  21. 8 0
      env/.env.test5
  22. 8 0
      env/.env.test6
  23. 8 0
      env/.env.test7
  24. 8 0
      env/.env.test8
  25. 8 0
      env/.env.test9
  26. BIN
      public/images/facebook.jpg
  27. BIN
      public/images/linkedin.jpg
  28. BIN
      public/images/youtube.jpg
  29. 4 7
      src/ApiResources/Organization/OrganizationCreationRequest.php
  30. 7 14
      src/ApiResources/Organization/OrganizationMemberCreationRequest.php
  31. 5 0
      src/ApiResources/Organization/Subdomain/SubdomainAvailability.php
  32. 354 0
      src/ApiResources/Shop/NewStructureArtistPremiumTrialRequest.php
  33. 9 0
      src/ApiResources/Shop/ShopRequestData.php
  34. 119 0
      src/Entity/Shop/ShopRequest.php
  35. 22 0
      src/Enum/Access/AccessIdsEnum.php
  36. 28 0
      src/Enum/Shop/ShopRequestStatus.php
  37. 22 0
      src/Enum/Shop/ShopRequestType.php
  38. 36 0
      src/Message/Handler/Shop/NewStructureArtistPremiumTrialHandler.php
  39. 42 0
      src/Message/Message/Shop/NewStructureArtistPremiumTrial.php
  40. 1 1
      src/Repository/Access/AccessRepository.php
  41. 125 0
      src/Service/Dolibarr/DolibarrApiService.php
  42. 140 0
      src/Service/Dolibarr/DolibarrUtils.php
  43. 3 1
      src/Service/Mailer/Builder/AbstractBuilder.php
  44. 58 0
      src/Service/Mailer/Builder/Shop/NewStructureArtistPremium/ConfirmationToRepresentativeBuilder.php
  45. 58 0
      src/Service/Mailer/Builder/Shop/NewStructureArtistPremium/NotificationToSalesAdminBuilder.php
  46. 57 0
      src/Service/Mailer/Builder/Shop/TokenValidationBuilder.php
  47. 82 0
      src/Service/Mailer/Model/Shop/NewStructureArtistPremium/ConfirmationToRepresentativeModel.php
  48. 30 0
      src/Service/Mailer/Model/Shop/NewStructureArtistPremium/NotificationToSalesAdminModel.php
  49. 93 0
      src/Service/Mailer/Model/Shop/TokenValidationModel.php
  50. 60 14
      src/Service/Organization/OrganizationFactory.php
  51. 1 0
      src/Service/Organization/OrganizationProfileCreator.php
  52. 0 37
      src/Service/Organization/Trial.php
  53. 351 0
      src/Service/Shop/ShopService.php
  54. 103 0
      src/Service/Shop/Trial.php
  55. 40 0
      src/Service/Twig/ToBase64Extension.php
  56. 1 1
      src/Service/Typo3/SubdomainService.php
  57. 5 5
      src/Service/Utils/UrlBuilder.php
  58. 64 0
      src/State/Processor/Shop/NewStructureArtistPremiumTrialRequestProcessor.php
  59. 66 0
      src/State/Provider/Shop/ShopRequestProvider.php
  60. 126 88
      templates/emails/base.html.twig
  61. 41 0
      templates/emails/shop/NewStructureArtistPremium/confirmation-to-representative.html.twig
  62. 105 0
      templates/emails/shop/NewStructureArtistPremium/notification-to-sales-admin.html.twig
  63. 34 0
      templates/emails/shop/token-validation.html.twig
  64. 593 1
      tests/Unit/Service/Dolibarr/DolibarrApiServiceTest.php
  65. 255 0
      tests/Unit/Service/Dolibarr/DolibarrUtilsTest.php
  66. 69 0
      tests/Unit/Service/Organization/OrganizationFactoryTest.php
  67. 1 1
      tests/Unit/Service/Organization/OrganizationProfileCreatorTest.php
  68. 207 0
      tests/Unit/Service/Organization/TrialTest.php
  69. 684 0
      tests/Unit/Service/Shop/ShopServiceTest.php
  70. 3 0
      translations/enum/organization/legal/messages+intl-icu.fr.yaml
  71. 11 0
      translations/enum/organization/principal-type/messages+intl-icu.fr.yaml

+ 13 - 0
.env

@@ -23,6 +23,7 @@ APP_SECRET=6a76497c8658bb23e2236f97a2627df3
 DATABASE_URL=xxx
 DATABASE_ADMINASSOS_URL=xxx
 DATABASE_AUDIT_URL=xxx
+DATABASE_DOLIBARR_URL=xxx
 DOLIBARR_API_TOKEN=xxx
 MERCURE_JWT_SECRET=xxx
 ###< secret values ###
@@ -31,6 +32,10 @@ MERCURE_JWT_SECRET=xxx
 CORS_ALLOW_ORIGIN=^https?://(localhost|127\.0\.0\.1)(:[0-9]+)$
 ###< nelmio/cors-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://local.admin.opentalent.fr
+###
+
 ###> url v2 ###
 PUBLIC_API_BASE_URL=https://local.ap2i.opentalent.fr
 ###
@@ -115,3 +120,11 @@ BLACKFIRE_CLIENT_TOKEN=8cfbeb263d044da9678dc2612531504da3790c308da7448e35724a5da
 BLACKFIRE_SERVER_ID=1171e53b-459b-41da-a292-80ff68cee8c2
 BLACKFIRE_SERVER_TOKEN=dbd1cfbea015fe83cccfc189a36ca3c16f3a1b43b94f50032a15e41e53548e8b
 ###< BlackFire configuration ###
+
+### Lien vers la FAQ
+FAQ_URL=https://ressources.opentalent.fr/space/FAQ/2495122/Artist+Standard+et+Premium+-+Prise+en+main
+###
+
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://local.logiciels.opentalent.fr
+###

+ 6 - 6
config/packages/docker/messenger.yaml

@@ -1,9 +1,9 @@
 # Désactive le fonctionnement asynchrone de messenger en mode dev
 # > commenter pour tester avec un fonctionnement asynchrone
 #   (dans ce cas, la commande `messenger:consume async` doit être en cours d'exécution)
-#framework:
-#    messenger:
-#        transports:
-#            # https://symfony.com/doc/current/messenger.html#transport-configuration
-#            async: 'sync://'
-#            failed: 'sync://'
+framework:
+    messenger:
+        transports:
+            # https://symfony.com/doc/current/messenger.html#transport-configuration
+            async: 'sync://'
+            failed: 'sync://'

+ 4 - 2
config/packages/doctrine.yaml

@@ -17,10 +17,12 @@ doctrine:
 
             adminassos:
                 url: '%env(resolve:DATABASE_ADMINASSOS_URL)%'
+                server_version: '5.7'
 
-                # IMPORTANT: You MUST configure your server version,
-                # either here or in the DATABASE_URL env var (see .env file)
+            dolibarr:
+                url: '%env(resolve:DATABASE_DOLIBARR_URL)%'
                 server_version: '5.7'
+
         types:
             uuid: Ramsey\Uuid\Doctrine\UuidType
 

+ 3 - 0
config/secrets/docker/docker.DATABASE_DOLIBARR_URL.76f96a.php

@@ -0,0 +1,3 @@
+<?php // docker.DATABASE_DOLIBARR_URL.76f96a on Thu, 19 Jun 2025 09:06:57 +0000
+
+return "gw\xD4\x05\x0E\xED\xBD2\xB5\xE7I\x97\x02\x237\x1DX\xBA\x5C\x04\x5BePi\xB3dl\xFD\xD0\x273J\x24\xE3\xC7Y\x28\xC9\xDE\xD0K\xB0\xC7\xF2\xD1E\x1E\xB9\xF2\xC0\xDB25\x5D\x89S\x3A\x02\x7C\xA6\xC0\x3B\xA6\xA3\xD4\x01I\xA8\x16G\xB4l\x8DzE\xD5\xFCuq\x84\xE0n\x241q\x18\x3C\xAA\xDC\x84\xB0\x17\x0B\xF5\xD1o\xF9\x02\x8C\xA6\x9CJ\x05\xF1\xA4\x86R\xBD\xBB\xC3i\x935\x1E\x18E\x176\x18\x91\x40\xEB\xD1\x19v\x24\xF0\x17\x23h\x18\xC8\xFB\xD7\x60yY\xEA\x8F\x04\x8F\xB8\xE9Z\xDB2a\xEB5\xAE\xF7\x7C\x92\xA3\xFF";

+ 1 - 0
config/secrets/docker/docker.list.php

@@ -3,6 +3,7 @@
 return [
     'DATABASE_ADMINASSOS_URL' => null,
     'DATABASE_AUDIT_URL' => null,
+    'DATABASE_DOLIBARR_URL' => null,
     'DATABASE_URL' => null,
     'DOLIBARR_API_TOKEN' => null,
     'MERCURE_JWT_SECRET' => null,

+ 3 - 0
config/secrets/prod/prod.DATABASE_DOLIBARR_URL.76f96a.php

@@ -0,0 +1,3 @@
+<?php // prod.DATABASE_DOLIBARR_URL.76f96a on Thu, 19 Jun 2025 09:58:07 +0000
+
+return "Em\x5B\xEDT\xDF\x40\x12E\xC9g\x3D\xAAZ\x9CfW\xF5\x83\x2Ac\xD3\x95Q\xC7\xAD\x83k1\xEF\xFD4s\x16\xAC\x0F\x1E\xE45l\xA6\x04q\x7F\xD2\xA0\x85\xCA\x3F\x80\x04\x0EMlI9\x92\xC9\xE4G\x03\x14G\xA1\xD0\x92\x8C\x08\x9Cur\x97\xBEg\x82\xE2\xE6\xD3\xD8\xA8\xBA\xDF\x1A\xFBYL\x25\x1C\x83\xC7D\x1D1\x23Wy\x98\xB4\xBCn\xD7\xA5V\xBC\x7D\xC3\x88F\x0D\xF6\x82\xB17\x8F\xEFi\x0E\xC1\xA5W\x3D\xE7\x90\x20A6\xB5\x01\x2C\xE5\xB2\xEA\xF5U\xBF\x2A\xF3\xDA\xAF_\x99\xC6sv\xF32\xB0H\x05\x84u\x3F\x60\xF8\xF9\x7C";

+ 1 - 0
config/secrets/prod/prod.list.php

@@ -3,6 +3,7 @@
 return [
     'DATABASE_ADMINASSOS_URL' => null,
     'DATABASE_AUDIT_URL' => null,
+    'DATABASE_DOLIBARR_URL' => null,
     'DATABASE_URL' => null,
     'DOLIBARR_API_TOKEN' => null,
     'MERCURE_JWT_SECRET' => null,

+ 3 - 0
config/secrets/staging/staging.DATABASE_DOLIBARR_URL.76f96a.php

@@ -0,0 +1,3 @@
+<?php // staging.DATABASE_DOLIBARR_URL.76f96a on Thu, 19 Jun 2025 09:58:32 +0000
+
+return "V\xE2\x8C\xCA\x02\xA6\x8A5\xE3U\x0BbW\xA9\xD8c\x22L\xA7\xE5\xC54\x02\x94\xC7G\xA1D\x26\x0DH\x2B\x20\x3D\x3C\xAC\x5C\xC1\x5E\x21\xF1\x8F.\xBBm\x9A\xC6\x3D";

+ 1 - 0
config/secrets/staging/staging.list.php

@@ -3,6 +3,7 @@
 return [
     'DATABASE_ADMINASSOS_URL' => null,
     'DATABASE_AUDIT_URL' => null,
+    'DATABASE_DOLIBARR_URL' => null,
     'DATABASE_URL' => null,
     'DOLIBARR_API_TOKEN' => null,
 ];

+ 3 - 0
config/secrets/test/test.DATABASE_DOLIBARR_URL.76f96a.php

@@ -0,0 +1,3 @@
+<?php // test.DATABASE_DOLIBARR_URL.76f96a on Thu, 19 Jun 2025 09:58:21 +0000
+
+return "d\xD8\xF9\xFD\x06\x80\xF8\x209\x1E\xAD\xAD\xCC\xEE\xBF\xD6\xD3\x25\xC2~\xC0\xE5r\xCE\xE7\xED-\x10\xF7\xC3\xF0\x29b\xE5Hw-q\x27\x88\xF1\x9A.\xDF\x04Y\x87\xD3x\xCApN6\xD5\xC3\x9E9\x89\x40\x3Et\xA1\xF6\xEE\x8D\xBB\xC1c\xD4\x9E\xB0\x0E\xCA\xBC\xC3\xC8\x1D\x2F\x2A~\x0D\xBE\x93\x12u\x23\xB0\x8F\x04Yds\xACx\xDC\x23\xBErs\xA8\xB8o\xB3\x8D\xE9\x0D\x26\xEF\xCE\xC5\xA0\x3E\xAB\xB8Vk\x9F\x11\x9D\xA4\x7CSG\x1A\xA7w\xAF\xDEO\x0D\xFEqY\xD8\xD1\xDD\xA9\x85T\x8A\xDF\x12\xF0\xDF\x8Fd\x15\x40\xE4\xE2\x9B\xC3\x27\xCF\xB2";

+ 1 - 0
config/secrets/test/test.list.php

@@ -3,6 +3,7 @@
 return [
     'DATABASE_ADMINASSOS_URL' => null,
     'DATABASE_AUDIT_URL' => null,
+    'DATABASE_DOLIBARR_URL' => null,
     'DATABASE_URL' => null,
     'DOLIBARR_API_TOKEN' => null,
     'MERCURE_JWT_SECRET' => null,

+ 3 - 2
config/services.yaml

@@ -25,8 +25,11 @@ services:
             $publicLegacyBaseUrl: '%env(PUBLIC_API_LEG_BASE_URL)%'
             $baseUrl: '%env(API_BASE_URL)%'
             $publicBaseUrl: '%env(PUBLIC_API_BASE_URL)%'
+            $adminBaseUrl: '%env(ADMIN_BASE_URL)%'
+            $softwareWebsiteUrl: '%env(SOFTWARE_WEBSITE_URL)%'
             $opentalentMailReport: 'mail.report@opentalent.fr'
             $fileStorageDir: '%kernel.project_dir%/var/files/storage'
+            $faqUrl: '%env(FAQ_URL)%'
 
     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name
@@ -132,5 +135,3 @@ services:
         arguments:
             - '@doctrine.orm.command.entity_manager_provider'
         tags: ['console.command']
-
-

+ 9 - 1
env/.env.docker

@@ -5,9 +5,13 @@ APP_SECRET=211cede3dc4b162da3ec2fbdcd905070
 ###< symfony/framework-bundle ###
 
 ###> nelmio/cors-bundle ###
-CORS_ALLOW_ORIGIN=^https?:\/\/(localhost|127\.0\.0\.1|(local.(admin|app|app|frames|agenda|maestro).opentalent.fr))(:[0-9]+)?$
+CORS_ALLOW_ORIGIN=^https?:\/\/(localhost|127\.0\.0\.1|(local.(admin|app|app|frames|agenda|maestro|logiciels).opentalent.fr))(:[0-9]+)?$
 ###< nelmio/cors-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://local.admin.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=http://nginx/
 PUBLIC_API_LEG_BASE_URL=https://local.api.opentalent.fr
@@ -22,6 +26,10 @@ PUBLIC_API_BASE_URL=https://local.ap2i.opentalent.fr
 TYPO3_BASE_URI=http://docker.sub.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://local.logiciels.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.prod

@@ -1,6 +1,10 @@
 ###> doctrine/doctrine-bundle ###
 APP_ENV=prod
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.opentalent.fr/api
@@ -15,6 +19,10 @@ PUBLIC_API_BASE_URL=https://ap2i.opentalent.fr/api
 TYPO3_BASE_URI=http://ohcluses.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.opentalent.fr
+###
+
 ###> dolibarr client ###
 DOLIBARR_API_BASE_URI=https://prod-erp.2iopenservice.com/api/index.php/
 ###< dolibarr client ###

+ 8 - 0
env/.env.staging

@@ -8,6 +8,10 @@ MERCURE_JWT_SECRET='TXsLVKnU4Ew4oyH4qcQO81CuSOVTbj58W42fDTIzIZPwpPCaGu2EvIL3DbtD
 CORS_ALLOW_ORIGIN=^$
 ###< nelmio/cors-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://none
+###
+
 ####> api v1 ###
 API_LEG_BASE_URL=https://none
 PUBLIC_API_LEG_BASE_URL=https://none
@@ -27,6 +31,10 @@ ELASTICSEARCH_PORT=9200
 TYPO3_BASE_URI=https://none
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=none
+###
+
 ### Internal requests (@see doc/internal_requests.md)
 INTERNAL_FILES_DOWNLOAD_URI=https://none
 ###

+ 8 - 0
env/.env.test

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test1

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test1.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test1.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test1.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test1.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test1.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test1.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test2

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test2.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test2.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test2.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test2.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test2.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test2.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test3

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test3.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test3.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test3.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test3.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test3.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test3.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test4

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test4.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test4.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test4.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test4.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test4.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test4.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test5

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test5.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test5.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test5.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test5.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test5.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test5.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test6

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test6.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test6.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test6.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test6.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test6.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test6.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test7

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test7.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test7.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test7.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test7.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test7.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test7.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test8

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test8.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test8.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test8.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test8.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test8.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test8.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

+ 8 - 0
env/.env.test9

@@ -3,6 +3,10 @@ APP_ENV=test
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+### Logiciel
+ADMIN_BASE_URL=https://admin.test9.opentalent.fr
+###
+
 ###> api v1 ###
 API_LEG_BASE_URL=https://api.test9.opentalent.fr/api
 PUBLIC_API_LEG_BASE_URL=https://api.test9.opentalent.fr/api
@@ -17,6 +21,10 @@ PUBLIC_API_BASE_URL=https://ap2i.test9.opentalent.fr/api
 TYPO3_BASE_URI=https://sub.test9.opentalent.fr
 ###< typo3 client ###
 
+### Site logiciels
+SOFTWARE_WEBSITE_URL=https://logiciels.test9.opentalent.fr
+###
+
 ###> symfony/mercure-bundle ###
 # See https://symfony.com/doc/current/mercure.html#configuration
 # The URL of the Mercure hub, used by the app to publish updates (can be a local URL)

BIN
public/images/facebook.jpg


BIN
public/images/linkedin.jpg


BIN
public/images/youtube.jpg


+ 4 - 7
src/ApiResources/Organization/OrganizationCreationRequest.php

@@ -12,6 +12,7 @@ use App\Enum\Organization\OrganizationIdsEnum;
 use App\Enum\Organization\PrincipalTypeEnum;
 use App\Enum\Organization\SettingsProductEnum;
 use App\State\Processor\Organization\OrganizationCreationRequestProcessor;
+use libphonenumber\PhoneNumber;
 use Symfony\Component\Validator\Constraints as Assert;
 
 /**
@@ -88,11 +89,7 @@ class OrganizationCreationRequest
 
     private int $countryId = self::FRANCE_COUNTRY_INTERNAL_ID;
 
-    #[Assert\Length(
-        min: 10,
-        minMessage: 'Phone number must be at least {{ limit }} characters long',
-    )]
-    private string $phoneNumber;
+    private PhoneNumber $phoneNumber;
 
     #[Assert\Email(
         message: 'The email {{ value }} is not a valid email.',
@@ -313,12 +310,12 @@ class OrganizationCreationRequest
         return $this;
     }
 
-    public function getPhoneNumber(): string
+    public function getPhoneNumber(): PhoneNumber
     {
         return $this->phoneNumber;
     }
 
-    public function setPhoneNumber(string $phoneNumber): self
+    public function setPhoneNumber(PhoneNumber $phoneNumber): self
     {
         $this->phoneNumber = $phoneNumber;
 

+ 7 - 14
src/ApiResources/Organization/OrganizationMemberCreationRequest.php

@@ -6,6 +6,7 @@ namespace App\ApiResources\Organization;
 
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Person\GenderEnum;
+use libphonenumber\PhoneNumber;
 use Symfony\Component\Validator\Constraints as Assert;
 
 /**
@@ -55,17 +56,9 @@ class OrganizationMemberCreationRequest
 
     private int $countryId = 72; // France's id
 
-    #[Assert\Length(
-        min: 10,
-        minMessage: 'Phone number must be at least {{ limit }} characters long',
-    )]
-    private ?string $phone = null;
+    private ?PhoneNumber $phone = null;
 
-    #[Assert\Length(
-        min: 10,
-        minMessage: 'Mobile phone number must be at least {{ limit }} characters long',
-    )]
-    private ?string $mobile = null;
+    private ?PhoneNumber $mobile = null;
 
     #[Assert\Email(
         message: 'The email {{ value }} is not a valid email.',
@@ -192,24 +185,24 @@ class OrganizationMemberCreationRequest
         return $this;
     }
 
-    public function getPhone(): ?string
+    public function getPhone(): ?PhoneNumber
     {
         return $this->phone;
     }
 
-    public function setPhone(?string $phone): self
+    public function setPhone(?PhoneNumber $phone): self
     {
         $this->phone = $phone;
 
         return $this;
     }
 
-    public function getMobile(): ?string
+    public function getMobile(): ?PhoneNumber
     {
         return $this->mobile;
     }
 
-    public function setMobile(?string $mobile): self
+    public function setMobile(?PhoneNumber $mobile): self
     {
         $this->mobile = $mobile;
 

+ 5 - 0
src/ApiResources/Organization/Subdomain/SubdomainAvailability.php

@@ -11,11 +11,16 @@ use App\State\Provider\Organization\Subdomain\SubdomainAvailabilityProvider;
 
 #[ApiResource(
     operations: [
+        // TODO: route à supprimer quand elle ne sera plus utilisée nulle part, on ne conservera que la route publique
         new Get(
             uriTemplate: '/subdomains/is_available',
             security: 'is_granted("ROLE_ORGANIZATION_VIEW") or is_granted("ROLE_ORGANIZATION")',
             provider: SubdomainAvailabilityProvider::class
         ),
+        new Get(
+            uriTemplate: '/public/subdomains/is_available',
+            provider: SubdomainAvailabilityProvider::class
+        ),
     ]
 )]
 class SubdomainAvailability

+ 354 - 0
src/ApiResources/Shop/NewStructureArtistPremiumTrialRequest.php

@@ -0,0 +1,354 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\Shop;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Post;
+use App\Enum\Organization\LegalEnum;
+use App\Enum\Organization\PrincipalTypeEnum;
+use App\State\Processor\Shop\NewStructureArtistPremiumTrialRequestProcessor;
+use libphonenumber\PhoneNumber;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * New structure trial request for the artist premium product.
+ *
+ * This API resource represents a request for a trial of the Artist Premium product for a new structure.
+ * It contains all the information needed to create a new organization and start a premium trial:
+ * - Structure information (name, address, email, type, legal status, etc.)
+ * - Representative information (name, function, email, phone, etc.)
+ * - Acceptance of terms and conditions
+ *
+ * The resource is exposed through API Platform for submission via a POST endpoint.
+ */
+#[ApiResource(
+    operations: [
+        new Post(
+            uriTemplate: '/public/shop/new-structure-artist-premium-trial-request',
+            processor: NewStructureArtistPremiumTrialRequestProcessor::class
+        ),
+    ]
+)]
+class NewStructureArtistPremiumTrialRequest implements ShopRequestData
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 0;
+
+    #[Assert\Length(
+        min: 2,
+        minMessage: "Structure's name must be at least {{ limit }} characters long",
+    )]
+    private string $structureName;
+
+    #[Assert\NotBlank]
+    private string $address;
+
+    private ?string $addressComplement = null;
+
+    #[Assert\Length(
+        min: 3,
+        minMessage: 'Postal code must be at least {{ limit }} characters long',
+    )]
+    private string $postalCode;
+
+    #[Assert\NotBlank]
+    private string $city;
+
+    #[Assert\NotBlank]
+    #[Assert\Email(message: 'The email {{ value }} is not a valid email.')]
+    private string $structureEmail;
+
+    private PrincipalTypeEnum $structureType;
+
+    private LegalEnum $legalStatus;
+
+    #[Assert\NotBlank]
+    #[Assert\Regex(pattern: '/^[a-z0-9][a-z0-9-]{0,28}[a-z0-9]$/')]
+    private string $structureIdentifier;
+
+    #[Assert\Regex(pattern: '/^|(\d{9})$/')]
+    private string $siren;
+
+    #[Assert\NotBlank]
+    private string $representativeFirstName;
+
+    #[Assert\NotBlank]
+    private string $representativeLastName;
+
+    #[Assert\NotBlank]
+    private string $representativeFunction;
+
+    #[Assert\NotBlank]
+    #[Assert\Email(message: 'The email {{ value }} is not a valid email.')]
+    private string $representativeEmail;
+
+    #[Assert\NotBlank]
+    private ?PhoneNumber $representativePhone;
+
+    #[Assert\IsTrue(message: 'terms-must-be-accepted')]
+    private bool $termsAccepted = false;
+
+    private bool $legalRepresentative = false;
+
+    private bool $newsletterSubscription = false;
+
+    /**
+     * Password of the admin account of the newly created structure.
+     *
+     * Must have at least 8 characters, including at least one uppercase letter,
+     * one lowercase letter, one digit, and one special character.
+     */
+    #[Assert\NotBlank]
+    #[Assert\Regex(
+        pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).{8,}$/',
+        message: 'Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one special character.'
+    )]
+    private string $password;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+
+        return $this;
+    }
+
+    public function getStructureName(): string
+    {
+        return $this->structureName;
+    }
+
+    public function setStructureName(string $structureName): self
+    {
+        $this->structureName = $structureName;
+
+        return $this;
+    }
+
+    public function getAddress(): string
+    {
+        return $this->address;
+    }
+
+    public function setAddress(string $address): self
+    {
+        $this->address = $address;
+
+        return $this;
+    }
+
+    public function getAddressComplement(): ?string
+    {
+        return $this->addressComplement;
+    }
+
+    public function setAddressComplement(?string $addressComplement): self
+    {
+        $this->addressComplement = $addressComplement;
+
+        return $this;
+    }
+
+    public function getPostalCode(): string
+    {
+        return $this->postalCode;
+    }
+
+    public function setPostalCode(string $postalCode): self
+    {
+        $this->postalCode = $postalCode;
+
+        return $this;
+    }
+
+    public function getCity(): string
+    {
+        return $this->city;
+    }
+
+    public function setCity(string $city): self
+    {
+        $this->city = $city;
+
+        return $this;
+    }
+
+    public function getStructureEmail(): string
+    {
+        return $this->structureEmail;
+    }
+
+    public function setStructureEmail(string $structureEmail): self
+    {
+        $this->structureEmail = $structureEmail;
+
+        return $this;
+    }
+
+    public function getStructureType(): PrincipalTypeEnum
+    {
+        return $this->structureType;
+    }
+
+    public function setStructureType(PrincipalTypeEnum $structureType): self
+    {
+        $this->structureType = $structureType;
+
+        return $this;
+    }
+
+    public function getLegalStatus(): LegalEnum
+    {
+        return $this->legalStatus;
+    }
+
+    public function setLegalStatus(LegalEnum $legalStatus): self
+    {
+        $this->legalStatus = $legalStatus;
+
+        return $this;
+    }
+
+    public function getStructureIdentifier(): string
+    {
+        return $this->structureIdentifier;
+    }
+
+    public function setStructureIdentifier(string $structureIdentifier): self
+    {
+        $this->structureIdentifier = $structureIdentifier;
+
+        return $this;
+    }
+
+    public function getSiren(): string
+    {
+        return $this->siren;
+    }
+
+    public function setSiren(string $siren): self
+    {
+        $this->siren = $siren;
+
+        return $this;
+    }
+
+    public function getRepresentativeFirstName(): string
+    {
+        return $this->representativeFirstName;
+    }
+
+    public function setRepresentativeFirstName(string $representativeFirstName): self
+    {
+        $this->representativeFirstName = $representativeFirstName;
+
+        return $this;
+    }
+
+    public function getRepresentativeLastName(): string
+    {
+        return $this->representativeLastName;
+    }
+
+    public function setRepresentativeLastName(string $representativeLastName): self
+    {
+        $this->representativeLastName = $representativeLastName;
+
+        return $this;
+    }
+
+    public function getRepresentativeFunction(): string
+    {
+        return $this->representativeFunction;
+    }
+
+    public function setRepresentativeFunction(string $representativeFunction): self
+    {
+        $this->representativeFunction = $representativeFunction;
+
+        return $this;
+    }
+
+    public function getRepresentativeEmail(): string
+    {
+        return $this->representativeEmail;
+    }
+
+    public function setRepresentativeEmail(string $representativeEmail): self
+    {
+        $this->representativeEmail = $representativeEmail;
+
+        return $this;
+    }
+
+    public function getRepresentativePhone(): ?PhoneNumber
+    {
+        return $this->representativePhone;
+    }
+
+    public function setRepresentativePhone(?PhoneNumber $representativePhone): self
+    {
+        $this->representativePhone = $representativePhone;
+
+        return $this;
+    }
+
+    public function getTermsAccepted(): bool
+    {
+        return $this->termsAccepted;
+    }
+
+    public function setTermsAccepted(bool $termsAccepted): self
+    {
+        $this->termsAccepted = $termsAccepted;
+
+        return $this;
+    }
+
+    public function getLegalRepresentative(): bool
+    {
+        return $this->legalRepresentative;
+    }
+
+    public function setLegalRepresentative(bool $legalRepresentative): self
+    {
+        $this->legalRepresentative = $legalRepresentative;
+
+        return $this;
+    }
+
+    public function getNewsletterSubscription(): bool
+    {
+        return $this->newsletterSubscription;
+    }
+
+    public function setNewsletterSubscription(bool $newsletterSubscription): self
+    {
+        $this->newsletterSubscription = $newsletterSubscription;
+
+        return $this;
+    }
+
+    public function getPassword(): string
+    {
+        return $this->password;
+    }
+
+    public function setPassword(string $password): self
+    {
+        $this->password = $password;
+
+        return $this;
+    }
+}

+ 9 - 0
src/ApiResources/Shop/ShopRequestData.php

@@ -0,0 +1,9 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\Shop;
+
+interface ShopRequestData
+{
+}

+ 119 - 0
src/Entity/Shop/ShopRequest.php

@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Entity\Shop;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use App\Enum\Shop\ShopRequestStatus;
+use App\Enum\Shop\ShopRequestType;
+use App\State\Provider\Shop\ShopRequestProvider;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * Une demande effectuée par un client via la boutique en ligne (ex: demande d'essai premium).
+ */
+#[ApiResource(operations: [
+    new Get(
+        uriTemplate: '/public/shop/validate/{token}',
+        provider: ShopRequestProvider::class,
+    ),
+])]
+#[ORM\Entity]
+class ShopRequest
+{
+    #[ORM\Id]
+    #[ORM\Column(type: 'guid')]
+    private string $token;
+
+    #[ORM\Column(type: 'datetime_immutable')]
+    private \DateTimeImmutable $submissionDate;
+
+    #[ORM\Column(length: 50, enumType: ShopRequestStatus::class)]
+    private ShopRequestStatus $status;
+
+    #[ORM\Column(length: 50, enumType: ShopRequestType::class)]
+    private ShopRequestType $type;
+
+    /**
+     * @var array<string, mixed>
+     */
+    #[ORM\Column(type: Types::JSON)]
+    private array $data = [];
+
+    public function __construct()
+    {
+        $this->submissionDate = new \DateTimeImmutable();
+        $this->status = ShopRequestStatus::PENDING;
+    }
+
+    public function getToken(): string
+    {
+        return $this->token;
+    }
+
+    public function setToken(string $token): self
+    {
+        $this->token = $token;
+
+        return $this;
+    }
+
+    public function getSubmissionDate(): \DateTimeImmutable
+    {
+        return $this->submissionDate;
+    }
+
+    public function setSubmissionDate(\DateTimeImmutable $submissionDate): self
+    {
+        $this->submissionDate = $submissionDate;
+
+        return $this;
+    }
+
+    public function getStatus(): ?ShopRequestStatus
+    {
+        return $this->status;
+    }
+
+    public function setStatus(?ShopRequestStatus $status): self
+    {
+        $this->status = $status;
+
+        return $this;
+    }
+
+    public function getType(): ShopRequestType
+    {
+        return $this->type;
+    }
+
+    public function setType(ShopRequestType $type): self
+    {
+        $this->type = $type;
+
+        return $this;
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getData(): array
+    {
+        return $this->data;
+    }
+
+    /**
+     * @param array<string, mixed> $data
+     *
+     * @return $this
+     */
+    public function setData(array $data): self
+    {
+        $this->data = $data;
+
+        return $this;
+    }
+}

+ 22 - 0
src/Enum/Access/AccessIdsEnum.php

@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Access;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Id de comptes spécifiques.
+ *
+ * This enum defines specific account IDs used in the application:
+ * - ADMIN_2IOPENSERVICE: ID of the admin2iopenservice account (10984)
+ *
+ * These IDs are used for system operations like sending emails from specific accounts.
+ */
+enum AccessIdsEnum: int
+{
+    use EnumMethodsTrait;
+
+    case ADMIN_2IOPENSERVICE = 10984;
+}

+ 28 - 0
src/Enum/Shop/ShopRequestStatus.php

@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Shop;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Statuts d'une ShopRequest.
+ *
+ * This enum defines the possible statuses for a shop request:
+ * - PENDING: Initial status when the request is created
+ * - ACTIVATION_LINK_SENT: Status when the activation link has been sent to the user
+ * - VALIDATED: Status when the user has validated the request by clicking the activation link
+ * - COMPLETED: Status when the request has been fully processed
+ * - ERROR: Status when an error occurred during processing
+ */
+enum ShopRequestStatus: string
+{
+    use EnumMethodsTrait;
+
+    case PENDING = 'PENDING';
+    case ACTIVATION_LINK_SENT = 'ACTIVATION_LINK_SENT';
+    case VALIDATED = 'VALIDATED';
+    case COMPLETED = 'COMPLETED';
+    case ERROR = 'ERROR';
+}

+ 22 - 0
src/Enum/Shop/ShopRequestType.php

@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Shop;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Type of shop request.
+ *
+ * This enum defines the possible types of shop requests:
+ * - NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL: Request for a trial of the Artist Premium product for a new structure
+ *
+ * Additional types can be added as new features are implemented.
+ */
+enum ShopRequestType: string
+{
+    use EnumMethodsTrait;
+
+    case NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL = 'NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL';
+}

+ 36 - 0
src/Message/Handler/Shop/NewStructureArtistPremiumTrialHandler.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Handler\Shop;
+
+use App\Message\Message\Shop\NewStructureArtistPremiumTrial;
+use App\Service\Shop\ShopService;
+use Symfony\Component\Messenger\Attribute\AsMessageHandler;
+
+/**
+ * Message handler for processing new structure artist premium trial requests.
+ *
+ * This class handles the NewStructureArtistPremiumTrial message, which is dispatched
+ * when a user validates a trial request by clicking the activation link in the email.
+ *
+ * It delegates the actual processing to the ShopService.
+ */
+#[AsMessageHandler]
+readonly class NewStructureArtistPremiumTrialHandler
+{
+    public function __construct(
+        private ShopService $shopService,
+    ) {
+    }
+
+    /**
+     * @param NewStructureArtistPremiumTrial $message The message to process
+     */
+    public function __invoke(NewStructureArtistPremiumTrial $message): void
+    {
+        $token = $message->getToken();
+
+        $this->shopService->handleNewStructureArtistPremiumTrialRequest($token);
+    }
+}

+ 42 - 0
src/Message/Message/Shop/NewStructureArtistPremiumTrial.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Message\Shop;
+
+/**
+ * Message for processing a new structure artist premium trial request.
+ *
+ * This message is dispatched when a user validates a trial request by clicking
+ * the activation link in the email. It contains the token that identifies the
+ * shop request to be processed.
+ *
+ * It is handled by the NewStructureArtistPremiumTrialHandler class.
+ */
+class NewStructureArtistPremiumTrial
+{
+    public function __construct(
+        private string $token,
+    ) {
+    }
+
+    /**
+     * Gets the token that identifies the shop request.
+     *
+     * @return string The token
+     */
+    public function getToken(): string
+    {
+        return $this->token;
+    }
+
+    /**
+     * Sets the token that identifies the shop request.
+     *
+     * @param string $token The token
+     */
+    public function setToken(string $token): void
+    {
+        $this->token = $token;
+    }
+}

+ 1 - 1
src/Repository/Access/AccessRepository.php

@@ -73,7 +73,7 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
     /**
      * Retourne l'access administrateur de cette organisation.
      */
-    public function findAdminAccess(Organization $organization): Access
+    public function findAdminAccess(Organization $organization): ?Access
     {
         return $this->findOneBy([
             'adminAccess' => 1,

+ 125 - 0
src/Service/Dolibarr/DolibarrApiService.php

@@ -7,6 +7,7 @@ namespace App\Service\Dolibarr;
 use App\Entity\Organization\Organization;
 use App\Enum\Dolibarr\DolibarrDocTypeEnum;
 use App\Service\Rest\ApiRequestService;
+use App\Service\Utils\DatesUtils;
 use JetBrains\PhpStorm\Pure;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
@@ -55,6 +56,18 @@ class DolibarrApiService extends ApiRequestService
         }
     }
 
+    /**
+     * Get the dolibarr id of an organization.
+     *
+     * @throws \JsonException
+     */
+    public function getSocietyId(int $organizationId): ?int
+    {
+        $society = $this->getSociety($organizationId);
+
+        return $society ? (int) $society['id'] : null;
+    }
+
     /**
      * Get the first active contract for the given dolibarr society.
      *
@@ -279,4 +292,116 @@ class DolibarrApiService extends ApiRequestService
 
         return $this->getJsonContent($route);
     }
+
+    /**
+     * Créé un nouveau contrat Dolibarr.
+     *
+     * @param int $productId L'id du produit dans dolibarr (@see DolibarrUtils::getProductId())
+     * @param int $duration  Durée du contrat (en mois)
+     *
+     * @throws \Exception
+     */
+    public function createContract(
+        int $socId,
+        int $productId,
+        bool $isNewClient = false,
+        int $duration = 12,
+    ): int {
+        $route = 'contracts';
+        $date = DatesUtils::new();
+
+        $product = $this->getJsonContent(
+            "products/$productId"
+        );
+
+        // Evolution (3) ou nouveau client (1)
+        $originVente = $isNewClient ? 1 : 3;
+
+        $body = [
+            'socid' => $socId,
+            'date_contrat' => $date->format('Y-m-d'),
+            'commercial_signature_id' => 8,
+            'commercial_suivi_id' => 8,
+            'statut' => 1,
+            'lines' => [
+                [
+                    'fk_product' => $productId,
+                    'label' => $product['label'],
+                    'desc' => $product['description'],
+                    'qty' => 1,
+                    'subprice' => number_format((float) $product['price'], 2),
+                    'price_base_type' => $product['price_base_type'],
+                    'tva_tx' => $product['tva_tx'],
+                ],
+            ],
+            'array_options' => [
+                'options_ec_amount' => number_format((float) $product['price'], 2),
+                'options_ec_duration_months' => $duration,
+                'options_ec_signature_date' => $date->format('Y-m-d'),
+                'options_ec_effective_date' => $date->format('Y-m-d'),
+                'options_ec_tacit_renewal' => 1,
+                'options_ec_termination_period_months' => 2,
+                'options_ec_billing_due' => 1,
+                'options_ec_billing_frequency' => 4,
+                'options_ec_billing_begin_period' => 1,
+                'options_ec_payment_condition' => 7, // A Livraison
+                'options_ec_payment_mode' => 6, // CB (voir table llx_c_paiement)
+                'options_ec_account' => 1,
+                'options_logicielfact' => 1,
+                'options_versionfact' => 2,
+                'options_2iopen_origvente' => $originVente,
+            ],
+        ];
+
+        return (int) $this->post($route, $body)->getContent();
+    }
+
+    /**
+     * Ajoute une ligne au contrat.
+     *
+     * @param int $duration Durée du contrat (en jours)
+     *
+     * @throws \Exception
+     */
+    public function createContractLine(int $contractId, int $productId, int $duration = 12): int
+    {
+        $route = "contracts/$contractId/lines";
+
+        $date = DatesUtils::new();
+        $endDate = DatesUtils::new()->modify('+'.$duration.' months')->modify('-1 day');
+
+        $product = $this->getJsonContent(
+            "products/$productId"
+        );
+
+        $body = [
+            'fk_product' => $productId,
+            'label' => $product['label'],
+            'desc' => $product['description'],
+            'qty' => 1,
+            'subprice' => number_format((float) $product['price'], 2),
+            'price_base_type' => $product['price_base_type'],
+            'tva_tx' => $product['tva_tx'],
+            'date_start' => $date->format('Y-m-d'),
+            'date_end' => $endDate->format('Y-m-d'),
+        ];
+
+        return (int) $this->post($route, $body)->getContent();
+    }
+
+    /**
+     * Met à jour le produit possédé par la structure dans Dolibarr.
+     */
+    public function updateSocietyProduct(int $socId, string $productName): void
+    {
+        $route = "thirdparties/$socId";
+
+        $body = [
+            'array_options' => [
+                'options_2iopen_software_opentalent' => $productName,
+            ],
+        ];
+
+        $this->put($route, $body);
+    }
 }

+ 140 - 0
src/Service/Dolibarr/DolibarrUtils.php

@@ -0,0 +1,140 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Dolibarr;
+
+use App\Enum\Organization\SettingsProductEnum;
+use App\Service\Utils\DatesUtils;
+use Doctrine\DBAL\Connection;
+
+/**
+ * Utility class for interacting with Dolibarr ERP/CRM system.
+ *
+ * This class provides various utility methods for:
+ * - Getting product IDs based on contract type and other parameters
+ * - Executing SQL queries on the Dolibarr database
+ * - Updating society commercials
+ * - Adding entries to the commercial action journal
+ * - Getting product names
+ *
+ * It handles the integration between the application and Dolibarr for shop-related operations.
+ */
+class DolibarrUtils
+{
+    public const ARTIST_STANDARD_CMF_PRODUCT_ID = 283;
+    public const ARTIST_PREMIUM_TRIAL_PRODUCT_ID = 598;
+    public const ARTIST_PREMIUM_PRODUCT_ID = 281;
+    public const ARTIST_PREMIUM_CMF_PRODUCT_ID = 282;
+
+    public function __construct(
+        private Connection $dolibarrConnection,
+    ) {
+    }
+
+    /**
+     * Retourne l'id Dolibarr du produit donné, selon le produit possédé et l'appartenance
+     * ou non au réseau CMF.
+     *
+     * @param SettingsProductEnum $contractType Produit concerné (@see SettingsProductEnum)
+     */
+    public function getProductId(
+        SettingsProductEnum $contractType,
+        bool $isTrial = false,
+        bool $isCmf = false,
+    ): int {
+        if ($contractType === SettingsProductEnum::ARTIST_PREMIUM && $isTrial) {
+            return self::ARTIST_PREMIUM_TRIAL_PRODUCT_ID;
+        } elseif ($contractType === SettingsProductEnum::ARTIST_PREMIUM && $isCmf) {
+            return self::ARTIST_PREMIUM_CMF_PRODUCT_ID;
+        } elseif ($contractType === SettingsProductEnum::ARTIST_PREMIUM) {
+            return self::ARTIST_PREMIUM_PRODUCT_ID;
+        } elseif ($contractType === SettingsProductEnum::ARTIST && $isCmf) {
+            return self::ARTIST_STANDARD_CMF_PRODUCT_ID;
+        } else {
+            throw new \InvalidArgumentException('Invalid contract type');
+        }
+    }
+
+    /**
+     * Exécute une requête SQL sur la DB Dolibarr.
+     *
+     * @param string                   $sql    The SQL query to execute
+     * @param array<int|string, mixed> $params The parameters to bind to the query
+     *
+     * @throws \Doctrine\DBAL\Exception
+     */
+    protected function executeQuery(string $sql, array $params = []): void
+    {
+        $this->dolibarrConnection->executeQuery($sql, $params);
+    }
+
+    /**
+     * Remplace le ou les commerciaux actuellement affectés à la société par l'utilisateur 'api'
+     * (pas de solution trouvée via l'API).
+     *
+     * @return void
+     *
+     * @throws \Doctrine\DBAL\Exception
+     */
+    public function updateSocietyCommercialsWithApi(int $societyId)
+    {
+        $apiUserId = 8;
+
+        $this->executeQuery(
+            'DELETE FROM llx_societe_commerciaux WHERE fk_soc = ?',
+            [$societyId]
+        );
+
+        $this->executeQuery(
+            'INSERT INTO llx_societe_commerciaux (fk_soc, fk_user) VALUES (?, ?)',
+            [$societyId, $apiUserId]
+        );
+    }
+
+    /**
+     * Enregistre une entrée dans le journal des actions commercial de la société Dolibarr
+     * (pas de solution trouvée via l'API).
+     *
+     * @throws \Exception
+     */
+    public function addActionComm(int $societyId, string $title, string $message): void
+    {
+        $tz = new \DateTimeZone('Europe/Paris');
+        $now = DatesUtils::new('now', $tz)->format('Y-m-d H:i:s');
+        $apiUserId = 8;
+
+        $sql = "INSERT INTO llx_actioncomm (fk_soc, ref, code, label, note, datep, datep2, datec, fk_user_author, fk_user_mod, fk_user_action, percent) 
+                   VALUES (?, -1, 'AC_OT_ONLINE_STORE', ?, ?, ?, ?, ?, ?, ?, ?, -1)";
+
+        $this->executeQuery($sql, [
+            $societyId,
+            $title,
+            $message,
+            $now,
+            $now,
+            $now,
+            $apiUserId,
+            $apiUserId,
+            $apiUserId,
+        ]);
+    }
+
+    /**
+     * Retourne le nom du produit dans Dolibarr.
+     *
+     * @param SettingsProductEnum $contractType Produit concerné (@see SettingsProductEnum)
+     */
+    public function getDolibarrProductName(SettingsProductEnum $contractType, bool $isTrial = false): ?string
+    {
+        return match ($contractType) {
+            SettingsProductEnum::ARTIST => 'Opentalent Artist',
+            SettingsProductEnum::ARTIST_PREMIUM => $isTrial ? 'Opentalent Artist Premium (Essai)' : 'Opentalent Artist Premium',
+            SettingsProductEnum::SCHOOL => 'Opentalent School',
+            SettingsProductEnum::SCHOOL_PREMIUM => $isTrial ? 'Opentalent School Premium (Essai)' : 'Opentalent School Premium',
+            SettingsProductEnum::MANAGER => 'Opentalent Manager',
+            SettingsProductEnum::MANAGER_PREMIUM => 'Opentalent Manager Premium',
+            default => null,
+        };
+    }
+}

+ 3 - 1
src/Service/Mailer/Builder/AbstractBuilder.php

@@ -64,7 +64,9 @@ class AbstractBuilder implements AbstractBuilderInterface
      */
     public function render(string $template, array $context): string
     {
-        return $this->twig->render(sprintf('@templates/emails/%s.html.twig', $template), $context);
+        $templatePath = sprintf('@templates/emails/%s.html.twig', $template);
+
+        return $this->twig->render($templatePath, $context);
     }
 
     /**

+ 58 - 0
src/Service/Mailer/Builder/Shop/NewStructureArtistPremium/ConfirmationToRepresentativeBuilder.php

@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Builder\Shop\NewStructureArtistPremium;
+
+use App\Entity\Access\Access;
+use App\Enum\Core\EmailSendingTypeEnum;
+use App\Service\Mailer\Builder\AbstractBuilder;
+use App\Service\Mailer\Builder\BuilderInterface;
+use App\Service\Mailer\Email;
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\Mailer\Model\Shop\NewStructureArtistPremium\ConfirmationToRepresentativeModel;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * Classe NewStructureArtistPremiumTrialRequestAccountCreationBuilder qui est chargé de construire l'Email
+ * d'accès au compte essai Opentalent Artist Premium.
+ */
+class ConfirmationToRepresentativeBuilder extends AbstractBuilder implements BuilderInterface
+{
+    public function __construct(
+        private readonly EntityManagerInterface $entityManager,
+        private readonly string $opentalentNoReplyEmailAddress,
+    ) {
+    }
+
+    public function support(MailerModelInterface $mailerModel): bool
+    {
+        return $mailerModel instanceof ConfirmationToRepresentativeModel;
+    }
+
+    /**
+     * @param ConfirmationToRepresentativeModel $mailerModel
+     */
+    public function build(MailerModelInterface $mailerModel): ArrayCollection
+    {
+        $author = $this->entityManager->getRepository(Access::class)->find($mailerModel->getSenderId());
+
+        $context = [
+            'mailerModel' => $mailerModel,
+        ];
+
+        $content = $this->render('shop/NewStructureArtistPremium/confirmation-to-representative', $context);
+
+        $email = (new Email())
+            ->setEmailEntity($this->buildEmailEntity('Accédez à votre compte essai Opentalent Artist Premium', $author, $content))
+            ->setContent($content)
+            ->setFrom($this->opentalentNoReplyEmailAddress)
+            ->setFromName('Opentalent - Agenda & Logiciels Culturels');
+
+        // Add recipient as a string (direct email address)
+        $this->addRecipient($email, $mailerModel->getTrialRequest()->getRepresentativeEmail(), EmailSendingTypeEnum::TO);
+
+        return new ArrayCollection([$email]);
+    }
+}

+ 58 - 0
src/Service/Mailer/Builder/Shop/NewStructureArtistPremium/NotificationToSalesAdminBuilder.php

@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Builder\Shop\NewStructureArtistPremium;
+
+use App\Entity\Access\Access;
+use App\Enum\Core\EmailSendingTypeEnum;
+use App\Service\Mailer\Builder\AbstractBuilder;
+use App\Service\Mailer\Builder\BuilderInterface;
+use App\Service\Mailer\Email;
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\Mailer\Model\Shop\NewStructureArtistPremium\NotificationToSalesAdminModel;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * Classe NewStructureArtistPremiumTrialRequestSalesAdminBuilder qui est chargé de construire l'Email
+ * d'information à l'administration des ventes concernant une demande d'essai artist premium pour une nouvelle structure.
+ */
+class NotificationToSalesAdminBuilder extends AbstractBuilder implements BuilderInterface
+{
+    public function __construct(
+        private readonly EntityManagerInterface $entityManager,
+        private readonly string $opentalentNoReplyEmailAddress,
+    ) {
+    }
+
+    public function support(MailerModelInterface $mailerModel): bool
+    {
+        return $mailerModel instanceof NotificationToSalesAdminModel;
+    }
+
+    /**
+     * @param NotificationToSalesAdminModel $mailerModel
+     */
+    public function build(MailerModelInterface $mailerModel): ArrayCollection
+    {
+        $author = $this->entityManager->getRepository(Access::class)->find($mailerModel->getSenderId());
+
+        $context = [
+            'trialRequest' => $mailerModel->getTrialRequest(),
+        ];
+
+        $content = $this->render('shop/NewStructureArtistPremium/notification-to-sales-admin', $context);
+
+        $email = (new Email())
+            ->setEmailEntity($this->buildEmailEntity('Nouvelle demande d\'essai Artist Premium', $author, $content))
+            ->setContent($content)
+            ->setFrom($this->opentalentNoReplyEmailAddress)
+            ->setFromName('Opentalent - Agenda & Logiciels Culturels');
+
+        // Add recipient as a string (direct email address)
+        $this->addRecipient($email, 'adv@opentalent.fr', EmailSendingTypeEnum::TO);
+
+        return new ArrayCollection([$email]);
+    }
+}

+ 57 - 0
src/Service/Mailer/Builder/Shop/TokenValidationBuilder.php

@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Builder\Shop;
+
+use App\Entity\Access\Access;
+use App\Enum\Core\EmailSendingTypeEnum;
+use App\Service\Mailer\Builder\AbstractBuilder;
+use App\Service\Mailer\Builder\BuilderInterface;
+use App\Service\Mailer\Email;
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\Mailer\Model\Shop\TokenValidationModel;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * Classe NewStructureTrialRequestValidationBuilder qui est chargé de construire l'Email de validation d'une demande d'essai de nouvelle structure.
+ */
+class TokenValidationBuilder extends AbstractBuilder implements BuilderInterface
+{
+    public function __construct(
+        private readonly EntityManagerInterface $entityManager,
+        private readonly string $opentalentNoReplyEmailAddress,
+    ) {
+    }
+
+    public function support(MailerModelInterface $mailerModel): bool
+    {
+        return $mailerModel instanceof TokenValidationModel;
+    }
+
+    /**
+     * @param TokenValidationModel $mailerModel
+     */
+    public function build(MailerModelInterface $mailerModel): ArrayCollection
+    {
+        $author = $this->entityManager->getRepository(Access::class)->find($mailerModel->getSenderId());
+
+        $context = [
+            'mailerModel' => $mailerModel,
+        ];
+
+        $content = $this->render('shop/token-validation', $context);
+
+        $email = (new Email())
+            ->setEmailEntity($this->buildEmailEntity('Validation de votre demande d\'essai', $author, $content))
+            ->setContent($content)
+            ->setFrom($this->opentalentNoReplyEmailAddress)
+            ->setFromName('Opentalent - Agenda & Logiciels Culturels');
+
+        // Add recipient as a string (direct email address)
+        $this->addRecipient($email, $mailerModel->getRepresentativeEmail(), EmailSendingTypeEnum::TO);
+
+        return new ArrayCollection([$email]);
+    }
+}

+ 82 - 0
src/Service/Mailer/Model/Shop/NewStructureArtistPremium/ConfirmationToRepresentativeModel.php

@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Model\Shop\NewStructureArtistPremium;
+
+use App\ApiResources\Shop\NewStructureArtistPremiumTrialRequest;
+use App\Service\Mailer\Model\AbstractMailerModel;
+use App\Service\Mailer\Model\MailerModelInterface;
+
+/**
+ * Classe NewStructureArtistPremiumTrialRequestAccountCreationModel qui conserve les données pour construire le mail
+ * d'accès au compte essai Opentalent Artist Premium.
+ */
+class ConfirmationToRepresentativeModel extends AbstractMailerModel implements MailerModelInterface
+{
+    private NewStructureArtistPremiumTrialRequest $trialRequest;
+    private string $accountCreationUrl;
+    private string $faqUrl;
+    private string $adminUsername;
+    private string $adminLoginUrl;
+
+    public function getTrialRequest(): NewStructureArtistPremiumTrialRequest
+    {
+        return $this->trialRequest;
+    }
+
+    public function setTrialRequest(NewStructureArtistPremiumTrialRequest $trialRequest): self
+    {
+        $this->trialRequest = $trialRequest;
+
+        return $this;
+    }
+
+    public function getAccountCreationUrl(): string
+    {
+        return $this->accountCreationUrl;
+    }
+
+    public function setAccountCreationUrl(string $accountCreationUrl): self
+    {
+        $this->accountCreationUrl = $accountCreationUrl;
+
+        return $this;
+    }
+
+    public function getFaqUrl(): string
+    {
+        return $this->faqUrl;
+    }
+
+    public function setFaqUrl(string $faqUrl): self
+    {
+        $this->faqUrl = $faqUrl;
+
+        return $this;
+    }
+
+    public function getAdminUsername(): string
+    {
+        return $this->adminUsername;
+    }
+
+    public function setAdminUsername(string $adminUsername): self
+    {
+        $this->adminUsername = $adminUsername;
+
+        return $this;
+    }
+
+    public function getAdminLoginUrl(): string
+    {
+        return $this->adminLoginUrl;
+    }
+
+    public function setAdminLoginUrl(string $adminLoginUrl): self
+    {
+        $this->adminLoginUrl = $adminLoginUrl;
+
+        return $this;
+    }
+}

+ 30 - 0
src/Service/Mailer/Model/Shop/NewStructureArtistPremium/NotificationToSalesAdminModel.php

@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Model\Shop\NewStructureArtistPremium;
+
+use App\ApiResources\Shop\NewStructureArtistPremiumTrialRequest;
+use App\Service\Mailer\Model\AbstractMailerModel;
+use App\Service\Mailer\Model\MailerModelInterface;
+
+/**
+ * Classe NewStructureArtistPremiumTrialRequestSalesAdminModel qui conserve les données pour construire le mail
+ * d'information à l'administration des ventes concernant une demande d'essai artist premium pour une nouvelle structure.
+ */
+class NotificationToSalesAdminModel extends AbstractMailerModel implements MailerModelInterface
+{
+    private NewStructureArtistPremiumTrialRequest $trialRequest;
+
+    public function getTrialRequest(): NewStructureArtistPremiumTrialRequest
+    {
+        return $this->trialRequest;
+    }
+
+    public function setTrialRequest(NewStructureArtistPremiumTrialRequest $trialRequest): self
+    {
+        $this->trialRequest = $trialRequest;
+
+        return $this;
+    }
+}

+ 93 - 0
src/Service/Mailer/Model/Shop/TokenValidationModel.php

@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Model\Shop;
+
+use App\Service\Mailer\Model\AbstractMailerModel;
+use App\Service\Mailer\Model\MailerModelInterface;
+
+/**
+ * Classe NewStructureTrialRequestValidationModel qui conserve les données pour construire le mail de validation d'une demande d'essai de nouvelle structure.
+ */
+class TokenValidationModel extends AbstractMailerModel implements MailerModelInterface
+{
+    private string $token;
+    private string $representativeEmail;
+    private string $representativeFirstName;
+    private string $representativeLastName;
+    private string $structureName;
+    private string $validationUrl;
+
+    public function getToken(): string
+    {
+        return $this->token;
+    }
+
+    public function setToken(string $token): self
+    {
+        $this->token = $token;
+
+        return $this;
+    }
+
+    public function getRepresentativeEmail(): string
+    {
+        return $this->representativeEmail;
+    }
+
+    public function setRepresentativeEmail(string $representativeEmail): self
+    {
+        $this->representativeEmail = $representativeEmail;
+
+        return $this;
+    }
+
+    public function getRepresentativeFirstName(): string
+    {
+        return $this->representativeFirstName;
+    }
+
+    public function setRepresentativeFirstName(string $representativeFirstName): self
+    {
+        $this->representativeFirstName = $representativeFirstName;
+
+        return $this;
+    }
+
+    public function getRepresentativeLastName(): string
+    {
+        return $this->representativeLastName;
+    }
+
+    public function setRepresentativeLastName(string $representativeLastName): self
+    {
+        $this->representativeLastName = $representativeLastName;
+
+        return $this;
+    }
+
+    public function getStructureName(): string
+    {
+        return $this->structureName;
+    }
+
+    public function setStructureName(string $structureName): self
+    {
+        $this->structureName = $structureName;
+
+        return $this;
+    }
+
+    public function getValidationUrl(): string
+    {
+        return $this->validationUrl;
+    }
+
+    public function setValidationUrl(string $validationUrl): self
+    {
+        $this->validationUrl = $validationUrl;
+
+        return $this;
+    }
+}

+ 60 - 14
src/Service/Organization/OrganizationFactory.php

@@ -43,7 +43,6 @@ use App\Service\Utils\DatesUtils;
 use App\Service\Utils\SecurityUtils;
 use App\Service\Utils\UrlBuilder;
 use Doctrine\ORM\EntityManagerInterface;
-use Elastica\Param;
 use libphonenumber\NumberParseException;
 use libphonenumber\PhoneNumberUtil;
 use Psr\Log\LoggerInterface;
@@ -61,6 +60,13 @@ class OrganizationFactory
 
     protected PhoneNumberUtil $phoneNumberUtil;
 
+    /**
+     * Regex pattern for password validation.
+     * Requires at least 8 characters, including at least one uppercase letter,
+     * one lowercase letter, one digit, and one special character.
+     */
+    private const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).{8,}$/';
+
     public function __construct(
         private readonly SubdomainService $subdomainService,
         private readonly OrganizationRepository $organizationRepository,
@@ -75,6 +81,7 @@ class OrganizationFactory
         private readonly ApiLegacyRequestService $apiLegacyRequestService,
         private readonly FunctionTypeRepository $functionTypeRepository,
         private readonly FileManager $fileManager,
+        private readonly \App\Repository\Access\AccessRepository $accessRepository,
     ) {
         $this->phoneNumberUtil = PhoneNumberUtil::getInstance();
     }
@@ -190,7 +197,7 @@ class OrganizationFactory
     /**
      * Lève une exception si cette organisation existe déjà.
      */
-    protected function interruptIfOrganizationExists(OrganizationCreationRequest $organizationCreationRequest): void
+    public function interruptIfOrganizationExists(OrganizationCreationRequest $organizationCreationRequest): void
     {
         if (
             $organizationCreationRequest->getSiretNumber()
@@ -348,12 +355,6 @@ class OrganizationFactory
         // Création du sous-domaine
         $subdomain = $this->makeSubdomain($organizationCreationRequest);
         $organization->addSubdomain($subdomain);
-
-        // <--- Pour la rétrocompatibilité avec la v1 ; pourra être supprimé lorsque la migration sera achevée
-        $parameters = $organization->getParameters();
-        $parameters->setSubDomain($organizationCreationRequest->getSubdomain());
-        $parameters->setOtherWebsite('https://'.$organizationCreationRequest->getSubdomain().'.opentalent.fr');
-        // --->
         $this->logger->debug(' - Subdomain created');
 
         return $organization;
@@ -390,6 +391,13 @@ class OrganizationFactory
     {
         $parameters = new Parameters();
 
+        // <--- Pour la rétrocompatibilité avec la v1 ; pourra être supprimé lorsque la migration sera achevée
+        $parameters->setSubDomain($organizationCreationRequest->getSubdomain());
+        $parameters->setOtherWebsite('https://'.$organizationCreationRequest->getSubdomain().'.opentalent.fr');
+        // --->
+
+        $parameters->setDesactivateOpentalentSiteWeb(!$organizationCreationRequest->getCreateWebsite());
+
         return $parameters;
     }
 
@@ -460,15 +468,10 @@ class OrganizationFactory
      */
     protected function makeContactPoint(OrganizationCreationRequest $organizationCreationRequest): ContactPoint
     {
-        if (!$this->phoneNumberUtil->isPossibleNumber($organizationCreationRequest->getPhoneNumber())) {
-            throw new \RuntimeException('Phone number is invalid or missing');
-        }
-        $phoneNumber = $this->phoneNumberUtil->parse($organizationCreationRequest->getPhoneNumber());
-
         $contactPoint = new ContactPoint();
         $contactPoint->setContactType(ContactPointTypeEnum::PRINCIPAL);
         $contactPoint->setEmail($organizationCreationRequest->getEmail());
-        $contactPoint->setTelphone($phoneNumber);
+        $contactPoint->setTelphone($organizationCreationRequest->getPhoneNumber());
         $contactPoint->setCreateDate($organizationCreationRequest->getCreationDate());
         $contactPoint->setCreatedBy($organizationCreationRequest->getAuthorId());
 
@@ -530,6 +533,7 @@ class OrganizationFactory
     {
         $admin = new Person();
         $admin->setUsername('admin'.strtolower($organizationCreationRequest->getSubdomain()));
+        $admin->setUsernameCanonical('admin'.strtolower($organizationCreationRequest->getSubdomain()));
         $randomString = ByteString::fromRandom(32)->toString();
         $admin->setPassword($randomString);
         $admin->setEnabled(true);
@@ -610,6 +614,7 @@ class OrganizationFactory
             }
 
             $person->setUsername($creationRequestData->getUsername());
+            $person->setUsernameCanonical($creationRequestData->getUsername());
             $person->setPassword(ByteString::fromRandom(32)->toString());
             $person->setGender($creationRequestData->getGender());
             $person->setName(
@@ -932,4 +937,45 @@ class OrganizationFactory
     {
         $this->dolibarrApiService->switchSocietyToProspect($organizationId);
     }
+
+    /**
+     * Sets the password for the admin account of an organization.
+     *
+     * The admin account is identified by the adminaccess property set to true.
+     * The password must meet the complexity requirements: at least 8 characters,
+     * including at least one uppercase letter, one lowercase letter, one digit,
+     * and one special character.
+     *
+     * @param Organization $organization The organization whose admin account password will be set
+     * @param string       $password     The plain password to set
+     *
+     * @throws \RuntimeException If no admin account is found or if the password doesn't meet the requirements
+     */
+    public function setAdminAccountPassword(Organization $organization, string $password): void
+    {
+        // Validate password complexity
+        if (!preg_match(self::PASSWORD_PATTERN, $password)) {
+            throw new \RuntimeException('Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one special character.');
+        }
+
+        // Find the admin access using the repository
+        $adminAccess = $this->accessRepository->findAdminAccess($organization);
+
+        if (!$adminAccess) {
+            throw new \RuntimeException('No admin account found for this organization.');
+        }
+
+        // Hash the password using bcrypt
+        $hashedPassword = password_hash($password, PASSWORD_BCRYPT);
+
+        // Set the hashed password on the Person entity
+        $person = $adminAccess->getPerson();
+        $person->setPassword($hashedPassword);
+
+        // Persist the changes
+        $this->entityManager->persist($person);
+        $this->entityManager->flush();
+
+        $this->logger->info('Admin account password set for organization: '.$organization->getId());
+    }
 }

+ 1 - 0
src/Service/Organization/OrganizationProfileCreator.php

@@ -10,6 +10,7 @@ use App\Enum\Organization\PrincipalTypeEnum;
 use App\Service\Network\Tree;
 use App\Service\Organization\Utils as OrganizationUtils;
 use App\Service\Security\Module;
+use App\Service\Shop\Trial;
 use App\Service\Utils\DatesUtils;
 use App\Test\Service\Organization\OrganizationProfileCreatorTest;
 

+ 0 - 37
src/Service/Organization/Trial.php

@@ -1,37 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Service\Organization;
-
-use App\Service\Utils\DatesUtils;
-
-/**
- * Class OrganizationProfileCreator : Service contenant les manipulations associés à la ressource OrganizationProfile.
- */
-class Trial
-{
-    public function __construct(
-        private DatesUtils $datesUtils,
-    ) {
-    }
-
-    /**
-     * Retourne le décompte sur 30 jours du dernier lancement d'essai.
-     *
-     * @return int
-     */
-    public function getTrialCountdown(?\DateTimeInterface $trialStartDate)
-    {
-        if (empty($trialStartDate)) {
-            return 0;
-        }
-
-        $daysSince = $this->datesUtils::daysSince($trialStartDate);
-        if ($daysSince > 30) {
-            return 0;
-        }
-
-        return 30 - $daysSince;
-    }
-}

+ 351 - 0
src/Service/Shop/ShopService.php

@@ -0,0 +1,351 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Shop;
+
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\ApiResources\Shop\NewStructureArtistPremiumTrialRequest;
+use App\Entity\Organization\Organization;
+use App\Entity\Shop\ShopRequest;
+use App\Enum\Access\AccessIdsEnum;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Enum\Shop\ShopRequestStatus;
+use App\Enum\Shop\ShopRequestType;
+use App\Message\Message\Shop\NewStructureArtistPremiumTrial;
+use App\Service\Mailer\Mailer;
+use App\Service\Mailer\Model\Shop\NewStructureArtistPremium\ConfirmationToRepresentativeModel;
+use App\Service\Mailer\Model\Shop\NewStructureArtistPremium\NotificationToSalesAdminModel;
+use App\Service\Mailer\Model\Shop\TokenValidationModel;
+use App\Service\Organization\OrganizationFactory;
+use App\Service\Utils\DatesUtils;
+use App\Service\Utils\UrlBuilder;
+use Doctrine\DBAL\Exception;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Exception\ORMException;
+use Doctrine\ORM\OptimisticLockException;
+use libphonenumber\PhoneNumberUtil;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
+use Symfony\Component\Messenger\Exception\ExceptionInterface;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * Service for managing shop requests.
+ *
+ * This service handles various shop-related operations.
+ * It provides functionality for:
+ * - Registering new shop requests
+ * - Validating and processing shop requests
+ * - Creating organizations based on trial requests
+ * - Starting premium trials for organizations
+ * - Generating subdomains from structure names
+ */
+class ShopService
+{
+    protected PhoneNumberUtil $phoneNumberUtil;
+
+    public function __construct(
+        private EntityManagerInterface $entityManager,
+        private Mailer $mailer,
+        private string $publicBaseUrl,
+        private string $adminBaseUrl,
+        private OrganizationFactory $organizationFactory,
+        private SerializerInterface $serializer,
+        private LoggerInterface $logger,
+        private MessageBusInterface $messageBus,
+        private Trial $trial,
+        private string $faqUrl,
+        private readonly string $softwareWebsiteUrl,
+    ) {
+        $this->phoneNumberUtil = PhoneNumberUtil::getInstance();
+    }
+
+    /**
+     * A new shop request has been submitted.
+     * Register the request, and send the validation link by email.
+     *
+     * @param array<string, mixed> $data
+     *
+     * @throws TransportExceptionInterface
+     */
+    public function registerNewShopRequest(ShopRequestType $type, array $data): ShopRequest
+    {
+        $this->controlShopRequestData($type, $data);
+        $request = $this->createRequest($type, $data);
+        $this->sendRequestValidationLink($request);
+
+        return $request;
+    }
+
+    /**
+     * Validate the shop request based on its type.
+     * For NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL, check if the organization already exists.
+     * For other types, throw an error.
+     *
+     * @param array<string, mixed> $data
+     */
+    protected function controlShopRequestData(ShopRequestType $type, array $data): void
+    {
+        // @phpstan-ignore-next-line identical.alwaysTrue
+        if ($type === ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL) {
+            $this->validateNewStructureArtistPremiumTrialRequest($data);
+        } else {
+            throw new \RuntimeException('request type not supported');
+        }
+    }
+
+    /**
+     * Validate the request and dispatch the appropriate job based on the request type.
+     *
+     * @throws \RuntimeException|ExceptionInterface
+     */
+    public function processShopRequest(ShopRequest $shopRequest): void
+    {
+        // Dispatch appropriate job based on request type
+        switch ($shopRequest->getType()->value) {
+            case ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL->value:
+                $this->handleNewStructureArtistPremiumTrialRequest($shopRequest->getToken());
+//                $this->messageBus->dispatch(
+//                    new NewStructureArtistPremiumTrial($shopRequest->getToken())
+//                );
+                break;
+            default:
+                throw new \RuntimeException('request type not supported');
+        }
+
+        $shopRequest->setStatus(ShopRequestStatus::VALIDATED);
+        $this->entityManager->persist($shopRequest);
+        $this->entityManager->flush();
+    }
+
+    /**
+     * Create and persist a new ShopRequest entity.
+     *
+     * @param array<string, mixed> $data
+     */
+    protected function createRequest(ShopRequestType $type, array $data): ShopRequest
+    {
+        $shopRequest = new ShopRequest();
+        $shopRequest->setToken(Uuid::v4()->toRfc4122());
+        $shopRequest->setType($type);
+        $shopRequest->setData($data);
+
+        $this->entityManager->persist($shopRequest);
+        $this->entityManager->flush();
+
+        return $shopRequest;
+    }
+
+    /**
+     * Send validation email with link.
+     *
+     * @throws TransportExceptionInterface
+     */
+    protected function sendRequestValidationLink(ShopRequest $shopRequest): void
+    {
+        $validationUrl = UrlBuilder::concat(
+            $this->softwareWebsiteUrl,
+            ['/shop/try/validation'],
+            ['token' => $shopRequest->getToken()]
+        );
+
+        $data = $shopRequest->getData();
+
+        $model = new TokenValidationModel();
+        $model
+            ->setToken($shopRequest->getToken())
+            ->setRepresentativeEmail($data['representativeEmail'] ?? '')
+            ->setRepresentativeFirstName($data['representativeFirstName'] ?? '')
+            ->setRepresentativeLastName($data['representativeLastName'] ?? '')
+            ->setStructureName($data['structureName'] ?? '')
+            ->setValidationUrl($validationUrl)
+            ->setSenderId(AccessIdsEnum::ADMIN_2IOPENSERVICE->value);
+
+        $this->mailer->main($model);
+
+        $shopRequest->setStatus(ShopRequestStatus::ACTIVATION_LINK_SENT);
+        $this->entityManager->persist($shopRequest);
+        $this->entityManager->flush();
+    }
+
+    /**
+     * Handles the processing of a new structure artist premium trial request.
+     *
+     * @param string $token The token identifying the shop request
+     *
+     * @throws Exception
+     * @throws \JsonException
+     * @throws ORMException
+     * @throws OptimisticLockException
+     */
+    public function handleNewStructureArtistPremiumTrialRequest(string $token): void
+    {
+        // Retrieve the ShopRequest entity using its token
+        $shopRequest = $this->entityManager->find(ShopRequest::class, $token);
+
+        if (!$shopRequest) {
+            $this->logger->error('Cannot find ShopRequest with token: '.$token);
+
+            return;
+        }
+
+        try {
+            // Convert the stored JSON data to a NewStructureArtistPremiumTrialRequest object
+            $data = $shopRequest->getData();
+            $trialRequest = $this->serializer->deserialize(
+                json_encode($data),
+                NewStructureArtistPremiumTrialRequest::class,
+                'json'
+            );
+
+            $organization = $this->createOrganization($trialRequest);
+
+            // Set the admin account password
+            $this->organizationFactory->setAdminAccountPassword($organization, $trialRequest->getPassword());
+
+            // Start the artist premium trial
+            $this->trial->startArtistPremiumTrialForNewStructure($organization, $trialRequest);
+
+            // Send email to sales administration
+            $this->sendMailToSalesAdministration($trialRequest);
+
+            // Send email to representative
+            $this->sendConfirmationMailToRepresentative($trialRequest);
+
+            $this->logger->info('Successfully processed NewStructureArtistPremiumTrial for token: '.$token);
+        } catch (\Throwable $e) {
+            $shopRequest->setStatus(ShopRequestStatus::ERROR);
+            $this->entityManager->persist($shopRequest);
+            $this->entityManager->flush();
+
+            $this->logger->error('Error processing NewStructureArtistPremiumTrial for token: '.$token.'. Error: '.$e->getMessage());
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Creates a new organization based on a trial request.
+     *
+     * @param NewStructureArtistPremiumTrialRequest $trialRequest The trial request containing organization data
+     *
+     * @return Organization The created organization
+     */
+    protected function createOrganization(NewStructureArtistPremiumTrialRequest $trialRequest): Organization
+    {
+        // Generate an OrganizationCreationRequest object
+        $organizationCreationRequest = $this->createOrganizationCreationRequestFromTrialRequest($trialRequest);
+
+        // Create the organization
+        return $this->organizationFactory->create($organizationCreationRequest);
+    }
+
+    /**
+     * Vérifie la validité d'une requête d'essai artist premium pour une nouvelle structure.
+     *
+     * @param array<string, mixed> $data
+     *
+     * @throws \Exception
+     */
+    protected function validateNewStructureArtistPremiumTrialRequest(
+        array $data,
+    ): void {
+        $trialRequestObj = $this->serializer->deserialize(
+            json_encode($data),
+            NewStructureArtistPremiumTrialRequest::class,
+            'json'
+        );
+
+        // Check if organization already exists
+        $organizationCreationRequest = $this->createOrganizationCreationRequestFromTrialRequest($trialRequestObj);
+        $this->organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    /**
+     * Creates an OrganizationCreationRequest from a NewStructureArtistPremiumTrialRequest.
+     *
+     * @param NewStructureArtistPremiumTrialRequest $trialRequest The trial request containing organization data
+     *
+     * @return OrganizationCreationRequest The created organization creation request
+     *
+     * @throws \Exception
+     */
+    protected function createOrganizationCreationRequestFromTrialRequest(
+        NewStructureArtistPremiumTrialRequest $trialRequest,
+    ): OrganizationCreationRequest {
+        $organizationCreationRequest = new OrganizationCreationRequest();
+        $organizationCreationRequest->setName($trialRequest->getStructureName());
+        $organizationCreationRequest->setStreetAddress1($trialRequest->getAddress());
+        $organizationCreationRequest->setStreetAddress2($trialRequest->getAddressComplement());
+        $organizationCreationRequest->setPostalCode($trialRequest->getPostalCode());
+        $organizationCreationRequest->setCity($trialRequest->getCity());
+        $organizationCreationRequest->setEmail($trialRequest->getStructureEmail());
+        $organizationCreationRequest->setPrincipalType($trialRequest->getStructureType());
+        $organizationCreationRequest->setLegalStatus($trialRequest->getLegalStatus());
+        $organizationCreationRequest->setSiretNumber($trialRequest->getSiren());
+        $organizationCreationRequest->setPhoneNumber($trialRequest->getRepresentativePhone());
+        $organizationCreationRequest->setSubdomain($trialRequest->getStructureIdentifier());
+        $organizationCreationRequest->setSendConfirmationEmailAt($trialRequest->getRepresentativeEmail());
+
+        // Set default values
+        $organizationCreationRequest->setProduct(SettingsProductEnum::FREEMIUM);
+        $organizationCreationRequest->setCreateWebsite(false);
+        $organizationCreationRequest->setClient(false);
+        $organizationCreationRequest->setCreationDate(DatesUtils::new());
+
+        return $organizationCreationRequest;
+    }
+
+    /**
+     * Envoie un email à l'administration des ventes pour informer d'une nouvelle demande d'essai artist premium.
+     *
+     * @param NewStructureArtistPremiumTrialRequest $trialRequest La demande d'essai
+     *
+     * @throws TransportExceptionInterface
+     */
+    protected function sendMailToSalesAdministration(NewStructureArtistPremiumTrialRequest $trialRequest): void
+    {
+        // Create the email model
+        $model = new NotificationToSalesAdminModel();
+        $model
+            ->setTrialRequest($trialRequest)
+            ->setSenderId(AccessIdsEnum::ADMIN_2IOPENSERVICE->value);
+
+        // Send the email to the sales administration
+        $this->mailer->main($model);
+    }
+
+    /**
+     * Envoie un email au représentant pour l'informer que sa demande d'essai artist premium a été validée
+     * et lui fournir un lien pour créer son compte et accéder au logiciel.
+     *
+     * @param NewStructureArtistPremiumTrialRequest $trialRequest La demande d'essai
+     *
+     * @throws TransportExceptionInterface
+     */
+    protected function sendConfirmationMailToRepresentative(
+        NewStructureArtistPremiumTrialRequest $trialRequest,
+    ): void {
+        // Create the admin username
+        $adminUsername = 'admin'.$trialRequest->getStructureIdentifier();
+
+        // Create the admin login URL
+        $adminLoginUrl = UrlBuilder::concat($this->adminBaseUrl, ['#/login/']);
+
+        // Create the email model
+        $model = new ConfirmationToRepresentativeModel();
+        $model
+            ->setTrialRequest($trialRequest)
+            ->setAccountCreationUrl(UrlBuilder::concat($this->publicBaseUrl, ['/account/create']))
+            ->setFaqUrl($this->faqUrl)
+            ->setAdminUsername($adminUsername)
+            ->setAdminLoginUrl($adminLoginUrl)
+            ->setSenderId(AccessIdsEnum::ADMIN_2IOPENSERVICE->value);
+
+        // Send the email to the representative
+        $this->mailer->main($model);
+    }
+}

+ 103 - 0
src/Service/Shop/Trial.php

@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Shop;
+
+use App\ApiResources\Shop\NewStructureArtistPremiumTrialRequest;
+use App\Entity\Organization\Organization;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Service\Dolibarr\DolibarrApiService;
+use App\Service\Dolibarr\DolibarrUtils;
+use App\Service\Utils\DatesUtils;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * Class Trial : Service contenant les manipulations associés aux périodes d'essai.
+ */
+class Trial
+{
+    public function __construct(
+        private DatesUtils $datesUtils,
+        private EntityManagerInterface $entityManager,
+        private DolibarrApiService $dolibarrApiService,
+        private DolibarrUtils $dolibarrUtils,
+    ) {
+    }
+
+    /**
+     * Retourne le décompte sur 30 jours du dernier lancement d'essai.
+     *
+     * @return int
+     */
+    public function getTrialCountdown(?\DateTimeInterface $trialStartDate)
+    {
+        if (empty($trialStartDate)) {
+            return 0;
+        }
+
+        $daysSince = $this->datesUtils::daysSince($trialStartDate);
+        if ($daysSince > 30) {
+            return 0;
+        }
+
+        return 30 - $daysSince;
+    }
+
+    /**
+     * Start an artist premium trial for an organization.
+     *
+     * @param Organization                          $organization The organization to start the trial for
+     * @param NewStructureArtistPremiumTrialRequest $request      The trial request data
+     *
+     * @throws \Doctrine\DBAL\Exception
+     * @throws \JsonException
+     */
+    public function startArtistPremiumTrialForNewStructure(
+        Organization $organization,
+        NewStructureArtistPremiumTrialRequest $request,
+    ): void {
+        // Update settings
+        $settings = $organization->getSettings();
+        $settings->setProductBeforeTrial($organization->getSettings()->getProduct());
+        $settings->setTrialActive(true);
+        $settings->setLastTrialStartDate(DatesUtils::new());
+        $settings->setProduct(SettingsProductEnum::ARTIST_PREMIUM);
+        $this->entityManager->persist($settings);
+        $this->entityManager->flush();
+
+        $dolibarrSocietyId = $this->dolibarrApiService->getSocietyId($organization->getId());
+
+        // Create contract in dolibarr
+        $dolibarrProductId = $this->dolibarrUtils->getProductId(
+            SettingsProductEnum::ARTIST_PREMIUM, true
+        );
+
+        $contractId = $this->dolibarrApiService->createContract(
+            $dolibarrSocietyId, $dolibarrProductId, true, 1
+        );
+
+        $this->dolibarrApiService->createContractLine($contractId, $dolibarrProductId, 1);
+
+        // Maj le représentant commercial dans dolibarr
+        $this->dolibarrUtils->updateSocietyCommercialsWithApi($dolibarrSocietyId);
+
+        // Met à jour le produit dans Dolibarr
+        $productName = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::ARTIST_PREMIUM, true);
+        $this->dolibarrApiService->updateSocietyProduct($dolibarrSocietyId, $productName);
+
+        // Ajoute une entrée aux actions commerciales dolibarr
+        $message = sprintf(
+            'Action réalisé par : %s %s.<br>Fonction : %s<br>Mail:%s<br>Tel:%s',
+            $request->getRepresentativeFirstName(),
+            $request->getRepresentativeLastName(),
+            $request->getRepresentativeFunction(),
+            $request->getRepresentativeEmail(),
+            $request->getRepresentativePhone()
+        );
+
+        $this->dolibarrUtils->addActionComm(
+            $dolibarrSocietyId, 'Ouverture de la période d\'essai (nouvelle structure)', $message
+        );
+    }
+}

+ 40 - 0
src/Service/Twig/ToBase64Extension.php

@@ -0,0 +1,40 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Twig;
+
+use App\Service\Utils\PathUtils;
+use Path\Path;
+use Symfony\Component\Asset\Packages;
+use Twig\Extension\AbstractExtension;
+use Twig\TwigFilter;
+
+class ToBase64Extension extends AbstractExtension
+{
+    public function __construct(
+        private Packages $packages,
+        private string $projectDir
+    ) {}
+
+    public function getFilters(): array
+    {
+        return [
+            new TwigFilter('img_to_base64', [$this, 'imgToBase64']),
+        ];
+    }
+
+    public function imgToBase64(string $assetPath): string
+    {
+        $publicDir = (new Path($this->projectDir))->append('public');
+        $imgPath = $publicDir->append($assetPath);
+
+        if (!$imgPath->exists()) {
+            return '';
+        }
+
+        $imageData = base64_encode($imgPath->getContent());
+        $mimeType = mime_content_type($imgPath->path()) ?: 'image/jpeg';
+
+        return "data:$mimeType;base64,$imageData";
+    }
+}

+ 1 - 1
src/Service/Typo3/SubdomainService.php

@@ -77,7 +77,7 @@ class SubdomainService
     {
         $reservedSubdomains = $this->parameterBag->get('opentalent.subdomains')['reserved'];
         $subRegexes = array_map(
-            function (string $s) { return '(\b'.trim($s).'\b)'; },
+            function (string $s) { return '(^'.trim($s, '^$/\s').'$)'; },
             $reservedSubdomains
         );
 

+ 5 - 5
src/Service/Utils/UrlBuilder.php

@@ -32,7 +32,7 @@ class UrlBuilder
     /**
      * Concatenate an url and a list of parameters.
      *
-     * @param list<string|int> $parameters
+     * @param array<string, mixed> $parameters
      */
     public static function concatParameters(string $url, array $parameters = []): string
     {
@@ -74,10 +74,10 @@ class UrlBuilder
     /**
      * Build an url.
      *
-     * @param string        $url           The base url
-     * @param array<string> $tails         la suite de l'url sous forme de tableau
-     * @param list<string>  $parameters    A list of parameters (can be an empty array)
-     * @param bool          $preprendHttps Should the 'https://' be prepended if missing
+     * @param string               $url           The base url
+     * @param array<string>        $tails         la suite de l'url sous forme de tableau
+     * @param array<string, mixed> $parameters    A list of parameters (can be an empty array)
+     * @param bool                 $preprendHttps Should the 'https://' be prepended if missing
      */
     public static function concat(string $url, array $tails, array $parameters = [], bool $preprendHttps = false): string
     {

+ 64 - 0
src/State/Processor/Shop/NewStructureArtistPremiumTrialRequestProcessor.php

@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\Shop;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\Enum\Shop\ShopRequestType;
+use App\Service\Shop\ShopService;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Processor for handling new structure trial requests for the artist premium product.
+ *
+ * It is used by the NewStructureArtistPremiumTrialRequest API resource.
+ */
+class NewStructureArtistPremiumTrialRequestProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly ShopService $shopService,
+        private readonly SerializerInterface $serializer,
+    ) {
+    }
+
+    /**
+     * Processes the incoming request to create a new structure artist premium trial request.
+     *
+     * This method:
+     * 1. Validates that the operation is a POST
+     * 2. Serializes the request data to JSON
+     * 3. Delegates to the ShopService to register the new shop request
+     *
+     * @param mixed     $data         The request data (NewStructureArtistPremiumTrialRequest object)
+     * @param Operation $operation    The API Platform operation
+     * @param mixed[]   $uriVariables The URI variables
+     * @param mixed[]   $context      The context
+     *
+     * @return mixed The processed data
+     *
+     * @throws TransportExceptionInterface
+     */
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+    {
+        if (!$operation instanceof Post) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        // Serialize the entity to JSON and decode to array
+        $jsonData = $this->serializer->serialize($data, 'json');
+        $requestData = json_decode($jsonData, true);
+
+        // Create the shop request
+        $this->shopService->registerNewShopRequest(
+            ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL,
+            $requestData
+        );
+
+        return $data;
+    }
+}

+ 66 - 0
src/State/Provider/Shop/ShopRequestProvider.php

@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Provider\Shop;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use App\Entity\Shop\ShopRequest;
+use App\Enum\Shop\ShopRequestStatus;
+use App\Service\Shop\ShopService;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+/**
+ * Provider for NewStructureTrialRequest validation.
+ */
+final class ShopRequestProvider implements ProviderInterface
+{
+    public function __construct(
+        private readonly EntityManagerInterface $entityManager,
+        private readonly ShopService $shopService,
+    ) {
+    }
+
+    /**
+     * @param array<mixed> $uriVariables
+     * @param array<mixed> $context
+     */
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
+    {
+        // Find the request by token
+        $token = $uriVariables['token'] ?? null;
+
+        if (!$token) {
+            throw new NotFoundHttpException('Token not provided');
+        }
+
+        $repository = $this->entityManager->getRepository(ShopRequest::class);
+        $shopRequest = $repository->findOneBy(['token' => $token]);
+
+        if (!$shopRequest) {
+            throw new NotFoundHttpException('Request not found or already validated');
+        }
+
+        if ($shopRequest->getStatus() !== ShopRequestStatus::ACTIVATION_LINK_SENT) {
+            throw new AccessDeniedHttpException('Invalid request status');
+        }
+
+        // Check if the submission date is more than 60 minutes old
+        $now = new \DateTimeImmutable();
+        $requestAge = $now->getTimestamp() - $shopRequest->getSubmissionDate()->getTimestamp();
+
+        if ($requestAge >= 60 * 60) {
+            throw new AccessDeniedHttpException('Request expired: submission date is more than 60 minutes old');
+        }
+
+        // Validate the request using the ShopService
+        $this->shopService->processShopRequest($shopRequest);
+
+        // Return a success response
+        return new Response('Request validated successfully', Response::HTTP_OK);
+    }
+}

+ 126 - 88
templates/emails/base.html.twig

@@ -1,99 +1,137 @@
-{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css'))|inline_css %}
+<!doctype html>
+<html lang="fr">
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+<style type="text/css" media="all">
+    {% block style %}
+    .mail_body *{
+        padding: 0px;
+        margin: 0px;
+        border: 0px;
+        font-family: Arial;
+    }
 
-    <style>
-        {% block style %}
-        .container *{
-            font-family: Arial;
-        }
-        .white{
-            color: #FFFFFF !important;
-        }
-        .black{
-            color: #000000 !important;
-        }
-        .container{
-            border: 2px solid #324250;
-            border-radius: 5px;
-            margin-top: 20px;
-        }
-        .header{
-            background: #324250;
-        }
-        .footer{
-            background: #1ead8f;
-            padding: 10px;
-            color: #FFFFFF;
-            font-weight: bold;
-        }
-        {% endblock %}
-    </style>
+    .mail_body .container{
+        max-width: 800px;
+        border: 2px solid #324250;
+        border-radius: 5px;
+        margin: auto;
+        margin-top: 20px;
+    }
+    .mail_body .content{
+        padding: 20px 8%;
+        font-size: 14px;
+    }
+    .mail_body .header{
+        background: #324250;
+        padding: 20px;
+        color: #FFF;
+    }
+    .mail_body .footer{
+        background: #1ead8f;
+        padding: 10px;
+        color: #FFF;
+        font-weight: bold;
+        font-size: 12px;
+    }
+    .mail_body .footer-white{
+        padding: 10px;
+        color: #000000;
+        font-size: 12px;
+    }
+    .mail_body .footer a{
+        color:#FFFFFF;
+    }
+    .mail_body .btn{
+        color: #fff;
+        background-color: #00ad8e;
+        padding: 8px 10px;
+        border-color: #ccccce;
+        border-radius: 5px;
+        font-size: 18px;
+        font-weight: bold;
+        width: 50%;
+        margin: auto;
+        margin-top: 20px;
+        display: block;
+        text-decoration:none;
+    }
+    .mail_body p{
+        margin-bottom: 10px;
+    }
+    .mail_body .center{
+        text-align: center;
+    }
+    .mail_body .as{
+        margin-top: 20px;
+        padding: 10px 20px 10px 20px;
+        border-top: 1px solid #0a0a0a;
+    }
+    .mail_body .small{
+        font-size: 12px;
+        font-style: italic;
+    }
+    .social_link{
+        padding-right: 15px;
+    }
+    h3{
+        margin-bottom: 10px !important;
+    }
 
-    <container>
+    ul{
+        margin-left:30px !important;
+        margin-bottom:30px !important;
+    }
+    {% endblock %}
+</style>
+</head>
+<body>
+<div class="mail_body">
+    <div class="container">
         {% block header %}
-            <row>
-                <columns small="12" class="header">
-                    <spacer size="10"></spacer>
-                    <p class="white">{{ organization.name }}</p>
-                </columns>
-            </row>
+        <div class="header">
+            {{ organization.name ?? '' }}
+        </div>
         {% endblock %}
 
-        <row>
-            <columns small="12">
-                <spacer size="10"></spacer>
-                    {% block content %}
-                    {% endblock %}
-                <spacer size="10"></spacer>
-            </columns>
-        </row>
+        <div class="content">
+            {% block content %}
+            {% endblock %}
+        </div>
 
         {% block antispam %}
-            <row>
-                <columns small="12">
-                    <p>
-                        <small class="black">
-                            Cet e-mail a été envoyé automatiquement à
-                            #__#ANTISPAM_PERSON_EMAIL#__#
-                            par le logiciel Opentalent utilisé par votre structure.
-                            Merci de ne pas y répondre.
-                        </small>
-                    </p>
-                </columns>
-            </row>
-        {% endblock %}
-
-        {% block footer %}
-            <row>
-                <columns small="12" class="footer">
-                    <center>
-                        <menu>
-                            <small>
-                                <item href="https://support.opentalent.fr" class="white">Aide du logiciel</item>
-                                -
-                                <item href="https://www.opentalent.fr/login" class="white">Se connecter au logiciel</item>
-                            </small>
-                        </menu>
-                    </center>
-                </columns>
-            </row>
+        <div class="as">
+            <p class="small">
+                Cet e-mail a été envoyé automatiquement à&nbsp;#__#ANTISPAM_PERSON_EMAIL#__# par le logiciel Opentalent utilisé par votre structure.
+                <br>Merci de ne pas y répondre.
+            </p>
+        </div>
         {% endblock %}
 
-        {% block footer_signature %}
-
-            <row>
-                <columns small="12">
-                    <spacer size="10"></spacer>
-                    <p class="text-center">
-                        <small class="black">
-                            &copy; Opentalent - {{ "now"|date("Y") }} - La plateforme culturelle : agenda culturel et logiciels pour les structures culturelles -
-                            <a href="https://www.opentalent.fr/agenda">www.opentalent.fr/agenda</a>
-                        </small>
-                    </p>
-                </columns>
-            </row>
-
-        {% endblock %}
+        <div class="footer center">
+            {% block footer %}
+            <a href="https://support.opentalent.fr" target="_blank">Aide du logiciel</a>   - <a href="https://www.opentalent.fr/login" target="_blank">Se connecter au logiciel</a>
+            {% endblock %}
+        </div>
 
-    </container>
+        <div class="footer-white center">
+            {% block footer_signature %}
+            © Opentalent - {{ 'now' | date('Y') }}
+            : Agenda et Logiciels pour les acteurs culturels - <a href="https://www.opentalent.fr" target="_blank">opentalent.fr</a>
+            <br><br><h3>Suivez-nous :</h3>
 
-{% endapply %}
+            <a href="https://www.facebook.com/opentalent" target="_blank" class="social_link">
+                <img src="{{ 'images/facebook.jpg' | img_to_base64 }}" width="30px"/>
+            </a>
+            <a href="https://www.linkedin.com/company/opentalent-agenda-et-logiciels-culturels" target="_blank" class="social_link">
+                <img src="{{ 'images/linkedin.jpg' | img_to_base64 }}" width="30px"/>
+            </a>
+            <a href="https://www.youtube.com/@Opentalent74300" target="_blank" class="social_link">
+                <img src="{{ 'images/youtube.jpg' | img_to_base64 }}" width="30px"/>
+            </a>
+            {% endblock %}
+        </div>
+    </div>
+</div>
+</body>
+</html>

+ 41 - 0
templates/emails/shop/NewStructureArtistPremium/confirmation-to-representative.html.twig

@@ -0,0 +1,41 @@
+{% extends '@templates/emails/base.html.twig' %}
+
+{% block title %}Accédez à votre compte essai Opentalent Artist Premium{% endblock %}
+
+{% block header %}
+<div class="header">
+    {{ mailerModel.trialRequest.structureName }}
+</div>
+{% endblock %}
+
+{% block content %}
+<p>Bonjour {{ mailerModel.trialRequest.representativeFirstName }},</p>
+
+<p>Votre demande d'essai pour Opentalent Artist Premium a été validée.</p>
+
+<p>Votre compte administrateur a été créé avec l'identifiant <code style="background-color: #f4f4f4; padding: 3px 5px; border-radius: 4px; font-family: monospace; border: 1px solid #ddd;">{{ mailerModel.adminUsername }}</code>.</p>
+
+<p>Pour accéder à votre espace, veuillez cliquer sur le lien ci-dessous :</p>
+
+<p class="center">
+    <a href="{{ mailerModel.adminLoginUrl }}" target="_blank" class="btn">
+        ACCÉDER AU LOGICIEL
+    </a>
+</p>
+
+<p>Si le bouton ne fonctionne pas, vous pouvez copier et coller le lien suivant dans votre navigateur :</p>
+<p>{{ mailerModel.adminLoginUrl }}</p>
+
+<p>Besoin d'aide ? Consultez notre FAQ complète pour découvrir toutes les fonctionnalités du logiciel et trouver les réponses à vos questions :</p>
+
+<p class="center">
+    <a href="{{ mailerModel.faqUrl }}" target="_blank" class="btn" style="background-color: #324250;">
+        TOUT SAVOIR SUR OPENTALENT ARTIST
+    </a>
+</p>
+
+<p>
+    À bientôt,<br>
+    L'équipe Opentalent
+</p>
+{% endblock %}

+ 105 - 0
templates/emails/shop/NewStructureArtistPremium/notification-to-sales-admin.html.twig

@@ -0,0 +1,105 @@
+{% extends '@templates/emails/base.html.twig' %}
+
+{% block title %}Nouvelle demande d'essai Artist Premium{% endblock %}
+
+{% block content %}
+    <h1>Nouvelle demande d'essai Artist Premium</h1>
+
+    <p>Une nouvelle structure a demandé à créer une structure et à démarrer une période d'essai artist premium.</p>
+
+    <h2>Informations sur la structure</h2>
+    <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+        <tr style="background-color: #f2f2f2;">
+            <th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Champ</th>
+            <th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Valeur</th>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Nom de la structure</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.structureName }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">Adresse</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.address }}</td>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Complément d'adresse</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.addressComplement }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">Code postal</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.postalCode }}</td>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Ville</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.city }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">Email de la structure</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.structureEmail }}</td>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Type de structure</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.structureType.value|trans }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">Statut juridique</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.legalStatus.value|trans }}</td>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Identifiant de la structure</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.structureIdentifier }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">SIREN</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.siren }}</td>
+        </tr>
+    </table>
+
+    <h2>Informations sur le représentant</h2>
+    <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
+        <tr style="background-color: #f2f2f2;">
+            <th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Champ</th>
+            <th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Valeur</th>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Prénom</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.representativeFirstName }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">Nom</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.representativeLastName }}</td>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Fonction</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.representativeFunction }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">Email</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.representativeEmail }}</td>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Téléphone</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.representativePhone }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">Représentant légal</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.legalRepresentative ? 'Oui' : 'Non' }}</td>
+        </tr>
+    </table>
+
+    <h2>Autres informations</h2>
+    <table style="width: 100%; border-collapse: collapse;">
+        <tr style="background-color: #f2f2f2;">
+            <th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Champ</th>
+            <th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Valeur</th>
+        </tr>
+        <tr>
+            <td style="border: 1px solid #ddd; padding: 8px;">Conditions acceptées</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.termsAccepted ? 'Oui' : 'Non' }}</td>
+        </tr>
+        <tr style="background-color: #f2f2f2;">
+            <td style="border: 1px solid #ddd; padding: 8px;">Inscription à la newsletter</td>
+            <td style="border: 1px solid #ddd; padding: 8px;">{{ trialRequest.newsletterSubscription ? 'Oui' : 'Non' }}</td>
+        </tr>
+    </table>
+{% endblock %}

+ 34 - 0
templates/emails/shop/token-validation.html.twig

@@ -0,0 +1,34 @@
+{% extends '@templates/emails/base.html.twig' %}
+
+{% block title %}Validation de votre demande d'essai Opentalent Artist Premium{% endblock %}
+
+{% block header %}
+<div class="header">
+    {{ mailerModel.structureName }}
+</div>
+{% endblock %}
+
+{% block content %}
+<p>Bonjour {{ mailerModel.representativeFirstName }},</p>
+
+<p>Nous avons bien reçu votre demande d'essai pour la structure "{{ mailerModel.structureName }}".</p>
+
+<p>Pour valider votre demande et commencer votre période d'essai, veuillez cliquer sur le lien ci-dessous :</p>
+
+<p class="center" style="margin-top: 15px; margin-bottom: 15px;">
+    <a href="{{ mailerModel.validationUrl }}" target="_blank" class="btn">
+        VALIDER MA DEMANDE
+    </a>
+</p>
+
+<p>Si le bouton ne fonctionne pas, vous pouvez copier et coller le lien suivant dans votre navigateur :</p>
+<pre style="display: block; padding: 6px 12px;">{{ mailerModel.validationUrl }}</pre>
+<br>
+
+<p>Nous vous remercions pour votre confiance et restons à votre disposition pour toute question.</p>
+
+<p>
+    À bientôt,<br>
+    L'équipe Opentalent
+</p>
+{% endblock %}

+ 593 - 1
tests/Unit/Service/Dolibarr/DolibarrApiServiceTest.php

@@ -4,6 +4,7 @@ namespace App\Tests\Unit\Service\Dolibarr;
 
 use App\Entity\Organization\Organization;
 use App\Service\Dolibarr\DolibarrApiService;
+use App\Service\Utils\DatesUtils;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -465,7 +466,7 @@ class DolibarrApiServiceTest extends TestCase
         ];
 
         $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
-        $response->method('getContent')->willReturn('456');
+        $response->method('getContent')->willReturn(json_encode(456));
 
         $dolibarrApiService
             ->expects(self::once())
@@ -478,6 +479,52 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals(456, $result);
     }
 
+    /**
+     * @see DolibarrApiService::getSocietyId()
+     */
+    public function testGetSocietyId(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getSocietyId'])
+            ->getMock();
+
+        $organizationId = 123;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getSociety')
+            ->with($organizationId)
+            ->willReturn(['id' => '456']);
+
+        $societyId = $dolibarrApiService->getSocietyId($organizationId);
+
+        $this->assertEquals(456, $societyId);
+    }
+
+    /**
+     * @see DolibarrApiService::getSocietyId()
+     */
+    public function testGetSocietyIdNotExisting(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getSocietyId'])
+            ->getMock();
+
+        $organizationId = 123;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getSociety')
+            ->with($organizationId)
+            ->willReturn(null);
+
+        $societyId = $dolibarrApiService->getSocietyId($organizationId);
+
+        $this->assertNull($societyId);
+    }
+
     /**
      * @see DolibarrApiService::createSociety
      */
@@ -514,6 +561,130 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals(456, $result);
     }
 
+    /**
+     * @see DolibarrApiService::getLastOrder()
+     */
+    public function testGetLastOrder(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getLastOrder'])
+            ->getMock();
+
+        $socId = 1;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with(
+                'orders',
+                [
+                    'sortfield' => 't.date_valid',
+                    'sortorder' => 'DESC',
+                    'limit' => 1,
+                    'sqlfilters' => 'fk_soc:=:'.$socId,
+                ]
+            )
+            ->willReturn([['id' => 10, 'ref' => 'ORDER123']]);
+
+        $result = $dolibarrApiService->getLastOrder($socId);
+
+        $this->assertEquals(['id' => 10, 'ref' => 'ORDER123'], $result);
+    }
+
+    /**
+     * @see DolibarrApiService::getLastOrder()
+     */
+    public function testGetLastOrderEmpty(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getLastOrder'])
+            ->getMock();
+
+        $socId = 1;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with(
+                'orders',
+                [
+                    'sortfield' => 't.date_valid',
+                    'sortorder' => 'DESC',
+                    'limit' => 1,
+                    'sqlfilters' => 'fk_soc:=:'.$socId,
+                ]
+            )
+            ->willReturn([]);
+
+        $result = $dolibarrApiService->getLastOrder($socId);
+
+        $this->assertNull($result);
+    }
+
+    /**
+     * @see DolibarrApiService::getLastOrder()
+     */
+    public function testGetLastOrderNotFound(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getLastOrder'])
+            ->getMock();
+
+        $socId = 1;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with(
+                'orders',
+                [
+                    'sortfield' => 't.date_valid',
+                    'sortorder' => 'DESC',
+                    'limit' => 1,
+                    'sqlfilters' => 'fk_soc:=:'.$socId,
+                ]
+            )
+            ->willThrowException(new HttpException(404));
+
+        $result = $dolibarrApiService->getLastOrder($socId);
+
+        $this->assertNull($result);
+    }
+
+    /**
+     * @see DolibarrApiService::getLastOrder()
+     */
+    public function testGetLastOrderError(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getLastOrder'])
+            ->getMock();
+
+        $socId = 1;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with(
+                'orders',
+                [
+                    'sortfield' => 't.date_valid',
+                    'sortorder' => 'DESC',
+                    'limit' => 1,
+                    'sqlfilters' => 'fk_soc:=:'.$socId,
+                ]
+            )
+            ->willThrowException(new HttpException(500));
+
+        $this->expectException(HttpException::class);
+
+        $dolibarrApiService->getLastOrder($socId);
+    }
+
     public function testSwitchSocietyToProspect(): void
     {
         $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
@@ -538,6 +709,79 @@ class DolibarrApiServiceTest extends TestCase
         $dolibarrApiService->switchSocietyToProspect(123);
     }
 
+    /**
+     * @see DolibarrApiService::downloadBillingDocPdf()
+     */
+    public function testDownloadBillingDocPdf(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['downloadBillingDocPdf'])
+            ->getMock();
+
+        $type = 'invoice';
+        $docRef = 'FA2023-0001';
+        $expectedResult = [
+            'filename' => 'FA2023-0001.pdf',
+            'content-type' => 'application/pdf',
+            'filesize' => 10660,
+            'content' => 'base64encodedcontent',
+            'encoding' => 'base64',
+        ];
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with('documents/download?modulepart=invoice&original_file='.urlencode("$docRef/$docRef.pdf"))
+            ->willReturn($expectedResult);
+
+        $result = $dolibarrApiService->downloadBillingDocPdf($type, $docRef);
+
+        $this->assertEquals($expectedResult, $result);
+    }
+
+    /**
+     * @see DolibarrApiService::downloadBillingDocPdf()
+     */
+    public function testDownloadBillingDocPdfInvalidType(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['downloadBillingDocPdf'])
+            ->getMock();
+
+        $type = 'invalid_type';
+        $docRef = 'FA2023-0001';
+
+        $this->expectException(\InvalidArgumentException::class);
+
+        $dolibarrApiService->downloadBillingDocPdf($type, $docRef);
+    }
+
+    /**
+     * @see DolibarrApiService::downloadBillingDocPdf()
+     */
+    public function testDownloadBillingDocPdfError(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['downloadBillingDocPdf'])
+            ->getMock();
+
+        $type = 'invoice';
+        $docRef = 'FA2023-0001';
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with('documents/download?modulepart=invoice&original_file='.urlencode("$docRef/$docRef.pdf"))
+            ->willThrowException(new HttpException(500));
+
+        $this->expectException(HttpException::class);
+
+        $dolibarrApiService->downloadBillingDocPdf($type, $docRef);
+    }
+
     public function testSwitchSocietyToProspectWithError(): void
     {
         $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
@@ -564,4 +808,352 @@ class DolibarrApiServiceTest extends TestCase
 
         $dolibarrApiService->switchSocietyToProspect(123);
     }
+
+    /**
+     * @see DolibarrApiService::createContract()
+     */
+    public function testCreateContract(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['createContract'])
+            ->getMock();
+
+        // Mock DatesUtils to return a fixed date
+        DatesUtils::setFakeDatetime('2023-01-01 12:00:00');
+        $date = DatesUtils::new();
+
+        $socId = 1;
+        $productId = 2;
+        $isNewClient = false;
+        $duration = 12;
+
+        // Mock product data
+        $productData = [
+            'label' => 'Test Product',
+            'description' => 'Test Description',
+            'price' => '100.00',
+            'price_base_type' => 'HT',
+            'tva_tx' => '20.00',
+        ];
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("products/$productId")
+            ->willReturn($productData);
+
+        // Expected contract data
+        $expectedBody = [
+            'socid' => $socId,
+            'date_contrat' => $date->format('Y-m-d'),
+            'commercial_signature_id' => 8,
+            'commercial_suivi_id' => 8,
+            'statut' => 1,
+            'lines' => [
+                [
+                    'fk_product' => $productId,
+                    'label' => $productData['label'],
+                    'desc' => $productData['description'],
+                    'qty' => 1,
+                    'subprice' => number_format((float) $productData['price'], 2),
+                    'price_base_type' => $productData['price_base_type'],
+                    'tva_tx' => $productData['tva_tx'],
+                ],
+            ],
+            'array_options' => [
+                'options_ec_amount' => number_format((float) $productData['price'], 2),
+                'options_ec_duration_months' => $duration,
+                'options_ec_signature_date' => $date->format('Y-m-d'),
+                'options_ec_effective_date' => $date->format('Y-m-d'),
+                'options_ec_tacit_renewal' => 1,
+                'options_ec_termination_period_months' => 2,
+                'options_ec_billing_due' => 1,
+                'options_ec_billing_frequency' => 4,
+                'options_ec_billing_begin_period' => 1,
+                'options_ec_payment_condition' => 7,
+                'options_ec_payment_mode' => 6,
+                'options_ec_account' => 1,
+                'options_logicielfact' => 1,
+                'options_versionfact' => 2,
+                'options_2iopen_origvente' => 3, // Evolution (3) for existing client
+            ],
+        ];
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('getContent')->willReturn('123');
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('post')
+            ->with('contracts', $expectedBody)
+            ->willReturn($response);
+
+        $result = $dolibarrApiService->createContract($socId, $productId, $isNewClient, $duration);
+
+        $this->assertEquals(123, $result);
+    }
+
+    /**
+     * @see DolibarrApiService::createContract()
+     */
+    public function testCreateContractForNewClient(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['createContract'])
+            ->getMock();
+
+        // Mock DatesUtils to return a fixed date
+        DatesUtils::setFakeDatetime('2023-01-01 12:00:00');
+        $date = DatesUtils::new();
+
+        $socId = 1;
+        $productId = 2;
+        $isNewClient = true; // Testing for new client
+        $duration = 12;
+
+        // Mock product data
+        $productData = [
+            'label' => 'Test Product',
+            'description' => 'Test Description',
+            'price' => '100.00',
+            'price_base_type' => 'HT',
+            'tva_tx' => '20.00',
+        ];
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("products/$productId")
+            ->willReturn($productData);
+
+        // Expected contract data with originVente = 1 for new client
+        $expectedBody = [
+            'socid' => $socId,
+            'date_contrat' => $date->format('Y-m-d'),
+            'commercial_signature_id' => 8,
+            'commercial_suivi_id' => 8,
+            'statut' => 1,
+            'lines' => [
+                [
+                    'fk_product' => $productId,
+                    'label' => $productData['label'],
+                    'desc' => $productData['description'],
+                    'qty' => 1,
+                    'subprice' => number_format((float) $productData['price'], 2),
+                    'price_base_type' => $productData['price_base_type'],
+                    'tva_tx' => $productData['tva_tx'],
+                ],
+            ],
+            'array_options' => [
+                'options_ec_amount' => number_format((float) $productData['price'], 2),
+                'options_ec_duration_months' => $duration,
+                'options_ec_signature_date' => $date->format('Y-m-d'),
+                'options_ec_effective_date' => $date->format('Y-m-d'),
+                'options_ec_tacit_renewal' => 1,
+                'options_ec_termination_period_months' => 2,
+                'options_ec_billing_due' => 1,
+                'options_ec_billing_frequency' => 4,
+                'options_ec_billing_begin_period' => 1,
+                'options_ec_payment_condition' => 7,
+                'options_ec_payment_mode' => 6,
+                'options_ec_account' => 1,
+                'options_logicielfact' => 1,
+                'options_versionfact' => 2,
+                'options_2iopen_origvente' => 1, // New client (1)
+            ],
+        ];
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('getContent')->willReturn('123');
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('post')
+            ->with('contracts', $expectedBody)
+            ->willReturn($response);
+
+        $result = $dolibarrApiService->createContract($socId, $productId, $isNewClient, $duration);
+
+        $this->assertEquals(123, $result);
+    }
+
+    /**
+     * @see DolibarrApiService::createContractLine()
+     */
+    public function testCreateContractLine(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['createContractLine'])
+            ->getMock();
+
+        // Mock DatesUtils to return a fixed date
+        DatesUtils::setFakeDatetime('2023-01-01 12:00:00');
+        $date = DatesUtils::new();
+        $endDate = DatesUtils::new()->modify('+12 months')->modify('-1 day');
+
+        $contractId = 1;
+        $productId = 2;
+        $duration = 12;
+
+        // Mock product data
+        $productData = [
+            'label' => 'Test Product',
+            'description' => 'Test Description',
+            'price' => '100.00',
+            'price_base_type' => 'HT',
+            'tva_tx' => '20.00',
+        ];
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("products/$productId")
+            ->willReturn($productData);
+
+        // Expected contract line data
+        $expectedBody = [
+            'fk_product' => $productId,
+            'label' => $productData['label'],
+            'desc' => $productData['description'],
+            'qty' => 1,
+            'subprice' => number_format((float) $productData['price'], 2),
+            'price_base_type' => $productData['price_base_type'],
+            'tva_tx' => $productData['tva_tx'],
+            'date_start' => $date->format('Y-m-d'),
+            'date_end' => $endDate->format('Y-m-d'),
+        ];
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('getContent')->willReturn('456');
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('post')
+            ->with("contracts/$contractId/lines", $expectedBody)
+            ->willReturn($response);
+
+        $result = $dolibarrApiService->createContractLine($contractId, $productId, $duration);
+
+        $this->assertEquals(456, $result);
+    }
+
+    /**
+     * @see DolibarrApiService::updateSocietyProduct()
+     */
+    public function testUpdateSocietyProduct(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['updateSocietyProduct'])
+            ->getMock();
+
+        $socId = 1;
+        $productName = 'ARTIST_PREMIUM';
+
+        $expectedBody = [
+            'array_options' => [
+                'options_2iopen_software_opentalent' => $productName,
+            ],
+        ];
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('put')
+            ->with("thirdparties/$socId", $expectedBody)
+            ->willReturn($response);
+
+        $dolibarrApiService->updateSocietyProduct($socId, $productName);
+    }
+
+    /**
+     * @see DolibarrApiService::createContract()
+     */
+    public function testCreateContractError(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['createContract'])
+            ->getMock();
+
+        $socId = 1;
+        $productId = 2;
+        $isNewClient = false;
+        $duration = 12;
+
+        // Mock product data retrieval error
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("products/$productId")
+            ->willThrowException(new HttpException(500, 'Product not found'));
+
+        $this->expectException(HttpException::class);
+        $this->expectExceptionMessage('Product not found');
+
+        $dolibarrApiService->createContract($socId, $productId, $isNewClient, $duration);
+    }
+
+    /**
+     * @see DolibarrApiService::createContractLine()
+     */
+    public function testCreateContractLineError(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['createContractLine'])
+            ->getMock();
+
+        $contractId = 1;
+        $productId = 2;
+        $duration = 12;
+
+        // Mock product data retrieval error
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("products/$productId")
+            ->willThrowException(new HttpException(500, 'Product not found'));
+
+        $this->expectException(HttpException::class);
+        $this->expectExceptionMessage('Product not found');
+
+        $dolibarrApiService->createContractLine($contractId, $productId, $duration);
+    }
+
+    /**
+     * @see DolibarrApiService::updateSocietyProduct()
+     */
+    public function testUpdateSocietyProductError(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['updateSocietyProduct'])
+            ->getMock();
+
+        $socId = 1;
+        $productName = 'ARTIST_PREMIUM';
+
+        $expectedBody = [
+            'array_options' => [
+                'options_2iopen_software_opentalent' => $productName,
+            ],
+        ];
+
+        // Mock put method to throw an exception
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('put')
+            ->with("thirdparties/$socId", $expectedBody)
+            ->willThrowException(new HttpException(500, 'Error updating society product'));
+
+        $this->expectException(HttpException::class);
+        $this->expectExceptionMessage('Error updating society product');
+
+        $dolibarrApiService->updateSocietyProduct($socId, $productName);
+    }
 }

+ 255 - 0
tests/Unit/Service/Dolibarr/DolibarrUtilsTest.php

@@ -0,0 +1,255 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\Dolibarr;
+
+use App\Enum\Organization\SettingsProductEnum;
+use App\Service\Dolibarr\DolibarrUtils;
+use App\Service\Utils\DatesUtils;
+use Doctrine\DBAL\Connection;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Class to expose protected methods for testing.
+ */
+class TestableDolibarrUtils extends DolibarrUtils
+{
+    public function executeQueryPublic(string $sql): void
+    {
+        $this->executeQuery($sql, []);
+    }
+}
+
+class DolibarrUtilsTest extends TestCase
+{
+    private Connection|MockObject $dolibarrConnection;
+    private TestableDolibarrUtils $dolibarrUtils;
+
+    protected function setUp(): void
+    {
+        $this->dolibarrConnection = $this->createMock(Connection::class);
+        $this->dolibarrUtils = new TestableDolibarrUtils($this->dolibarrConnection);
+    }
+
+    /**
+     * @see DolibarrUtils::getProductId()
+     */
+    public function testGetProductIdForArtistPremiumTrial(): void
+    {
+        $result = $this->dolibarrUtils->getProductId(
+            SettingsProductEnum::ARTIST_PREMIUM,
+            true,
+            false
+        );
+
+        $this->assertEquals(DolibarrUtils::ARTIST_PREMIUM_TRIAL_PRODUCT_ID, $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getProductId()
+     */
+    public function testGetProductIdForArtistPremiumCmf(): void
+    {
+        $result = $this->dolibarrUtils->getProductId(
+            SettingsProductEnum::ARTIST_PREMIUM,
+            false,
+            true
+        );
+
+        $this->assertEquals(DolibarrUtils::ARTIST_PREMIUM_CMF_PRODUCT_ID, $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getProductId()
+     */
+    public function testGetProductIdForArtistPremium(): void
+    {
+        $result = $this->dolibarrUtils->getProductId(
+            SettingsProductEnum::ARTIST_PREMIUM,
+            false,
+            false
+        );
+
+        $this->assertEquals(DolibarrUtils::ARTIST_PREMIUM_PRODUCT_ID, $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getProductId()
+     */
+    public function testGetProductIdForArtistCmf(): void
+    {
+        $result = $this->dolibarrUtils->getProductId(
+            SettingsProductEnum::ARTIST,
+            false,
+            true
+        );
+
+        $this->assertEquals(DolibarrUtils::ARTIST_STANDARD_CMF_PRODUCT_ID, $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getProductId()
+     */
+    public function testGetProductIdWithInvalidContractType(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('Invalid contract type');
+
+        $this->dolibarrUtils->getProductId(
+            SettingsProductEnum::SCHOOL,
+            false,
+            false
+        );
+    }
+
+    /**
+     * @see DolibarrUtils::executeQuery()
+     */
+    public function testExecuteQuery(): void
+    {
+        $sql = 'DELETE FROM llx_societe_commerciaux WHERE fk_soc = 123';
+
+        $this->dolibarrConnection
+            ->expects($this->once())
+            ->method('executeQuery')
+            ->with($sql, []);
+
+        $this->dolibarrUtils->executeQueryPublic($sql);
+    }
+
+    /**
+     * @see DolibarrUtils::updateSocietyCommercialsWithApi()
+     */
+    public function testUpdateSocietyCommercialsWithApi(): void
+    {
+        $societyId = 123;
+        $apiUserId = 8;
+
+        $this->dolibarrConnection
+            ->expects($this->exactly(2))
+            ->method('executeQuery')
+            ->withConsecutive(
+                ['DELETE FROM llx_societe_commerciaux WHERE fk_soc = ?', [$societyId]],
+                ['INSERT INTO llx_societe_commerciaux (fk_soc, fk_user) VALUES (?, ?)', [$societyId, $apiUserId]]
+            );
+
+        $this->dolibarrUtils->updateSocietyCommercialsWithApi($societyId);
+    }
+
+    /**
+     * @see DolibarrUtils::addActionComm()
+     */
+    public function testAddActionComm(): void
+    {
+        $societyId = 123;
+        $title = 'Test Title';
+        $message = 'Test Message';
+        $apiUserId = 8;
+
+        // Mock DatesUtils to return a fixed date
+        $tz = new \DateTimeZone('Europe/Paris');
+        $mockDate = new \DateTime('2023-01-01 12:00:00', $tz);
+        $formattedDate = $mockDate->format('Y-m-d H:i:s');
+
+        // Create a partial mock of DatesUtils to control the 'new' static method
+        $datesMock = $this->createMock(DatesUtils::class);
+        DatesUtils::setFakeDatetime('2023-01-01 12:00:00');
+
+        $sql = "INSERT INTO llx_actioncomm (fk_soc, ref, code, label, note, datep, datep2, datec, fk_user_author, fk_user_mod, fk_user_action, percent) 
+                   VALUES (?, -1, 'AC_OT_ONLINE_STORE', ?, ?, ?, ?, ?, ?, ?, ?, -1)";
+
+        $this->dolibarrConnection
+            ->expects($this->once())
+            ->method('executeQuery')
+            ->with(
+                $sql,
+                [
+                    $societyId,
+                    $title,
+                    $message,
+                    $formattedDate,
+                    $formattedDate,
+                    $formattedDate,
+                    $apiUserId,
+                    $apiUserId,
+                    $apiUserId,
+                ]
+            );
+
+        $this->dolibarrUtils->addActionComm($societyId, $title, $message);
+    }
+
+    /**
+     * @see DolibarrUtils::getDolibarrProductName()
+     */
+    public function testGetDolibarrProductNameForArtist(): void
+    {
+        $result = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::ARTIST);
+        $this->assertEquals('Opentalent Artist', $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getDolibarrProductName()
+     */
+    public function testGetDolibarrProductNameForArtistPremiumTrial(): void
+    {
+        $result = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::ARTIST_PREMIUM, true);
+        $this->assertEquals('Opentalent Artist Premium (Essai)', $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getDolibarrProductName()
+     */
+    public function testGetDolibarrProductNameForArtistPremium(): void
+    {
+        $result = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::ARTIST_PREMIUM);
+        $this->assertEquals('Opentalent Artist Premium', $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getDolibarrProductName()
+     */
+    public function testGetDolibarrProductNameForSchool(): void
+    {
+        $result = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::SCHOOL);
+        $this->assertEquals('Opentalent School', $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getDolibarrProductName()
+     */
+    public function testGetDolibarrProductNameForSchoolPremiumTrial(): void
+    {
+        $result = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::SCHOOL_PREMIUM, true);
+        $this->assertEquals('Opentalent School Premium (Essai)', $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getDolibarrProductName()
+     */
+    public function testGetDolibarrProductNameForSchoolPremium(): void
+    {
+        $result = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::SCHOOL_PREMIUM);
+        $this->assertEquals('Opentalent School Premium', $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getDolibarrProductName()
+     */
+    public function testGetDolibarrProductNameForManager(): void
+    {
+        $result = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::MANAGER);
+        $this->assertEquals('Opentalent Manager', $result);
+    }
+
+    /**
+     * @see DolibarrUtils::getDolibarrProductName()
+     */
+    public function testGetDolibarrProductNameForManagerPremium(): void
+    {
+        $result = $this->dolibarrUtils->getDolibarrProductName(SettingsProductEnum::MANAGER_PREMIUM);
+        $this->assertEquals('Opentalent Manager Premium', $result);
+    }
+}

+ 69 - 0
tests/Unit/Service/Organization/OrganizationFactoryTest.php

@@ -185,6 +185,7 @@ class OrganizationFactoryTest extends TestCase
     private readonly MockObject|ApiLegacyRequestService $apiLegacyRequestService;
     private readonly MockObject|PhoneNumberUtil $phoneNumberUtil;
     private readonly MockObject|FunctionTypeRepository $functionTypeRepository;
+    private readonly MockObject|\App\Repository\Access\AccessRepository $accessRepository;
 
     public function setUp(): void
     {
@@ -203,6 +204,7 @@ class OrganizationFactoryTest extends TestCase
         $this->phoneNumberUtil = $this->getMockBuilder(PhoneNumberUtil::class)->disableOriginalConstructor()->getMock();
         $this->functionTypeRepository = $this->getMockBuilder(FunctionTypeRepository::class)->disableOriginalConstructor()->getMock();
         $this->fileManager = $this->getMockBuilder(FileManager::class)->disableOriginalConstructor()->getMock();
+        $this->accessRepository = $this->getMockBuilder(\App\Repository\Access\AccessRepository::class)->disableOriginalConstructor()->getMock();
     }
 
     public function tearDown(): void
@@ -229,6 +231,7 @@ class OrganizationFactoryTest extends TestCase
                     $this->apiLegacyRequestService,
                     $this->functionTypeRepository,
                     $this->fileManager,
+                    $this->accessRepository,
                 ])
             ->setMethodsExcept(['setLoggerInterface', 'setPhoneNumberUtil', $methodName])
             ->getMock();
@@ -2190,4 +2193,70 @@ class OrganizationFactoryTest extends TestCase
 
         $organizationFactory->switchDolibarrSocietyToProspect(123);
     }
+
+    public function testSetAdminAccountPassword(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('setAdminAccountPassword');
+
+        // Create mock organization
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        // Create mock access with adminAccess=true
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        // Create mock person
+        $person = $this->getMockBuilder(Person::class)->getMock();
+
+        // Set up the access to return the person
+        $access->method('getPerson')->willReturn($person);
+
+        // Set up the AccessRepository to return the access when findAdminAccess is called
+        $this->accessRepository->method('findAdminAccess')->with($organization)->willReturn($access);
+
+        // Expect the person's setPassword method to be called with a hashed password (not the plain password)
+        $person->expects(self::once())->method('setPassword')->with(self::callback(function ($hashedPassword) {
+            // Verify that the password is not the plain text password
+            return is_string($hashedPassword) && $hashedPassword !== 'Password123!';
+        }));
+
+        // Expect the EntityManager's persist and flush methods to be called
+        $this->entityManager->expects(self::once())->method('persist')->with($person);
+        $this->entityManager->expects(self::once())->method('flush');
+
+        // Call the method with a valid password
+        $organizationFactory->setAdminAccountPassword($organization, 'Password123!');
+    }
+
+    public function testSetAdminAccountPasswordInvalidPassword(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('setAdminAccountPassword');
+
+        // Create mock organization
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        // Expect an exception to be thrown for an invalid password
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one special character.');
+
+        // Call the method with an invalid password
+        $organizationFactory->setAdminAccountPassword($organization, 'password');
+    }
+
+    public function testSetAdminAccountPasswordNoAdminAccount(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('setAdminAccountPassword');
+
+        // Create mock organization
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        // Set up the AccessRepository to return null when findAdminAccess is called
+        $this->accessRepository->method('findAdminAccess')->with($organization)->willReturn(null);
+
+        // Expect an exception to be thrown when no admin account is found
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('No admin account found for this organization.');
+
+        // Call the method with a valid password
+        $organizationFactory->setAdminAccountPassword($organization, 'Password123!');
+    }
 }

+ 1 - 1
tests/Unit/Service/Organization/OrganizationProfileCreatorTest.php

@@ -13,9 +13,9 @@ use App\Enum\Organization\PrincipalTypeEnum;
 use App\Enum\Organization\SettingsProductEnum;
 use App\Service\Network\Tree;
 use App\Service\Organization\OrganizationProfileCreator;
-use App\Service\Organization\Trial;
 use App\Service\Organization\Utils as OrganizationUtils;
 use App\Service\Security\Module;
+use App\Service\Shop\Trial;
 use Doctrine\Common\Collections\ArrayCollection;
 use PHPUnit\Framework\TestCase;
 

+ 207 - 0
tests/Unit/Service/Organization/TrialTest.php

@@ -0,0 +1,207 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\Organization;
+
+use App\ApiResources\Shop\NewStructureArtistPremiumTrialRequest;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Settings;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Service\Dolibarr\DolibarrApiService;
+use App\Service\Dolibarr\DolibarrUtils;
+use App\Service\Shop\Trial;
+use App\Service\Utils\DatesUtils;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Unit tests for Trial class.
+ *
+ * @see Trial
+ */
+class TrialTest extends TestCase
+{
+    private DatesUtils $datesUtils;
+    private Trial $trial;
+    private EntityManagerInterface $entityManager;
+    private DolibarrApiService $dolibarrApiService;
+    private DolibarrUtils $dolibarrUtils;
+
+    public function setUp(): void
+    {
+        $this->datesUtils = new DatesUtils();
+        $this->entityManager = $this->createMock(EntityManagerInterface::class);
+        $this->dolibarrApiService = $this->createMock(DolibarrApiService::class);
+        $this->dolibarrUtils = $this->createMock(DolibarrUtils::class);
+        $this->trial = new Trial(
+            $this->datesUtils,
+            $this->entityManager,
+            $this->dolibarrApiService,
+            $this->dolibarrUtils
+        );
+    }
+
+    public function tearDown(): void
+    {
+        DatesUtils::clearFakeDatetime();
+    }
+
+    /**
+     * @see Trial::getTrialCountdown()
+     */
+    public function testGetTrialCountdownWithNullStartDate(): void
+    {
+        $result = $this->trial->getTrialCountdown(null);
+
+        $this->assertEquals(0, $result);
+    }
+
+    /**
+     * @see Trial::getTrialCountdown()
+     */
+    public function testGetTrialCountdownWithRecentStartDate(): void
+    {
+        // Set up a trial start date
+        $trialStartDate = new \DateTime('2023-01-01');
+
+        // Set the current date to be 10 days after the trial start date
+        DatesUtils::setFakeDatetime('2023-01-11');
+
+        $result = $this->trial->getTrialCountdown($trialStartDate);
+
+        // Should return 30 - 10 = 20 days remaining
+        $this->assertEquals(20, $result);
+    }
+
+    /**
+     * @see Trial::getTrialCountdown()
+     */
+    public function testGetTrialCountdownWithExactly30DaysAgo(): void
+    {
+        // Set up a trial start date
+        $trialStartDate = new \DateTime('2023-01-01');
+
+        // Set the current date to be 30 days after the trial start date
+        DatesUtils::setFakeDatetime('2023-01-31');
+
+        $result = $this->trial->getTrialCountdown($trialStartDate);
+
+        // Should return 30 - 30 = 0 days remaining
+        $this->assertEquals(0, $result);
+    }
+
+    /**
+     * @see Trial::getTrialCountdown()
+     */
+    public function testGetTrialCountdownWithOldStartDate(): void
+    {
+        // Set up a trial start date
+        $trialStartDate = new \DateTime('2023-01-01');
+
+        // Set the current date to be 40 days after the trial start date
+        DatesUtils::setFakeDatetime('2023-02-10');
+
+        $result = $this->trial->getTrialCountdown($trialStartDate);
+
+        // Should return 0 days remaining since the trial has expired
+        $this->assertEquals(0, $result);
+    }
+
+    /**
+     * Test startArtistPremiumTrial method.
+     *
+     * @see Trial::startArtistPremiumTrialForNewStructure()
+     */
+    public function testStartArtistPremiumTrial(): void
+    {
+        DatesUtils::setFakeDatetime('2025-01-01 12:00:00');
+
+        $organization = $this->createMock(Organization::class);
+
+        $settings = $this->createMock(Settings::class);
+        $settings->method('getProduct')->willReturn(SettingsProductEnum::FREEMIUM);
+
+        $organization->method('getSettings')->willReturn($settings);
+        $organization->method('getId')->willReturn(123);
+
+        $request = $this->createMock(NewStructureArtistPremiumTrialRequest::class);
+        $request->method('getRepresentativeFirstName')->willReturn('John');
+        $request->method('getRepresentativeLastName')->willReturn('Doe');
+        $request->method('getRepresentativeFunction')->willReturn('Manager');
+        $request->method('getRepresentativeEmail')->willReturn('test@example.com');
+        $request->method('getRepresentativePhone')->willReturn('+33123456789');
+
+        $settings
+            ->expects(self::once())
+            ->method('setProductBeforeTrial')
+            ->with(SettingsProductEnum::FREEMIUM);
+
+        $settings
+            ->expects(self::once())
+            ->method('setTrialActive')
+            ->with(true);
+
+        $settings
+            ->expects(self::once())
+            ->method('setLastTrialStartDate')
+            ->with(self::callback(function ($dateTime) {
+                return $dateTime instanceof \DateTime && $dateTime->format('Y-m-d H:i:s') === '2025-01-01 12:00:00';
+            }));
+
+        $settings->expects(self::once())
+            ->method('setProduct')
+            ->with(SettingsProductEnum::ARTIST_PREMIUM);
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($settings);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush');
+
+        $this->dolibarrApiService->expects(self::once())
+            ->method('getSocietyId')
+            ->with(123)
+            ->willReturn(456);
+
+        $this->dolibarrUtils->expects(self::once())
+            ->method('getProductId')
+            ->with(SettingsProductEnum::ARTIST_PREMIUM, true)
+            ->willReturn(789);
+
+        $this->dolibarrApiService->expects(self::once())
+            ->method('createContract')
+            ->with(456, 789, true, 1)
+            ->willReturn(101112);
+
+        $this->dolibarrApiService->expects(self::once())
+            ->method('createContractLine')
+            ->with(101112, 789, 1);
+
+        $this->dolibarrUtils->expects(self::once())
+            ->method('updateSocietyCommercialsWithApi')
+            ->with(456);
+
+        $this->dolibarrUtils->expects(self::once())
+            ->method('getDolibarrProductName')
+            ->with(SettingsProductEnum::ARTIST_PREMIUM, true)
+            ->willReturn('Artist Premium (essai)');
+
+        $this->dolibarrApiService->expects(self::once())
+            ->method('updateSocietyProduct')
+            ->with(456, 'Artist Premium (essai)');
+
+        $this->dolibarrUtils->expects(self::once())
+            ->method('addActionComm')
+            ->with(456, "Ouverture de la période d'essai (nouvelle structure)", self::callback(function ($message) {
+                return strpos($message, 'John') !== false
+                    && strpos($message, 'Doe') !== false
+                    && strpos($message, 'Manager') !== false
+                    && strpos($message, 'test@example.com') !== false
+                    && strpos($message, '+33123456789') !== false;
+            }));
+
+        $this->trial->startArtistPremiumTrialForNewStructure($organization, $request);
+    }
+}

+ 684 - 0
tests/Unit/Service/Shop/ShopServiceTest.php

@@ -0,0 +1,684 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\Shop;
+
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\ApiResources\Shop\NewStructureArtistPremiumTrialRequest;
+use App\Entity\Organization\Organization;
+use App\Entity\Shop\ShopRequest;
+use App\Enum\Organization\LegalEnum;
+use App\Enum\Organization\PrincipalTypeEnum;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Enum\Shop\ShopRequestStatus;
+use App\Enum\Shop\ShopRequestType;
+use App\Message\Message\Shop\NewStructureArtistPremiumTrial;
+use App\Service\Mailer\Mailer;
+use App\Service\Organization\OrganizationFactory;
+use App\Service\Shop\ShopService;
+use App\Service\Shop\Trial;
+use App\Service\Utils\DatesUtils;
+use Doctrine\ORM\EntityManagerInterface;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Messenger\Envelope;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+
+class TestableShopService extends ShopService
+{
+    public PhoneNumberUtil $phoneNumberUtil;
+
+    public function controlShopRequestData(ShopRequestType $type, array $data): void
+    {
+        parent::controlShopRequestData($type, $data);
+    }
+
+    public function createRequest(ShopRequestType $type, array $data): ShopRequest
+    {
+        return parent::createRequest($type, $data);
+    }
+
+    public function sendRequestValidationLink(ShopRequest $shopRequest): void
+    {
+        parent::sendRequestValidationLink($shopRequest);
+    }
+
+    public function handleNewStructureArtistPremiumTrialRequest(string $token): void
+    {
+        parent::handleNewStructureArtistPremiumTrialRequest($token);
+    }
+
+    public function createOrganization(NewStructureArtistPremiumTrialRequest $trialRequest): Organization
+    {
+        return parent::createOrganization($trialRequest);
+    }
+
+    public function generateSubdomain(string $name): string
+    {
+        return parent::generateSubdomain($name);
+    }
+
+    public function validateNewStructureArtistPremiumTrialRequest(array $data): void
+    {
+        parent::validateNewStructureArtistPremiumTrialRequest($data);
+    }
+
+    public function createOrganizationCreationRequestFromTrialRequest(
+        NewStructureArtistPremiumTrialRequest $trialRequest,
+    ): OrganizationCreationRequest {
+        return parent::createOrganizationCreationRequestFromTrialRequest($trialRequest);
+    }
+
+    public function sendMailToSalesAdministration(NewStructureArtistPremiumTrialRequest $trialRequest): void
+    {
+        parent::sendMailToSalesAdministration($trialRequest);
+    }
+
+    public function sendConfirmationMailToRepresentative(NewStructureArtistPremiumTrialRequest $trialRequest): void
+    {
+        parent::sendConfirmationMailToRepresentative($trialRequest);
+    }
+}
+
+/**
+ * Unit tests for ShopService.
+ */
+class ShopServiceTest extends TestCase
+{
+    private readonly MockObject|EntityManagerInterface $entityManager;
+    private readonly MockObject|Mailer $mailer;
+    private string $publicBaseUrl;
+    private string $adminBaseUrl;
+    private readonly MockObject|OrganizationFactory $organizationFactory;
+    private readonly MockObject|SerializerInterface $serializer;
+    private readonly MockObject|LoggerInterface $logger;
+    private readonly MockObject|MessageBusInterface $messageBus;
+    private readonly MockObject|Trial $trial;
+    private string $faqUrl;
+    private string $softwareWebsiteUrl;
+
+    public function setUp(): void
+    {
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->mailer = $this->getMockBuilder(Mailer::class)->disableOriginalConstructor()->getMock();
+        $this->publicBaseUrl = 'https://example.com';
+        $this->adminBaseUrl = 'https://admin.example.com';
+        $this->organizationFactory = $this->getMockBuilder(OrganizationFactory::class)->disableOriginalConstructor()->getMock();
+        $this->serializer = $this->getMockBuilder(SerializerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->messageBus = $this->getMockBuilder(MessageBusInterface::class)->disableOriginalConstructor()->getMock();
+        $this->trial = $this->getMockBuilder(Trial::class)->disableOriginalConstructor()->getMock();
+        $this->faqUrl = 'https://faq.example.com';
+        $this->softwareWebsiteUrl = 'https://software.example.com';
+    }
+
+    private function getShopServiceMockFor(string $methodName): TestableShopService|MockObject
+    {
+        $shopService = $this
+            ->getMockBuilder(TestableShopService::class)
+            ->setConstructorArgs(
+                [
+                    $this->entityManager,
+                    $this->mailer,
+                    $this->publicBaseUrl,
+                    $this->adminBaseUrl,
+                    $this->organizationFactory,
+                    $this->serializer,
+                    $this->logger,
+                    $this->messageBus,
+                    $this->trial,
+                    $this->faqUrl,
+                    $this->softwareWebsiteUrl,
+                ]
+            )
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+
+        return $shopService;
+    }
+
+    public function tearDown(): void
+    {
+        DatesUtils::clearFakeDatetime();
+    }
+
+    /**
+     * Test registerNewShopRequest method.
+     */
+    public function testRegisterNewShopRequest(): void
+    {
+        $shopService = $this->getShopServiceMockFor('registerNewShopRequest');
+
+        $type = ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL;
+        $data = [
+            'representativeEmail' => 'test@example.com',
+            'representativeFirstName' => 'John',
+            'representativeLastName' => 'Doe',
+            'structureName' => 'Test Structure',
+        ];
+
+        $shopRequest = $this->getMockBuilder(ShopRequest::class)->getMock();
+
+        $shopService->expects(self::once())
+            ->method('controlShopRequestData')
+            ->with($type, $data);
+
+        $shopService->expects(self::once())
+            ->method('createRequest')
+            ->with($type, $data)
+            ->willReturn($shopRequest);
+
+        $shopService->expects(self::once())
+            ->method('sendRequestValidationLink')
+            ->with($shopRequest);
+
+        $result = $shopService->registerNewShopRequest($type, $data);
+
+        $this->assertSame($shopRequest, $result);
+    }
+
+    /**
+     * Test controlShopRequestData method for NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL type.
+     */
+    public function testControlShopRequestDataForNewStructureArtistPremiumTrial(): void
+    {
+        $shopService = $this->getShopServiceMockFor('controlShopRequestData');
+
+        $type = ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL;
+        $data = [
+            'representativeEmail' => 'test@example.com',
+            'representativeFirstName' => 'John',
+            'representativeLastName' => 'Doe',
+            'structureName' => 'Test Structure',
+        ];
+
+        $shopService->expects(self::once())
+            ->method('validateNewStructureArtistPremiumTrialRequest')
+            ->with($data);
+
+        $shopService->controlShopRequestData($type, $data);
+    }
+
+    /**
+     * Test processShopRequest method for NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL type.
+     */
+    public function testProcessShopRequest(): void
+    {
+        $shopService = $this->getShopServiceMockFor('processShopRequest');
+
+        $shopRequest = $this->getMockBuilder(ShopRequest::class)->getMock();
+        $shopRequest->method('getType')->willReturn(ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL);
+        $shopRequest->method('getToken')->willReturn('test-token');
+
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::callback(function ($message) {
+                return $message instanceof NewStructureArtistPremiumTrial
+                    && $message->getToken() === 'test-token';
+            }))
+            ->willReturn(new Envelope(new NewStructureArtistPremiumTrial('azerty')));
+
+        $shopRequest
+            ->expects(self::once())
+            ->method('setStatus')
+            ->with(ShopRequestStatus::VALIDATED);
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($shopRequest);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush');
+
+        $shopService->processShopRequest($shopRequest);
+    }
+
+    /**
+     * Test createRequest method.
+     */
+    public function testCreateRequest(): void
+    {
+        $shopService = $this->getShopServiceMockFor('createRequest');
+
+        $uuidRx = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i';
+
+        $type = ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL;
+        $data = [
+            'representativeEmail' => 'test@example.com',
+            'representativeFirstName' => 'John',
+            'representativeLastName' => 'Doe',
+            'structureName' => 'Test Structure',
+        ];
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with(self::callback(function ($shopRequest) use ($type, $data, $uuidRx) {
+                return $shopRequest instanceof ShopRequest
+                    && $shopRequest->getType() === $type
+                    && $shopRequest->getData() === $data
+                    && preg_match($uuidRx, $shopRequest->getToken());
+            }));
+
+        $this->entityManager->expects(self::once())
+            ->method('flush');
+
+        $result = $shopService->createRequest($type, $data);
+
+        $this->assertInstanceOf(ShopRequest::class, $result);
+        $this->assertSame($type, $result->getType());
+        $this->assertSame($data, $result->getData());
+        $this->assertMatchesRegularExpression($uuidRx, $result->getToken());
+    }
+
+    /**
+     * Test sendRequestValidationLink method.
+     */
+    public function testSendRequestValidationLink(): void
+    {
+        $shopService = $this->getShopServiceMockFor('sendRequestValidationLink');
+
+        $token = 'test-token';
+        $data = [
+            'representativeEmail' => 'test@example.com',
+            'representativeFirstName' => 'John',
+            'representativeLastName' => 'Doe',
+            'structureName' => 'Test Structure',
+        ];
+
+        $shopRequest = $this->getMockBuilder(ShopRequest::class)->getMock();
+        $shopRequest->method('getToken')->willReturn($token);
+        $shopRequest->method('getData')->willReturn($data);
+
+        $this->mailer
+            ->expects(self::once())
+            ->method('main')
+            ->with(self::callback(function ($model) use ($token, $data) {
+                return $model->getToken() === $token
+                    && $model->getRepresentativeEmail() === $data['representativeEmail']
+                    && $model->getRepresentativeFirstName() === $data['representativeFirstName']
+                    && $model->getRepresentativeLastName() === $data['representativeLastName']
+                    && $model->getStructureName() === $data['structureName']
+                    && $model->getValidationUrl() === 'https://software.example.com/shop/try/validation?token='.$token;
+            }));
+
+        $shopRequest->expects(self::once())
+            ->method('setStatus')
+            ->with(ShopRequestStatus::ACTIVATION_LINK_SENT);
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($shopRequest);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush');
+
+        $shopService->sendRequestValidationLink($shopRequest);
+    }
+
+    /**
+     * Test handleNewStructureArtistPremiumTrialRequest method.
+     */
+    public function testHandleNewStructureArtistPremiumTrialRequest(): void
+    {
+        $shopService = $this->getShopServiceMockFor('handleNewStructureArtistPremiumTrialRequest');
+
+        $token = 'test-token';
+        $data = [
+            'representativeEmail' => 'test@example.com',
+            'representativeFirstName' => 'John',
+            'representativeLastName' => 'Doe',
+            'structureName' => 'Test Structure',
+        ];
+
+        $shopRequest = $this->getMockBuilder(ShopRequest::class)->getMock();
+        $shopRequest->method('getToken')->willReturn($token);
+        $shopRequest->method('getData')->willReturn($data);
+
+        $this->entityManager
+            ->expects(self::once())
+            ->method('find')
+            ->with(ShopRequest::class, $token)
+            ->willReturn($shopRequest);
+
+        $trialRequest = $this->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
+            ->getMock();
+
+        $this->serializer
+            ->expects(self::once())
+            ->method('deserialize')
+            ->with(json_encode($data), NewStructureArtistPremiumTrialRequest::class, 'json')
+            ->willReturn($trialRequest);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $shopService
+            ->expects(self::once())
+            ->method('createOrganization')
+            ->with($trialRequest)
+            ->willReturn($organization);
+
+        // Add expectation for password setting
+        $trialRequest->method('getPassword')->willReturn('Password123!');
+
+        $this->organizationFactory
+            ->expects(self::once())
+            ->method('setAdminAccountPassword')
+            ->with($organization, 'Password123!');
+
+        $this->trial
+            ->expects(self::once())
+            ->method('startArtistPremiumTrialForNewStructure')
+            ->with($organization, $trialRequest);
+
+        // Mock the sendMailToSalesAdministration and sendConfirmationMailToRepresentative methods
+        $shopService->expects(self::once())
+            ->method('sendMailToSalesAdministration')
+            ->with($trialRequest);
+
+        $shopService->expects(self::once())
+            ->method('sendConfirmationMailToRepresentative')
+            ->with($trialRequest);
+
+        $this->logger->expects(self::once())
+            ->method('info')
+            ->with('Successfully processed NewStructureArtistPremiumTrial for token: '.$token);
+
+        $shopService->handleNewStructureArtistPremiumTrialRequest($token);
+    }
+
+    /**
+     * Test handleNewStructureArtistPremiumTrialRequest method with non-existent token.
+     */
+    public function testHandleNewStructureArtistPremiumTrialRequestWithNonExistentToken(): void
+    {
+        $shopService = $this->getShopServiceMockFor('handleNewStructureArtistPremiumTrialRequest');
+
+        $this->entityManager->expects(self::once())
+            ->method('find')
+            ->with(ShopRequest::class, 'non-existent-token')
+            ->willReturn(null);
+
+        $this->logger->expects(self::once())
+            ->method('error')
+            ->with('Cannot find ShopRequest with token: non-existent-token');
+
+        $shopService->handleNewStructureArtistPremiumTrialRequest('non-existent-token');
+    }
+
+    /**
+     * Test handleNewStructureArtistPremiumTrialRequest method when an exception occurs.
+     */
+    public function testHandleNewStructureArtistPremiumTrialRequestWithException(): void
+    {
+        $shopService = $this->getShopServiceMockFor('handleNewStructureArtistPremiumTrialRequest');
+
+        $token = 'test-token';
+        $data = [
+            'representativeEmail' => 'test@example.com',
+            'representativeFirstName' => 'John',
+            'representativeLastName' => 'Doe',
+            'structureName' => 'Test Structure',
+        ];
+
+        $shopRequest = $this->getMockBuilder(ShopRequest::class)->getMock();
+        $shopRequest->method('getToken')->willReturn($token);
+        $shopRequest->method('getData')->willReturn($data);
+
+        // Expect setStatus to be called with ERROR
+        $shopRequest->expects(self::once())
+            ->method('setStatus')
+            ->with(ShopRequestStatus::ERROR);
+
+        $this->entityManager
+            ->expects(self::once())
+            ->method('find')
+            ->with(ShopRequest::class, $token)
+            ->willReturn($shopRequest);
+
+        // Simulate an exception during deserialization
+        $this->serializer
+            ->expects(self::once())
+            ->method('deserialize')
+            ->willThrowException(new \Exception('Test exception'));
+
+        // Expect persist and flush to be called to save the ERROR status
+        $this->entityManager
+            ->expects(self::once())
+            ->method('persist')
+            ->with($shopRequest);
+
+        $this->entityManager
+            ->expects(self::once())
+            ->method('flush');
+
+        // Expect error to be logged
+        $this->logger->expects(self::once())
+            ->method('error')
+            ->with(self::stringContains('Error processing NewStructureArtistPremiumTrial for token: '.$token));
+
+        // Expect the exception to be re-thrown
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Test exception');
+
+        $shopService->handleNewStructureArtistPremiumTrialRequest($token);
+    }
+
+    /**
+     * Test createOrganization method.
+     */
+    public function testCreateOrganization(): void
+    {
+        $shopService = $this->getShopServiceMockFor('createOrganization');
+
+        $trialRequest = $this
+            ->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
+            ->getMock();
+
+        $organizationCreationRequest = $this
+            ->getMockBuilder(OrganizationCreationRequest::class)
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $shopService->expects(self::once())
+            ->method('createOrganizationCreationRequestFromTrialRequest')
+            ->with($trialRequest)
+            ->willReturn($organizationCreationRequest);
+
+        $this->organizationFactory
+            ->expects(self::once())
+            ->method('create')
+            ->with($organizationCreationRequest)
+            ->willReturn($organization);
+
+        $result = $shopService->createOrganization($trialRequest);
+
+        $this->assertSame($organization, $result);
+    }
+
+    /**
+     * Test validateNewStructureArtistPremiumTrialRequest method with valid data.
+     */
+    public function testValidateNewStructureArtistPremiumTrialRequest(): void
+    {
+        $shopService = $this->getShopServiceMockFor('validateNewStructureArtistPremiumTrialRequest');
+
+        $phoneNumberUtil = $this
+            ->getMockBuilder(PhoneNumberUtil::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $shopService->phoneNumberUtil = $phoneNumberUtil;
+
+        $trialRequest = $this
+            ->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
+            ->getMock();
+        $trialRequest->method('getRepresentativePhone')->willReturn('+33123456789');
+
+        $organizationCreationRequest = $this
+            ->getMockBuilder(OrganizationCreationRequest::class)
+            ->getMock();
+
+        $shopService->expects(self::once())
+            ->method('createOrganizationCreationRequestFromTrialRequest')
+            ->with($trialRequest)
+            ->willReturn($organizationCreationRequest);
+
+        $phoneNumberUtil->expects(self::once())
+            ->method('isPossibleNumber')
+            ->with('+33123456789')
+            ->willReturn(true);
+
+        $this->organizationFactory->expects(self::once())
+            ->method('interruptIfOrganizationExists')
+            ->with($organizationCreationRequest);
+
+        // Convert the trial request to an array for validation
+        $data = [
+            'representativePhone' => '+33123456789',
+        ];
+        $this->serializer->expects(self::once())
+            ->method('deserialize')
+            ->with(json_encode($data), NewStructureArtistPremiumTrialRequest::class, 'json')
+            ->willReturn($trialRequest);
+
+        $shopService->validateNewStructureArtistPremiumTrialRequest($data);
+    }
+
+    /**
+     * Test validateNewStructureArtistPremiumTrialRequest method with invalid phone number.
+     */
+    public function testValidateNewStructureArtistPremiumTrialRequestWithInvalidPhoneNumber(): void
+    {
+        $shopService = $this->getShopServiceMockFor('validateNewStructureArtistPremiumTrialRequest');
+
+        $phoneNumberUtil = $this->getMockBuilder(PhoneNumberUtil::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $shopService->phoneNumberUtil = $phoneNumberUtil;
+
+        $trialRequest = $this
+            ->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
+            ->getMock();
+        $trialRequest->method('getRepresentativePhone')->willReturn('invalid-phone');
+
+        // Mock the phoneNumberUtil to return false for isPossibleNumber
+        $phoneNumberUtil->expects(self::once())
+            ->method('isPossibleNumber')
+            ->with('invalid-phone')
+            ->willReturn(false);
+
+        // Convert the trial request to an array for validation
+        $data = [
+            'representativePhone' => 'invalid-phone',
+        ];
+        $this->serializer->expects(self::once())
+            ->method('deserialize')
+            ->with(json_encode($data), NewStructureArtistPremiumTrialRequest::class, 'json')
+            ->willReturn($trialRequest);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Invalid phone number');
+
+        $shopService->validateNewStructureArtistPremiumTrialRequest($data);
+    }
+
+    /**
+     * Test createOrganizationCreationRequestFromTrialRequest method with forValidationOnly=false.
+     */
+    public function testCreateOrganizationCreationRequestFromTrialRequestComplete(): void
+    {
+        $shopService = $this->getShopServiceMockFor('createOrganizationCreationRequestFromTrialRequest');
+
+        $trialRequest = $this->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
+            ->getMock();
+        $trialRequest->method('getStructureName')->willReturn('Test Structure');
+        $trialRequest->method('getCity')->willReturn('Test City');
+        $trialRequest->method('getPostalCode')->willReturn('12345');
+        $trialRequest->method('getAddress')->willReturn('Test Address');
+        $trialRequest->method('getAddressComplement')->willReturn('Test Address Complement');
+        $trialRequest->method('getRepresentativePhone')->willReturn('+33123456789');
+        $trialRequest->method('getStructureEmail')->willReturn('structure@example.com');
+        $trialRequest->method('getStructureType')->willReturn(PrincipalTypeEnum::ARTISTIC_EDUCATION_ONLY);
+        $trialRequest->method('getLegalStatus')->willReturn(LegalEnum::ASSOCIATION_LAW_1901);
+        $trialRequest->method('getSiren')->willReturn('123456789');
+        $trialRequest->method('getStructureIdentifier')->willReturn('test-structure');
+
+        // Set a fake date for testing
+        $fakeDate = '2023-05-15 10:30:00';
+        DatesUtils::setFakeDatetime($fakeDate);
+
+        // Create the organization creation request
+        $result = $shopService->createOrganizationCreationRequestFromTrialRequest($trialRequest);
+
+        // Verify that all fields are set
+        $this->assertEquals('Test Structure', $result->getName());
+        $this->assertEquals('Test City', $result->getCity());
+        $this->assertEquals('12345', $result->getPostalCode());
+        $this->assertEquals('Test Address', $result->getStreetAddress1());
+        $this->assertEquals('Test Address Complement', $result->getStreetAddress2());
+        $this->assertEquals('structure@example.com', $result->getEmail());
+        $this->assertEquals(PrincipalTypeEnum::ARTISTIC_EDUCATION_ONLY, $result->getPrincipalType());
+        $this->assertEquals(LegalEnum::ASSOCIATION_LAW_1901, $result->getLegalStatus());
+        $this->assertEquals('123456789', $result->getSiretNumber());
+        $this->assertEquals('+33123456789', $result->getPhoneNumber());
+        $this->assertEquals('test-structure', $result->getSubdomain());
+        $this->assertEquals(SettingsProductEnum::FREEMIUM, $result->getProduct());
+        $this->assertFalse($result->getCreateWebsite());
+        $this->assertFalse($result->isClient());
+        $this->assertEquals(new \DateTime($fakeDate), $result->getCreationDate());
+    }
+
+    /**
+     * Test sendMailToSalesAdministration method.
+     */
+    public function testSendMailToSalesAdministration(): void
+    {
+        $shopService = $this->getShopServiceMockFor('sendMailToSalesAdministration');
+
+        $trialRequest = $this->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
+            ->getMock();
+
+        $this->mailer
+            ->expects(self::once())
+            ->method('main')
+            ->with(self::callback(function ($model) use ($trialRequest) {
+                return $model instanceof \App\Service\Mailer\Model\Shop\NewStructureArtistPremium\NotificationToSalesAdminModel
+                    && $model->getTrialRequest() === $trialRequest
+                    && $model->getSenderId() === \App\Enum\Access\AccessIdsEnum::ADMIN_2IOPENSERVICE->value;
+            }));
+
+        $shopService->sendMailToSalesAdministration($trialRequest);
+    }
+
+    /**
+     * Test sendConfirmationMailToRepresentative method.
+     */
+    public function testSendConfirmationMailToRepresentative(): void
+    {
+        $shopService = $this->getShopServiceMockFor('sendConfirmationMailToRepresentative');
+
+        $trialRequest = $this->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
+            ->getMock();
+        $trialRequest->method('getStructureIdentifier')->willReturn('test-structure');
+
+        $this->mailer
+            ->expects(self::once())
+            ->method('main')
+            ->with(self::callback(function ($model) use ($trialRequest) {
+                return $model instanceof \App\Service\Mailer\Model\Shop\NewStructureArtistPremium\ConfirmationToRepresentativeModel
+                    && $model->getTrialRequest() === $trialRequest
+                    && $model->getSenderId() === \App\Enum\Access\AccessIdsEnum::ADMIN_2IOPENSERVICE->value
+                    && $model->getAccountCreationUrl() === 'https://example.com/account/create'
+                    && $model->getFaqUrl() === 'https://faq.example.com'
+                    && $model->getAdminUsername() === 'admintest-structure'
+                    && $model->getAdminLoginUrl() === 'https://admin.example.com/#/login/';
+            }));
+
+        $shopService->sendConfirmationMailToRepresentative($trialRequest);
+    }
+}

+ 3 - 0
translations/enum/organization/legal/messages+intl-icu.fr.yaml

@@ -0,0 +1,3 @@
+LOCAL_AUTHORITY: Collectivité territoriale (Mairie, SIVOM, SIVU, EPIC, …)
+ASSOCIATION_LAW_1901: Association loi 1901 ou assimilée (Droit local, ...)
+COMMERCIAL_SOCIETY: Entreprise commerciale (SARL, SAS, EURL, Autoentrepreneur, …)

+ 11 - 0
translations/enum/organization/principal-type/messages+intl-icu.fr.yaml

@@ -0,0 +1,11 @@
+ARTISTIC_EDUCATION_ONLY: Enseignement artistique seul
+ARTISTIC_ENSEMBLE: Ensemble artistique
+ARTISTIC_PRACTICE_EDUCATION: Pratique et enseignement artistique
+ARTISTIC_PRACTICE_ONLY: Pratique artistique seule
+NATIONAL_FEDERATION: Fédération nationale
+REGIONAL_FEDERATION: Fédération régionale
+DEPARTEMENTAL_FEDERATION: Fédération départementale
+LOCAL_FEDERATION: Fédération locale
+GROUPMENT: Groupement
+DELEGATION: Délégation
+MUSIC_OPENTALENT: Openassos musique