From fb8041e250036a0baed9ac7e1ffb78433dd4a3f7 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Thu, 30 Jul 2026 19:32:35 +0100 Subject: [PATCH 1/2] Add Stripe disconnect, self-serve event currency change, and inline image upload errors --- .../DisconnectStripeConnectAccountAction.php | 37 ++++ .../Http/Request/Image/CreateImageRequest.php | 2 +- .../Handlers/Event/UpdateEventHandler.php | 27 ++- .../DisconnectStripeConnectAccountHandler.php | 47 +++++ backend/app/Validators/Rules/RulesHelper.php | 2 +- backend/routes/api.php | 3 + .../Handlers/Event/UpdateEventHandlerTest.php | 40 ++-- ...connectStripeConnectAccountHandlerTest.php | 98 +++++++++ frontend/src/api/client.ts | 3 + frontend/src/api/organizer-stripe.client.ts | 3 + .../Controls/InsertImageControl/index.tsx | 9 +- .../common/ImageUploadDropzone/index.tsx | 28 ++- .../Sections/EventDetailsForm/index.tsx | 3 +- .../Sections/PayoutsSettings/index.tsx | 57 ++++++ frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 189 +++++++++++------- frontend/src/locales/el.js | 2 +- frontend/src/locales/el.po | 189 +++++++++++------- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 189 +++++++++++------- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 189 +++++++++++------- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 189 +++++++++++------- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 189 +++++++++++------- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 189 +++++++++++------- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 189 +++++++++++------- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 189 +++++++++++------- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 189 +++++++++++------- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 189 +++++++++++------- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 189 +++++++++++------- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 189 +++++++++++------- frontend/src/locales/sk.js | 2 +- frontend/src/locales/sk.po | 189 +++++++++++------- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 189 +++++++++++------- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 189 +++++++++++------- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 189 +++++++++++------- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 189 +++++++++++------- .../useDisconnectOrganizerStripeConnect.ts | 24 +++ .../src/utilites/imageUploadValidation.ts | 29 +++ 52 files changed, 2507 insertions(+), 1343 deletions(-) create mode 100644 backend/app/Http/Actions/Organizers/Stripe/DisconnectStripeConnectAccountAction.php create mode 100644 backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DisconnectStripeConnectAccountHandler.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/DisconnectStripeConnectAccountHandlerTest.php create mode 100644 frontend/src/mutations/useDisconnectOrganizerStripeConnect.ts create mode 100644 frontend/src/utilites/imageUploadValidation.ts 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..4aaa4a52a0 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,10 @@ 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->guardCurrencyChange($eventData); } $attributes = [ @@ -86,6 +87,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 +162,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..fca0d7ae49 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; @@ -68,7 +66,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 +75,12 @@ public function test_throws_when_changing_currency_with_completed_orders(): void ->shouldReceive('findFirstWhere') ->andReturn($existing); - $completedOrder = Mockery::mock(OrderDomainObject::class); $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 +93,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'); @@ -113,12 +109,9 @@ public function test_allows_currency_change_when_no_completed_orders(): void ->andReturn($existing, $reloaded); $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 +123,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 +144,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 +159,8 @@ public function test_skips_completed_orders_check_when_currency_unchanged(): voi ->shouldReceive('findFirstWhere') ->andReturn($existing, $reloaded); - $this->orderRepository->shouldNotReceive('findWhere'); + $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.`} />