diff --git a/backend/app/DomainObjects/Enums/StripePlatform.php b/backend/app/DomainObjects/Enums/StripePlatform.php new file mode 100644 index 0000000000..306501c6fa --- /dev/null +++ b/backend/app/DomainObjects/Enums/StripePlatform.php @@ -0,0 +1,28 @@ +value; + } + + public static function getAllValues(): array + { + return array_column(self::cases(), 'value'); + } +} \ No newline at end of file diff --git a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php index 0c3a27fbf0..f05ee2d21d 100644 --- a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php @@ -25,6 +25,7 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const ACCOUNT_VERIFIED_AT = 'account_verified_at'; final public const STRIPE_CONNECT_ACCOUNT_TYPE = 'stripe_connect_account_type'; final public const IS_MANUALLY_VERIFIED = 'is_manually_verified'; + final public const STRIPE_PLATFORM = 'stripe_platform'; protected int $id; protected ?int $account_configuration_id = null; @@ -41,6 +42,7 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected ?string $account_verified_at = null; protected ?string $stripe_connect_account_type = null; protected bool $is_manually_verified = false; + protected ?string $stripe_platform = null; public function toArray(): array { @@ -60,6 +62,7 @@ public function toArray(): array 'account_verified_at' => $this->account_verified_at ?? null, 'stripe_connect_account_type' => $this->stripe_connect_account_type ?? null, 'is_manually_verified' => $this->is_manually_verified ?? null, + 'stripe_platform' => $this->stripe_platform ?? null, ]; } @@ -227,4 +230,15 @@ public function getIsManuallyVerified(): bool { return $this->is_manually_verified; } + + public function setStripePlatform(?string $stripe_platform): self + { + $this->stripe_platform = $stripe_platform; + return $this; + } + + public function getStripePlatform(): ?string + { + return $this->stripe_platform; + } } diff --git a/backend/app/DomainObjects/Generated/EmailTemplateDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EmailTemplateDomainObjectAbstract.php index 615bc8c1eb..bd7818e7d4 100644 --- a/backend/app/DomainObjects/Generated/EmailTemplateDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EmailTemplateDomainObjectAbstract.php @@ -22,6 +22,7 @@ abstract class EmailTemplateDomainObjectAbstract extends \HiEvents\DomainObjects final public const IS_ACTIVE = 'is_active'; final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; + final public const DELETED_AT = 'deleted_at'; protected int $id; protected int $account_id; @@ -35,6 +36,7 @@ abstract class EmailTemplateDomainObjectAbstract extends \HiEvents\DomainObjects protected bool $is_active = true; protected ?string $created_at = null; protected ?string $updated_at = null; + protected ?string $deleted_at = null; public function toArray(): array { @@ -51,6 +53,7 @@ public function toArray(): array 'is_active' => $this->is_active ?? null, 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, ]; } @@ -185,4 +188,15 @@ public function getUpdatedAt(): ?string { return $this->updated_at; } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } } diff --git a/backend/app/Exceptions/Stripe/StripeClientConfigurationException.php b/backend/app/Exceptions/Stripe/StripeClientConfigurationException.php new file mode 100644 index 0000000000..851412d112 --- /dev/null +++ b/backend/app/Exceptions/Stripe/StripeClientConfigurationException.php @@ -0,0 +1,9 @@ +createPaymentIntentHandler = $createPaymentIntentHandler; } public function __invoke(int $eventId, string $orderShortId): JsonResponse @@ -28,6 +27,8 @@ public function __invoke(int $eventId, string $orderShortId): JsonResponse return $this->jsonResponse([ 'client_secret' => $createIntent->clientSecret, 'account_id' => $createIntent->accountId, + 'public_key' => $createIntent->publicKey, + 'stripe_platform' => $createIntent->stripePlatform?->value, ]); } } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 7073cd658a..8f3b933a63 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -19,13 +19,15 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Stripe\StripeClient; +use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; class AppServiceProvider extends ServiceProvider { public function register(): void { $this->bindDoctrineConnection(); - $this->bindStripeClient(); + $this->bindStripeServices(); $this->bindCurrencyConversionClient(); } @@ -67,8 +69,11 @@ function () { ); } - private function bindStripeClient(): void + private function bindStripeServices(): void { + $this->app->singleton(StripeConfigurationService::class); + $this->app->singleton(StripeClientFactory::class); + if (!config('services.stripe.secret_key')) { logger()?->debug('Stripe secret key is not set in the configuration file. Payment processing will not work.'); return; diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php index e921350d39..0b4a672dfc 100644 --- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php +++ b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php @@ -8,10 +8,14 @@ use HiEvents\Exceptions\CreateStripeConnectAccountFailedException; use HiEvents\Exceptions\CreateStripeConnectAccountLinksFailedException; use HiEvents\Exceptions\SaasModeEnabledException; +use HiEvents\Exceptions\Stripe\StripeClientConfigurationException; use HiEvents\Helper\Url; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountDTO; use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountResponse; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; +use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService; +use HiEvents\DomainObjects\Enums\StripePlatform; use Illuminate\Config\Repository; use Illuminate\Database\DatabaseManager; use Psr\Log\LoggerInterface; @@ -24,9 +28,10 @@ public function __construct( private AccountRepositoryInterface $accountRepository, private DatabaseManager $databaseManager, - private StripeClient $stripe, private LoggerInterface $logger, private Repository $config, + private StripeClientFactory $stripeClientFactory, + private StripeConfigurationService $stripeConfigurationService, ) { } @@ -47,13 +52,19 @@ public function handle(CreateStripeConnectAccountDTO $command): CreateStripeConn /** * @throws CreateStripeConnectAccountFailedException|CreateStripeConnectAccountLinksFailedException + * @throws StripeClientConfigurationException */ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $command): CreateStripeConnectAccountResponse { $account = $this->accountRepository->findById($command->accountId); + $primaryPlatform = $this->stripeConfigurationService->getPrimaryPlatform(); + $stripeClient = $this->stripeClientFactory->createForPlatform($primaryPlatform); + $stripeConnectAccount = $this->getOrCreateStripeConnectAccount( account: $account, + stripeClient: $stripeClient, + platform: $primaryPlatform, ); $response = new CreateStripeConnectAccountResponse( @@ -72,7 +83,7 @@ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $ return $response; } - $response->connectUrl = $this->getStripeAccountSetupUrl($stripeConnectAccount, $account); + $response->connectUrl = $this->getStripeAccountSetupUrl($stripeConnectAccount, $account, $stripeClient); return $response; } @@ -80,14 +91,18 @@ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $ /** * @throws CreateStripeConnectAccountFailedException */ - private function getOrCreateStripeConnectAccount(AccountDomainObject $account): Account + private function getOrCreateStripeConnectAccount( + AccountDomainObject $account, + StripeClient $stripeClient, + ?StripePlatform $platform + ): Account { try { if ($account->getStripeAccountId() !== null) { - return $this->stripe->accounts->retrieve($account->getStripeAccountId()); + return $stripeClient->accounts->retrieve($account->getStripeAccountId()); } - $stripeAccount = $this->stripe->accounts->create([ + $stripeAccount = $stripeClient->accounts->create([ 'type' => $this->config->get('app.stripe_connect_account_type') ?? StripeConnectAccountType::EXPRESS->value, ]); @@ -105,11 +120,18 @@ private function getOrCreateStripeConnectAccount(AccountDomainObject $account): ); } + $updateAttributes = [ + AccountDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + AccountDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, + ]; + + // Set the platform that was actually used to create this account + if ($platform && $account->getStripePlatform() === null) { + $updateAttributes[AccountDomainObjectAbstract::STRIPE_PLATFORM] = $platform->value; + } + $this->accountRepository->updateWhere( - attributes: [ - AccountDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, - AccountDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, - ], + attributes: $updateAttributes, where: [ 'id' => $account->getId(), ] @@ -131,10 +153,10 @@ private function isStripeAccountComplete(Account $stripAccount): bool /** * @throws CreateStripeConnectAccountLinksFailedException */ - private function getStripeAccountSetupUrl(Account $stripAccount, AccountDomainObject $account): string + private function getStripeAccountSetupUrl(Account $stripAccount, AccountDomainObject $account, StripeClient $stripeClient): string { try { - $accountLink = $this->stripe->accountLinks->create([ + $accountLink = $stripeClient->accountLinks->create([ 'account' => $stripAccount->id, 'refresh_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_REFRESH_URL, [ 'is_refresh' => true, diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php index 0db40c5914..45212259f3 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php @@ -18,6 +18,9 @@ use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; +use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService; +use HiEvents\DomainObjects\Enums\StripePlatform; use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentRequestDTO; use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentResponseDTO; use HiEvents\Services\Domain\Payment\Stripe\StripePaymentIntentCreationService; @@ -34,6 +37,8 @@ public function __construct( private CheckoutSessionManagementService $sessionIdentifierService, private StripePaymentsRepositoryInterface $stripePaymentsRepository, private AccountRepositoryInterface $accountRepository, + private StripeClientFactory $stripeClientFactory, + private StripeConfigurationService $stripeConfigurationService, ) { } @@ -71,24 +76,35 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO )) ->findByEventId($order->getEventId()); + // Get platform information from account + $stripePlatform = StripePlatform::fromString($account->getStripePlatform()); + $stripeClient = $this->stripeClientFactory->createForPlatform($stripePlatform); + $publicKey = $this->stripeConfigurationService->getPublicKey($stripePlatform); + // If we already have a Stripe session then re-fetch the client secret if ($order->getStripePayment() !== null) { return new CreatePaymentIntentResponseDTO( paymentIntentId: $order->getStripePayment()->getPaymentIntentId(), - clientSecret: $this->stripePaymentService->retrievePaymentIntentClientSecret( + clientSecret: $this->stripePaymentService->retrievePaymentIntentClientSecretWithClient( + $stripeClient, $order->getStripePayment()->getPaymentIntentId(), $account->getStripeAccountId() ), accountId: $account->getStripeAccountId(), + stripePlatform: $stripePlatform, + publicKey: $publicKey, ); } - $paymentIntent = $this->stripePaymentService->createPaymentIntent(CreatePaymentIntentRequestDTO::fromArray([ - 'amount' => MoneyValue::fromFloat($order->getTotalGross(), $order->getCurrency()), - 'currencyCode' => $order->getCurrency(), - 'account' => $account, - 'order' => $order, - ])); + $paymentIntent = $this->stripePaymentService->createPaymentIntentWithClient( + $stripeClient, + CreatePaymentIntentRequestDTO::fromArray([ + 'amount' => MoneyValue::fromFloat($order->getTotalGross(), $order->getCurrency()), + 'currencyCode' => $order->getCurrency(), + 'account' => $account, + 'order' => $order, + ]) + ); $this->stripePaymentsRepository->create([ StripePaymentDomainObjectAbstract::ORDER_ID => $order->getId(), @@ -97,6 +113,13 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO StripePaymentDomainObjectAbstract::APPLICATION_FEE => $paymentIntent->applicationFeeAmount, ]); - return $paymentIntent; + return new CreatePaymentIntentResponseDTO( + paymentIntentId: $paymentIntent->paymentIntentId, + clientSecret: $paymentIntent->clientSecret, + accountId: $paymentIntent->accountId, + applicationFeeAmount: $paymentIntent->applicationFeeAmount, + stripePlatform: $stripePlatform, + publicKey: $publicKey, + ); } } diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php index c56330ca50..613aa9b797 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php @@ -16,6 +16,7 @@ use Stripe\Webhook; use Throwable; use UnexpectedValueException; +use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService; class IncomingWebhookHandler { @@ -33,6 +34,7 @@ public function __construct( private readonly AccountUpdateHandler $accountUpdateHandler, private readonly Logger $logger, private readonly Repository $cache, + private readonly StripeConfigurationService $stripeConfigurationService, ) { } @@ -45,11 +47,7 @@ public function __construct( public function handle(StripeWebhookDTO $webhookDTO): void { try { - $event = Webhook::constructEvent( - $webhookDTO->payload, - $webhookDTO->headerSignature, - config('services.stripe.webhook_secret'), - ); + $event = $this->constructEventWithValidPlatform($webhookDTO); if (!in_array($event->type, self::$validEvents, true)) { $this->logger->debug(__('Received a :event Stripe event, which has no handler', [ @@ -119,6 +117,38 @@ public function handle(StripeWebhookDTO $webhookDTO): void } } + private function constructEventWithValidPlatform(StripeWebhookDTO $webhookDTO): Event + { + $webhookSecrets = $this->stripeConfigurationService->getAllWebhookSecrets(); + $lastException = null; + + foreach ($webhookSecrets as $platform => $webhookSecret) { + try { + if (!$webhookSecret) { + continue; + } + + $event = Webhook::constructEvent( + $webhookDTO->payload, + $webhookDTO->headerSignature, + $webhookSecret + ); + + $this->logger->debug('Webhook validated with platform: ' . $platform, [ + 'event_id' => $event->id, + 'platform' => $platform, + ]); + + return $event; + } catch (SignatureVerificationException $exception) { + $lastException = $exception; + continue; + } + } + + throw $lastException ?? new SignatureVerificationException(__('Unable to verify Stripe signature with any platform')); + } + private function hasEventBeenHandled(Event $event): bool { return $this->cache->has('stripe_event_' . $event->id); diff --git a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php index 4a68b74257..c556045a8f 100644 --- a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php +++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php @@ -2,14 +2,18 @@ namespace HiEvents\Services\Domain\Payment\Stripe\DTOs; +use HiEvents\DomainObjects\Enums\StripePlatform; + readonly class CreatePaymentIntentResponseDTO { public function __construct( - public ?string $paymentIntentId = null, - public ?string $clientSecret = null, - public ?string $accountId = null, - public ?string $error = null, - public int $applicationFeeAmount = 0, + public ?string $paymentIntentId = null, + public ?string $clientSecret = null, + public ?string $accountId = null, + public ?string $error = null, + public int $applicationFeeAmount = 0, + public ?StripePlatform $stripePlatform = null, + public ?string $publicKey = null, ) { } diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php index fc0e253660..bc73351c38 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php @@ -18,7 +18,6 @@ class StripePaymentIntentCreationService { public function __construct( - private readonly StripeClient $stripeClient, private readonly LoggerInterface $logger, private readonly Repository $config, private readonly StripeCustomerRepositoryInterface $stripeCustomerRepository, @@ -31,13 +30,14 @@ public function __construct( /** * @throws CreatePaymentIntentFailedException */ - public function retrievePaymentIntentClientSecret( - string $paymentIntentId, - ?string $accountId = null, + public function retrievePaymentIntentClientSecretWithClient( + StripeClient $stripeClient, + string $paymentIntentId, + ?string $accountId = null, ): string { try { - return $this->stripeClient->paymentIntents->retrieve( + return $stripeClient->paymentIntents->retrieve( id: $paymentIntentId, opts: $accountId ? ['stripe_account' => $accountId] : [] )->client_secret; @@ -57,7 +57,10 @@ public function retrievePaymentIntentClientSecret( * @throws CreatePaymentIntentFailedException * @throws ApiErrorException|Throwable */ - public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntentDTO): CreatePaymentIntentResponseDTO + public function createPaymentIntentWithClient( + StripeClient $stripeClient, + CreatePaymentIntentRequestDTO $paymentIntentDTO + ): CreatePaymentIntentResponseDTO { try { $this->databaseManager->beginTransaction(); @@ -67,10 +70,10 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent order: $paymentIntentDTO->order, )->toMinorUnit(); - $paymentIntent = $this->stripeClient->paymentIntents->create([ + $paymentIntent = $stripeClient->paymentIntents->create([ 'amount' => $paymentIntentDTO->amount->toMinorUnit(), 'currency' => $paymentIntentDTO->currencyCode, - 'customer' => $this->upsertStripeCustomer($paymentIntentDTO)->getStripeCustomerId(), + 'customer' => $this->upsertStripeCustomerWithClient($stripeClient, $paymentIntentDTO)->getStripeCustomerId(), 'metadata' => [ 'order_id' => $paymentIntentDTO->order->getId(), 'event_id' => $paymentIntentDTO->order->getEventId(), @@ -143,7 +146,10 @@ private function getStripeAccountData(CreatePaymentIntentRequestDTO $paymentInte /** * @throws ApiErrorException|CreatePaymentIntentFailedException */ - private function upsertStripeCustomer(CreatePaymentIntentRequestDTO $paymentIntentDTO): StripeCustomerDomainObject + private function upsertStripeCustomerWithClient( + StripeClient $stripeClient, + CreatePaymentIntentRequestDTO $paymentIntentDTO + ): StripeCustomerDomainObject { $customer = $this->stripeCustomerRepository->findFirstWhere([ 'email' => $paymentIntentDTO->order->getEmail(), @@ -151,7 +157,7 @@ private function upsertStripeCustomer(CreatePaymentIntentRequestDTO $paymentInte ]); if ($customer === null) { - $stripeCustomer = $this->stripeClient->customers->create( + $stripeCustomer = $stripeClient->customers->create( params: [ 'email' => $paymentIntentDTO->order->getEmail(), 'name' => $paymentIntentDTO->order->getFullName(), @@ -171,7 +177,7 @@ private function upsertStripeCustomer(CreatePaymentIntentRequestDTO $paymentInte return $customer; } - $stripeCustomer = $this->stripeClient->customers->update( + $stripeCustomer = $stripeClient->customers->update( id: $customer->getStripeCustomerId(), params: ['name' => $paymentIntentDTO->order->getFullName()], opts: $this->getStripeAccountData($paymentIntentDTO), diff --git a/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php b/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php new file mode 100644 index 0000000000..5883b91af1 --- /dev/null +++ b/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php @@ -0,0 +1,32 @@ +configurationService->getSecretKey($platform); + + if (empty($secretKey)) { + $platformName = $platform?->value ?: 'default'; + throw new StripeClientConfigurationException( + __('Stripe secret key not configured for platform: :platform', ['platform' => $platformName]) + ); + } + + return new StripeClient($secretKey); + } +} diff --git a/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php b/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php new file mode 100644 index 0000000000..5ac01b36a5 --- /dev/null +++ b/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php @@ -0,0 +1,52 @@ + config('services.stripe.ca_secret_key', config('services.stripe.secret_key')), + StripePlatform::IRELAND => config('services.stripe.ie_secret_key', config('services.stripe.secret_key')), + default => config('services.stripe.secret_key'), + }; + } + + public function getPublicKey(?StripePlatform $platform = null): ?string + { + return match ($platform) { + StripePlatform::CANADA => config('services.stripe.ca_public_key', config('services.stripe.public_key')), + StripePlatform::IRELAND => config('services.stripe.ie_public_key', config('services.stripe.public_key')), + default => config('services.stripe.public_key'), + }; + } + + public function getPrimaryPlatform(): ?StripePlatform + { + $platformString = config('services.stripe.primary_platform'); + return StripePlatform::fromString($platformString); + } + + public function getAllWebhookSecrets(): array + { + $secrets = array_filter([ + 'default' => config('services.stripe.webhook_secret'), + StripePlatform::CANADA->value => config('services.stripe.ca_webhook_secret'), + StripePlatform::IRELAND->value => config('services.stripe.ie_webhook_secret'), + ]); + + // order by primary platform first + $primary = $this->getPrimaryPlatform()?->value; + + if ($primary && isset($secrets[$primary])) { + $primarySecret = [$primary => $secrets[$primary]]; + unset($secrets[$primary]); + return $primarySecret + $secrets; + } + + return $secrets; + } +} diff --git a/backend/config/services.php b/backend/config/services.php index f8faf1c875..bed59c719c 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -35,6 +35,19 @@ 'secret_key' => env('STRIPE_SECRET_KEY'), 'public_key' => env('STRIPE_PUBLIC_KEY'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + + // Canadian platform (Optional) + 'ca_secret_key' => env('STRIPE_CA_SECRET_KEY'), + 'ca_public_key' => env('STRIPE_CA_PUBLIC_KEY'), + 'ca_webhook_secret' => env('STRIPE_CA_WEBHOOK_SECRET'), + + // Irish platform (Optional) + 'ie_secret_key' => env('STRIPE_IE_SECRET_KEY'), + 'ie_public_key' => env('STRIPE_IE_PUBLIC_KEY'), + 'ie_webhook_secret' => env('STRIPE_IE_WEBHOOK_SECRET'), + + // Primary platform for new organizers + 'primary_platform' => env('STRIPE_PRIMARY_PLATFORM'), ], 'open_exchange_rates' => [ 'app_id' => env('OPEN_EXCHANGE_RATES_APP_ID'), diff --git a/backend/database/migrations/2025_09_01_080412_move_stripe_platform_from_organizers_to_accounts.php b/backend/database/migrations/2025_09_01_080412_move_stripe_platform_from_organizers_to_accounts.php new file mode 100644 index 0000000000..4a3a0ffef4 --- /dev/null +++ b/backend/database/migrations/2025_09_01_080412_move_stripe_platform_from_organizers_to_accounts.php @@ -0,0 +1,32 @@ +string('stripe_platform', 2)->nullable()->after('account_verified_at'); + $table->index('stripe_platform'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove stripe_platform from accounts table + Schema::table('accounts', function (Blueprint $table) { + $table->dropIndex(['stripe_platform']); + $table->dropColumn('stripe_platform'); + }); + } +}; diff --git a/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php b/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php new file mode 100644 index 0000000000..acbacfb41b --- /dev/null +++ b/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php @@ -0,0 +1,29 @@ +assertEquals(StripePlatform::CANADA, StripePlatform::fromString('ca')); + $this->assertEquals(StripePlatform::IRELAND, StripePlatform::fromString('ie')); + $this->assertNull(StripePlatform::fromString(null)); + $this->assertNull(StripePlatform::fromString('invalid')); + } + + public function test_to_string_returns_value(): void + { + $this->assertEquals('ca', StripePlatform::CANADA->toString()); + $this->assertEquals('ie', StripePlatform::IRELAND->toString()); + } + + public function test_get_all_values(): void + { + $expected = ['ca', 'ie']; + $this->assertEquals($expected, StripePlatform::getAllValues()); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php new file mode 100644 index 0000000000..56845cb913 --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php @@ -0,0 +1,105 @@ +mockConfigService = Mockery::mock(StripeConfigurationService::class); + $this->factory = new StripeClientFactory($this->mockConfigService); + } + + public function test_create_for_platform_creates_client_with_default_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(null) + ->once() + ->andReturn('sk_test_default'); + + $client = $this->factory->createForPlatform(); + + $this->assertInstanceOf(StripeClient::class, $client); + } + + public function test_create_for_platform_creates_client_with_canada_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(StripePlatform::CANADA) + ->once() + ->andReturn('sk_test_canada'); + + $client = $this->factory->createForPlatform(StripePlatform::CANADA); + + $this->assertInstanceOf(StripeClient::class, $client); + } + + public function test_create_for_platform_creates_client_with_ireland_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(StripePlatform::IRELAND) + ->once() + ->andReturn('sk_test_ireland'); + + $client = $this->factory->createForPlatform(StripePlatform::IRELAND); + + $this->assertInstanceOf(StripeClient::class, $client); + } + + public function test_create_for_platform_throws_exception_when_no_secret_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(null) + ->once() + ->andReturn(''); + + $this->expectException(StripeClientConfigurationException::class); + $this->expectExceptionMessage('Stripe secret key not configured for platform: default'); + + $this->factory->createForPlatform(); + } + + public function test_create_for_platform_throws_exception_for_canada_platform_missing_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(StripePlatform::CANADA) + ->once() + ->andReturn(''); + + $this->expectException(StripeClientConfigurationException::class); + $this->expectExceptionMessage('Stripe secret key not configured for platform: ca'); + + $this->factory->createForPlatform(StripePlatform::CANADA); + } + + public function test_create_for_platform_throws_exception_for_ireland_platform_missing_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(StripePlatform::IRELAND) + ->once() + ->andReturn(''); + + $this->expectException(StripeClientConfigurationException::class); + $this->expectExceptionMessage('Stripe secret key not configured for platform: ie'); + + $this->factory->createForPlatform(StripePlatform::IRELAND); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php new file mode 100644 index 0000000000..cfbdf2b0a3 --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php @@ -0,0 +1,162 @@ +service = new StripeConfigurationService(); + } + + public function test_get_secret_key_returns_default_when_no_platform(): void + { + config(['services.stripe.secret_key' => 'sk_default']); + + $result = $this->service->getSecretKey(); + + $this->assertEquals('sk_default', $result); + } + + public function test_get_secret_key_returns_canada_platform_key(): void + { + config([ + 'services.stripe.secret_key' => 'sk_default', + 'services.stripe.ca_secret_key' => 'sk_canada' + ]); + + $result = $this->service->getSecretKey(StripePlatform::CANADA); + + $this->assertEquals('sk_canada', $result); + } + + public function test_get_secret_key_returns_ireland_platform_key(): void + { + config([ + 'services.stripe.secret_key' => 'sk_default', + 'services.stripe.ie_secret_key' => 'sk_ireland' + ]); + + $result = $this->service->getSecretKey(StripePlatform::IRELAND); + + $this->assertEquals('sk_ireland', $result); + } + + public function test_get_secret_key_returns_null_when_no_keys_configured(): void + { + // Clear all configuration + config([ + 'services.stripe.secret_key' => null, + 'services.stripe.ca_secret_key' => null, + 'services.stripe.ie_secret_key' => null + ]); + + $result = $this->service->getSecretKey(); + + $this->assertNull($result); + } + + public function test_get_public_key_returns_correct_platform_keys(): void + { + config([ + 'services.stripe.public_key' => 'pk_default', + 'services.stripe.ca_public_key' => 'pk_canada', + 'services.stripe.ie_public_key' => 'pk_ireland' + ]); + + $this->assertEquals('pk_default', $this->service->getPublicKey()); + $this->assertEquals('pk_canada', $this->service->getPublicKey(StripePlatform::CANADA)); + $this->assertEquals('pk_ireland', $this->service->getPublicKey(StripePlatform::IRELAND)); + } + + public function test_get_all_webhook_secrets_includes_all_platforms(): void + { + config([ + 'services.stripe.webhook_secret' => 'whsec_default', + 'services.stripe.ca_webhook_secret' => 'whsec_canada', + 'services.stripe.ie_webhook_secret' => 'whsec_ireland' + ]); + + $result = $this->service->getAllWebhookSecrets(); + + $this->assertEquals('whsec_default', $result['default']); + $this->assertEquals('whsec_canada', $result['ca']); + $this->assertEquals('whsec_ireland', $result['ie']); + } + + public function test_get_primary_platform_returns_correct_enum(): void + { + config(['services.stripe.primary_platform' => 'ie']); + + $result = $this->service->getPrimaryPlatform(); + + $this->assertEquals(StripePlatform::IRELAND, $result); + } + + public function test_get_primary_platform_returns_null_when_not_configured(): void + { + config(['services.stripe.primary_platform' => null]); + + $result = $this->service->getPrimaryPlatform(); + + $this->assertNull($result); + } + + public function test_get_primary_platform_returns_null_for_invalid_platform(): void + { + config(['services.stripe.primary_platform' => 'invalid']); + + $result = $this->service->getPrimaryPlatform(); + + $this->assertNull($result); + } + + public function test_get_all_webhook_secrets_returns_filtered_secrets(): void + { + config([ + 'services.stripe.webhook_secret' => 'whsec_default', + 'services.stripe.ca_webhook_secret' => 'whsec_canada', + 'services.stripe.ie_webhook_secret' => null + ]); + + $result = $this->service->getAllWebhookSecrets(); + + $expected = [ + 'default' => 'whsec_default', + 'ca' => 'whsec_canada' + ]; + + $this->assertEquals($expected, $result); + } + + public function test_get_all_webhook_secrets_orders_primary_platform_first(): void + { + config([ + 'services.stripe.webhook_secret' => 'whsec_default', + 'services.stripe.ca_webhook_secret' => 'whsec_canada', + 'services.stripe.ie_webhook_secret' => 'whsec_ireland', + 'services.stripe.primary_platform' => 'ie' + ]); + + $result = $this->service->getAllWebhookSecrets(); + + $keys = array_keys($result); + $this->assertEquals('ie', $keys[0], 'Primary platform should be first'); + } + + public function test_get_primary_platform_handles_string_conversion(): void + { + config(['services.stripe.primary_platform' => 'ca']); + + $result = $this->service->getPrimaryPlatform(); + + $this->assertEquals(StripePlatform::CANADA, $result); + } +} \ No newline at end of file diff --git a/frontend/src/api/order.client.ts b/frontend/src/api/order.client.ts index 6b4589fbad..9edd5cc97d 100644 --- a/frontend/src/api/order.client.ts +++ b/frontend/src/api/order.client.ts @@ -148,6 +148,8 @@ export const orderClientPublic = { const response = await publicApi.post<{ client_secret: string, account_id?: string, + public_key: string, + stripe_platform?: string, }>(`events/${eventId}/order/${orderShortId}/stripe/payment_intent`); return response.data; }, diff --git a/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx index 1a215a75e8..1c01f20087 100644 --- a/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx @@ -3,7 +3,6 @@ import {useCreateStripePaymentIntent} from "../../../../../../queries/useCreateS import {useEffect, useState} from "react"; import {loadStripe, Stripe} from "@stripe/stripe-js"; import {useGetEventPublic} from "../../../../../../queries/useGetEventPublic.ts"; -import {getConfig} from "../../../../../../utilites/config.ts"; import {CheckoutContent} from "../../../../../layouts/Checkout/CheckoutContent"; import {HomepageInfoMessage} from "../../../../../common/HomepageInfoMessage"; import {t} from "@lingui/macro"; @@ -29,7 +28,7 @@ export const StripePaymentMethod = ({enabled, setSubmitHandler}: StripePaymentMe const {data: event} = useGetEventPublic(eventId); useEffect(() => { - if (!stripeData?.client_secret) { + if (!stripeData?.client_secret || !stripeData?.public_key) { return; } @@ -38,7 +37,7 @@ export const StripePaymentMethod = ({enabled, setSubmitHandler}: StripePaymentMe stripeAccount: stripeAccount } : {}; - setStripePromise(loadStripe(getConfig('VITE_STRIPE_PUBLISHABLE_KEY') as string, options)); + setStripePromise(loadStripe(stripeData.public_key, options)); }, [stripeData]); if (!enabled) { diff --git a/frontend/src/queries/useCreateStripePaymentIntent.ts b/frontend/src/queries/useCreateStripePaymentIntent.ts index 5eada79576..9076146ff4 100644 --- a/frontend/src/queries/useCreateStripePaymentIntent.ts +++ b/frontend/src/queries/useCreateStripePaymentIntent.ts @@ -1,7 +1,6 @@ import {useQuery} from "@tanstack/react-query"; import {orderClientPublic} from "../api/order.client.ts"; import {IdParam} from "../types.ts"; -import {getSessionIdentifier} from "../utilites/sessionIdentifier.ts"; export const GET_INITIATE_STRIPE_SESSION_PUBLIC_QUERY_KEY = 'getStripSessionPublic'; @@ -10,11 +9,11 @@ export const useCreateStripePaymentIntent = (eventId: IdParam, orderShortId: IdP queryKey: [GET_INITIATE_STRIPE_SESSION_PUBLIC_QUERY_KEY], queryFn: async () => { - const {client_secret, account_id} = await orderClientPublic.createStripePaymentIntent( + const {client_secret, account_id, public_key, stripe_platform} = await orderClientPublic.createStripePaymentIntent( Number(eventId), String(orderShortId), ); - return {client_secret, account_id}; + return {client_secret, account_id, public_key, stripe_platform}; }, retry: false,