diff --git a/backend/app/Http/Actions/Organizers/Stripe/DisconnectStripeConnectAccountAction.php b/backend/app/Http/Actions/Organizers/Stripe/DisconnectStripeConnectAccountAction.php new file mode 100644 index 0000000000..85d8929843 --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Stripe/DisconnectStripeConnectAccountAction.php @@ -0,0 +1,37 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN); + + try { + $this->disconnectStripeConnectAccountHandler->handle( + organizerId: $organizerId, + accountId: $this->getAuthenticatedAccountId(), + stripeAccountId: $stripeAccountId, + ); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_NOT_FOUND, + ); + } + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Request/Image/CreateImageRequest.php b/backend/app/Http/Request/Image/CreateImageRequest.php index 497a9ff008..7abf7245bb 100644 --- a/backend/app/Http/Request/Image/CreateImageRequest.php +++ b/backend/app/Http/Request/Image/CreateImageRequest.php @@ -20,7 +20,7 @@ public function rules(): array 'image' => [ 'required', 'image', - 'max:8192', // 8mb + 'max:5120', // 5mb 'dimensions:min_width='.$minWidth.',min_height='.$minHeight.',max_width=4000,max_height=4000', 'mimes:jpeg,png,jpg,webp', ], diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index dc590468d2..4beb440e84 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -9,7 +9,6 @@ use HiEvents\DomainObjects\EventLocationDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\LocationDomainObject; -use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Events\Dispatcher; use HiEvents\Events\EventUpdateEvent; use HiEvents\Exceptions\CannotChangeCurrencyException; @@ -66,8 +65,11 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void ); } - if ($eventData->currency !== null && $eventData->currency !== $existingEvent->getCurrency()) { - $this->checkForCompletedOrders($eventData); + $isCurrencyChanging = $eventData->currency !== null && $eventData->currency !== $existingEvent->getCurrency(); + + if ($isCurrencyChanging) { + $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$eventData->id]); + $this->guardCurrencyChange($eventData); } $attributes = [ @@ -86,6 +88,16 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void ], ); + if ($isCurrencyChanging) { + $this->orderRepository->updateWhere( + attributes: ['currency' => $eventData->currency], + where: [ + 'event_id' => $eventData->id, + ['total_gross', '=', 0], + ], + ); + } + $this->updateSingleOccurrenceDates($eventData, $existingEvent); } @@ -151,16 +163,16 @@ private function getUpdateEvent(UpdateEventDTO $eventData): EventDomainObject /** * @throws CannotChangeCurrencyException */ - private function checkForCompletedOrders(UpdateEventDTO $eventData): void + private function guardCurrencyChange(UpdateEventDTO $eventData): void { - $orders = $this->orderRepository->findWhere([ + $paidOrder = $this->orderRepository->findFirstWhere([ 'event_id' => $eventData->id, - 'status' => OrderStatus::COMPLETED->name, + ['total_gross', '>', 0], ]); - if ($orders->isNotEmpty()) { + if ($paidOrder !== null) { throw new CannotChangeCurrencyException( - __('You cannot change the currency of an event that has completed orders'), + __('You cannot change the currency of an event that has paid orders. To use a different currency, duplicate the event and change the currency on the new event.'), ); } } diff --git a/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DisconnectStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DisconnectStripeConnectAccountHandler.php new file mode 100644 index 0000000000..c7ab34ff7c --- /dev/null +++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DisconnectStripeConnectAccountHandler.php @@ -0,0 +1,47 @@ +organizerRepository->findFirstWhere([ + 'id' => $organizerId, + 'account_id' => $accountId, + ]); + + if ($organizer === null) { + throw new ResourceNotFoundException(__('Organizer not found.')); + } + + $deletedCount = $this->organizerStripePlatformRepository->deleteWhere([ + 'organizer_id' => $organizerId, + 'stripe_account_id' => $stripeAccountId, + ]); + + if ($deletedCount === 0) { + throw new ResourceNotFoundException(__('Stripe connection not found.')); + } + + $this->logger->info('Stripe connect account disconnected from organizer', [ + 'organizer_id' => $organizerId, + 'account_id' => $accountId, + 'stripe_account_id' => $stripeAccountId, + ]); + } +} diff --git a/backend/app/Validators/Rules/RulesHelper.php b/backend/app/Validators/Rules/RulesHelper.php index ba5573d94b..6a26a4732c 100644 --- a/backend/app/Validators/Rules/RulesHelper.php +++ b/backend/app/Validators/Rules/RulesHelper.php @@ -21,7 +21,7 @@ class RulesHelper public const IMAGE_RULES = [ 'required', 'image', - 'max:8192', // 8mb + 'max:5120', // 5mb 'dimensions:min_width=600,min_height=50,max_width=4000,max_height=4000', 'mimes:jpeg,png,jpg,webp', ]; diff --git a/backend/routes/api.php b/backend/routes/api.php index ca0a1d6ad3..bbff2c58bb 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -179,6 +179,7 @@ use HiEvents\Http\Actions\Organizers\Stats\GetOrganizerStatsAction; use HiEvents\Http\Actions\Organizers\Stripe\CopyStripeConnectAccountAction; use HiEvents\Http\Actions\Organizers\Stripe\CreateStripeConnectAccountAction; +use HiEvents\Http\Actions\Organizers\Stripe\DisconnectStripeConnectAccountAction; use HiEvents\Http\Actions\Organizers\Stripe\GetStripeConnectAccountsAction; use HiEvents\Http\Actions\Organizers\UpdateOrganizerLocationAction; use HiEvents\Http\Actions\Organizers\UpdateOrganizerStatusAction; @@ -355,6 +356,8 @@ function (Router $router): void { $router->get('/organizers/{organizerId}/stripe/connect_accounts', GetStripeConnectAccountsAction::class); $router->post('/organizers/{organizerId}/stripe/connect', CreateStripeConnectAccountAction::class); $router->post('/organizers/{organizerId}/stripe/copy_from/{sourceOrganizerId}', CopyStripeConnectAccountAction::class); + $router->delete('/organizers/{organizerId}/stripe/connect_accounts/{stripeAccountId}', DisconnectStripeConnectAccountAction::class) + ->where('stripeAccountId', '[A-Za-z0-9_]+'); // VAT Settings - Organizer level $router->get('/organizers/{organizerId}/vat-settings', GetOrganizerVatSettingAction::class); diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php index d2548b0955..ba33bea279 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php @@ -6,7 +6,6 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\OrderDomainObject; -use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Events\Dispatcher; use HiEvents\Exceptions\CannotChangeCurrencyException; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; @@ -16,7 +15,6 @@ use HiEvents\Services\Application\Handlers\Event\UpdateEventHandler; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; use Mockery; use Mockery\MockInterface; @@ -34,6 +32,8 @@ class UpdateEventHandlerTest extends TestCase private Dispatcher|MockInterface $dispatcher; + private DatabaseManager|MockInterface $databaseManager; + private UpdateEventHandler $handler; protected function setUp(): void @@ -49,13 +49,13 @@ protected function setUp(): void $this->purifier = Mockery::mock(HtmlPurifierService::class); $this->dispatcher = Mockery::mock(Dispatcher::class); - $databaseManager = Mockery::mock(DatabaseManager::class); - $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($cb) => $cb()); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + $this->databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($cb) => $cb()); $this->handler = new UpdateEventHandler( $this->eventRepository, $this->dispatcher, - $databaseManager, + $this->databaseManager, $this->orderRepository, $this->purifier, $this->occurrenceRepository, @@ -68,7 +68,7 @@ protected function tearDown(): void parent::tearDown(); } - public function test_throws_when_changing_currency_with_completed_orders(): void + public function test_throws_when_changing_currency_with_paid_orders(): void { $existing = Mockery::mock(EventDomainObject::class); $existing->shouldReceive('getCurrency')->andReturn('USD'); @@ -77,14 +77,17 @@ public function test_throws_when_changing_currency_with_completed_orders(): void ->shouldReceive('findFirstWhere') ->andReturn($existing); - $completedOrder = Mockery::mock(OrderDomainObject::class); + $this->databaseManager + ->shouldReceive('statement') + ->once() + ->with('SELECT pg_advisory_xact_lock(?)', [1]); + $this->orderRepository - ->shouldReceive('findWhere') - ->with([ - 'event_id' => 1, - 'status' => OrderStatus::COMPLETED->name, - ]) - ->andReturn(new Collection([$completedOrder])); + ->shouldReceive('findFirstWhere') + ->with(['event_id' => 1, ['total_gross', '>', 0]]) + ->andReturn(Mockery::mock(OrderDomainObject::class)); + + $this->orderRepository->shouldNotReceive('updateWhere'); $this->expectException(CannotChangeCurrencyException::class); @@ -97,7 +100,7 @@ public function test_throws_when_changing_currency_with_completed_orders(): void )); } - public function test_allows_currency_change_when_no_completed_orders(): void + public function test_allows_currency_change_and_updates_free_orders_when_no_paid_orders(): void { $existing = Mockery::mock(EventDomainObject::class); $existing->shouldReceive('getCurrency')->andReturn('USD'); @@ -112,13 +115,15 @@ public function test_allows_currency_change_when_no_completed_orders(): void ->shouldReceive('findFirstWhere') ->andReturn($existing, $reloaded); + $this->databaseManager + ->shouldReceive('statement') + ->once() + ->with('SELECT pg_advisory_xact_lock(?)', [1]); + $this->orderRepository - ->shouldReceive('findWhere') - ->with([ - 'event_id' => 1, - 'status' => OrderStatus::COMPLETED->name, - ]) - ->andReturn(new Collection); + ->shouldReceive('findFirstWhere') + ->with(['event_id' => 1, ['total_gross', '>', 0]]) + ->andReturnNull(); $this->purifier->shouldReceive('purify')->andReturn(null); @@ -130,6 +135,14 @@ public function test_allows_currency_change_when_no_completed_orders(): void ['id' => 1, 'account_id' => 5], ); + $this->orderRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + ['currency' => 'EUR'], + ['event_id' => 1, ['total_gross', '=', 0]], + ); + $this->dispatcher->shouldReceive('dispatchEvent')->once(); $result = $this->handler->handle(new UpdateEventDTO( @@ -143,7 +156,7 @@ public function test_allows_currency_change_when_no_completed_orders(): void $this->assertSame($reloaded, $result); } - public function test_skips_completed_orders_check_when_currency_unchanged(): void + public function test_skips_orders_check_when_currency_unchanged(): void { $existing = Mockery::mock(EventDomainObject::class); $existing->shouldReceive('getCurrency')->andReturn('USD'); @@ -158,7 +171,9 @@ public function test_skips_completed_orders_check_when_currency_unchanged(): voi ->shouldReceive('findFirstWhere') ->andReturn($existing, $reloaded); - $this->orderRepository->shouldNotReceive('findWhere'); + $this->databaseManager->shouldNotReceive('statement'); + $this->orderRepository->shouldNotReceive('findFirstWhere'); + $this->orderRepository->shouldNotReceive('updateWhere'); $this->purifier->shouldReceive('purify')->andReturn(null); $this->eventRepository->shouldReceive('updateWhere')->once(); $this->dispatcher->shouldReceive('dispatchEvent')->once(); diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/DisconnectStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/DisconnectStripeConnectAccountHandlerTest.php new file mode 100644 index 0000000000..11c03498ed --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/DisconnectStripeConnectAccountHandlerTest.php @@ -0,0 +1,98 @@ +organizerRepository = m::mock(OrganizerRepositoryInterface::class); + $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class); + $this->logger = m::mock(LoggerInterface::class); + + $this->handler = new DisconnectStripeConnectAccountHandler( + $this->organizerRepository, + $this->organizerStripePlatformRepository, + $this->logger, + ); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } + + public function test_soft_deletes_matching_stripe_platform_rows(): void + { + $this->expectNotToPerformAssertions(); + + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 1, 'account_id' => 99]) + ->andReturn((new OrganizerDomainObject)->setId(1)->setAccountId(99)); + + $this->organizerStripePlatformRepository + ->shouldReceive('deleteWhere') + ->once() + ->with(['organizer_id' => 1, 'stripe_account_id' => 'acct_123']) + ->andReturn(1); + + $this->logger->shouldReceive('info')->once(); + + $this->handler->handle(organizerId: 1, accountId: 99, stripeAccountId: 'acct_123'); + } + + public function test_throws_when_organizer_not_found(): void + { + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 1, 'account_id' => 99]) + ->andReturnNull(); + + $this->organizerStripePlatformRepository->shouldNotReceive('deleteWhere'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle(organizerId: 1, accountId: 99, stripeAccountId: 'acct_123'); + } + + public function test_throws_when_no_connection_matches(): void + { + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn((new OrganizerDomainObject)->setId(1)->setAccountId(99)); + + $this->organizerStripePlatformRepository + ->shouldReceive('deleteWhere') + ->once() + ->with(['organizer_id' => 1, 'stripe_account_id' => 'acct_missing']) + ->andReturn(0); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle(organizerId: 1, accountId: 99, stripeAccountId: 'acct_missing'); + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f69f47d8f6..5ffb2d7484 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -38,6 +38,9 @@ export const api = axios.create({ api.interceptors.response.use( (response) => response, (error) => { + if (!error.response) { + return Promise.reject(error); + } const { status } = error.response; const currentPath = window?.location.pathname; const isAllowedUnauthenticatedPath = ALLOWED_UNAUTHENTICATED_PATHS.some(path => currentPath.includes(path)); diff --git a/frontend/src/api/organizer-stripe.client.ts b/frontend/src/api/organizer-stripe.client.ts index e7d12d3ff9..04762b28b1 100644 --- a/frontend/src/api/organizer-stripe.client.ts +++ b/frontend/src/api/organizer-stripe.client.ts @@ -27,4 +27,7 @@ export const organizerStripeClient = { ); return response.data; }, + disconnectAccount: async (organizerId: IdParam, stripeAccountId: string) => { + await api.delete(`organizers/${organizerId}/stripe/connect_accounts/${stripeAccountId}`); + }, }; diff --git a/frontend/src/components/common/Editor/Controls/InsertImageControl/index.tsx b/frontend/src/components/common/Editor/Controls/InsertImageControl/index.tsx index 7348cd59bb..ae7ee2caf6 100644 --- a/frontend/src/components/common/Editor/Controls/InsertImageControl/index.tsx +++ b/frontend/src/components/common/Editor/Controls/InsertImageControl/index.tsx @@ -4,6 +4,7 @@ import {t} from "@lingui/macro"; import {IconPhotoPlus} from "@tabler/icons-react"; import {Button, FileButton, Group, Image, Loader, Modal, Portal, Stack, Tabs, Text, TextInput} from "@mantine/core"; import {useUploadImage} from "../../../../../mutations/useUploadImage.ts"; +import {extractImageUploadErrors, validateImageFile} from "../../../../../utilites/imageUploadValidation.ts"; export const InsertImageControl = () => { const editor = useRichTextEditorContext(); @@ -54,6 +55,11 @@ export const InsertImageControl = () => { setUploadError(t`Please select an image.`); return; } + const validationError = validateImageFile(file); + if (validationError) { + setUploadError(validationError); + return; + } setIsUploading(true); setUploadError(null); uploadMutation.mutate({image: file}, { @@ -63,8 +69,7 @@ export const InsertImageControl = () => { setIsUploading(false); }, onError: (error: any) => { - const message = error?.response?.data?.message ?? t`Failed to upload image.`; - setUploadError(message); + setUploadError(extractImageUploadErrors(error).join(" ")); setIsUploading(false); } }); diff --git a/frontend/src/components/common/ImageUploadDropzone/index.tsx b/frontend/src/components/common/ImageUploadDropzone/index.tsx index c3519d72a2..fbaf212623 100644 --- a/frontend/src/components/common/ImageUploadDropzone/index.tsx +++ b/frontend/src/components/common/ImageUploadDropzone/index.tsx @@ -8,10 +8,13 @@ import {ActionIcon, Button, Group, Loader, Text} from "@mantine/core"; import {IconReplace, IconTrash, IconUpload} from "@tabler/icons-react"; import {t} from "@lingui/macro"; import {IdParam, ImageType} from "../../../types.ts"; +import { + extractImageUploadErrors, + IMAGE_MAX_UPLOAD_SIZE, + validateImageFile, +} from "../../../utilites/imageUploadValidation.ts"; import classes from "./ImageUploadDropzone.module.scss"; -const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; // 5MB - interface ImageUploadDropzoneProps { disabled?: boolean; helpText?: string; @@ -71,6 +74,12 @@ export const ImageUploadDropzone = ({ const [file] = files; if (!file) return; + const validationError = validateImageFile(file); + if (validationError) { + setErrors([validationError]); + return; + } + setErrors([]); setLoading(true); @@ -93,18 +102,7 @@ export const ImageUploadDropzone = ({ onError: (error: any) => { console.error(error); setLoading(false); - - // Extract error messages from the response - let errorMessages: string[]; - if (error?.response?.data?.errors?.image) { - errorMessages = error.response.data.errors.image; - } else if (error?.response?.data?.message) { - errorMessages = [error.response.data.message]; - } else { - errorMessages = [t`Failed to upload image. Please try again.`]; - } - - setErrors(errorMessages); + setErrors(extractImageUploadErrors(error)); }, } ); @@ -220,7 +218,7 @@ export const ImageUploadDropzone = ({ onDrop={handleDrop} onReject={handleReject} accept={IMAGE_MIME_TYPE} - maxSize={MAX_UPLOAD_SIZE} + maxSize={IMAGE_MAX_UPLOAD_SIZE} disabled={disabled || loading} className={classes.dropzone} classNames={{ diff --git a/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx b/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx index 2ffc5833c9..f5156dfee8 100644 --- a/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx @@ -135,7 +135,7 @@ export const EventDetailsForm = () => { {...form.getInputProps('currency')} label={t`Currency`} placeholder={t`EUR`} - disabled + description={t`The currency used for this event's ticket prices.`} />