diff --git a/backend/app/DomainObjects/Enums/OrganizerReportTypes.php b/backend/app/DomainObjects/Enums/OrganizerReportTypes.php index ea0abbc7c..dccca6f4d 100644 --- a/backend/app/DomainObjects/Enums/OrganizerReportTypes.php +++ b/backend/app/DomainObjects/Enums/OrganizerReportTypes.php @@ -10,4 +10,5 @@ enum OrganizerReportTypes: string case EVENTS_PERFORMANCE = 'events_performance'; case TAX_SUMMARY = 'tax_summary'; case CHECK_IN_SUMMARY = 'check_in_summary'; + case PLATFORM_FEES = 'platform_fees'; } diff --git a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php index ecb073ac0..076da8954 100644 --- a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php @@ -25,8 +25,6 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const TOTAL_SERVICE_FEE = 'total_service_fee'; final public const TAXES_AND_FEES_ROLLUP = 'taxes_and_fees_rollup'; final public const PRODUCT_TYPE = 'product_type'; - final public const BUNDLE_GROUP_ID = 'bundle_group_id'; - final public const IS_BUNDLE_PRIMARY = 'is_bundle_primary'; protected int $id; protected int $order_id; @@ -43,8 +41,6 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected ?float $total_service_fee = 0.0; protected array|string|null $taxes_and_fees_rollup = null; protected string $product_type = 'TICKET'; - protected ?string $bundle_group_id = null; - protected bool $is_bundle_primary = false; public function toArray(): array { @@ -64,8 +60,6 @@ public function toArray(): array 'total_service_fee' => $this->total_service_fee ?? null, 'taxes_and_fees_rollup' => $this->taxes_and_fees_rollup ?? null, 'product_type' => $this->product_type ?? null, - 'bundle_group_id' => $this->bundle_group_id ?? null, - 'is_bundle_primary' => $this->is_bundle_primary ?? null, ]; } @@ -233,26 +227,4 @@ public function getProductType(): string { return $this->product_type; } - - public function setBundleGroupId(?string $bundle_group_id): self - { - $this->bundle_group_id = $bundle_group_id; - return $this; - } - - public function getBundleGroupId(): ?string - { - return $this->bundle_group_id; - } - - public function setIsBundlePrimary(bool $is_bundle_primary): self - { - $this->is_bundle_primary = $is_bundle_primary; - return $this; - } - - public function getIsBundlePrimary(): bool - { - return $this->is_bundle_primary; - } } diff --git a/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php b/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php new file mode 100644 index 000000000..26688e20f --- /dev/null +++ b/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php @@ -0,0 +1,224 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $this->validateDateRange($request); + + if (!in_array($reportType, OrganizerReportTypes::valuesArray(), true)) { + throw new BadRequestHttpException(__('Invalid report type.')); + } + + $reportData = $this->reportHandler->handle( + reportData: new GetOrganizerReportDTO( + organizerId: $organizerId, + reportType: OrganizerReportTypes::from($reportType), + startDate: $request->validated('start_date'), + endDate: $request->validated('end_date'), + currency: $request->validated('currency'), + eventId: $request->validated('event_id'), + page: 1, + perPage: self::MAX_EXPORT_ROWS, + ), + ); + + $data = $reportData instanceof PaginatedReportDTO + ? $reportData->data + : $reportData; + + $filename = $reportType . '_' . date('Y-m-d_H-i-s') . '.csv'; + + return new StreamedResponse(function () use ($data, $reportType) { + $handle = fopen('php://output', 'w'); + + $headers = $this->getHeadersForReportType($reportType); + fputcsv($handle, $headers); + + foreach ($data as $row) { + $csvRow = $this->formatRowForReportType($row, $reportType); + fputcsv($handle, $csvRow); + } + + fclose($handle); + }, 200, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => "attachment; filename=\"$filename\"", + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Pragma' => 'no-cache', + 'Expires' => '0', + ]); + } + + private function getHeadersForReportType(string $reportType): array + { + return match ($reportType) { + OrganizerReportTypes::PLATFORM_FEES->value => [ + 'Event', + 'Payment Date', + 'Order Reference', + 'Amount Paid', + 'Hi.Events Fee', + 'VAT Rate', + 'VAT on Fee', + 'Total Fee', + 'Currency', + 'Stripe Payment ID', + ], + OrganizerReportTypes::REVENUE_SUMMARY->value => [ + 'Date', + 'Gross Sales', + 'Net Revenue', + 'Total Refunded', + 'Total Tax', + 'Total Fee', + 'Order Count', + ], + OrganizerReportTypes::EVENTS_PERFORMANCE->value => [ + 'Event ID', + 'Event Name', + 'Currency', + 'Start Date', + 'End Date', + 'Status', + 'Event State', + 'Products Sold', + 'Gross Revenue', + 'Total Refunded', + 'Net Revenue', + 'Total Tax', + 'Total Fee', + 'Total Orders', + 'Unique Customers', + 'Page Views', + ], + OrganizerReportTypes::TAX_SUMMARY->value => [ + 'Event ID', + 'Event Name', + 'Currency', + 'Tax Name', + 'Tax Rate', + 'Total Collected', + 'Order Count', + ], + OrganizerReportTypes::CHECK_IN_SUMMARY->value => [ + 'Event ID', + 'Event Name', + 'Start Date', + 'Total Attendees', + 'Total Checked In', + 'Check-in Rate (%)', + 'Check-in Lists Count', + ], + default => [], + }; + } + + private function formatRowForReportType(object $row, string $reportType): array + { + return match ($reportType) { + OrganizerReportTypes::PLATFORM_FEES->value => [ + $row->event_name ?? '', + $row->payment_date ? date('Y-m-d H:i:s', strtotime($row->payment_date)) : '', + $row->order_reference ?? '', + $row->amount_paid ?? 0, + $row->fee_amount ?? 0, + $row->vat_rate !== null ? ($row->vat_rate * 100) . '%' : '', + $row->vat_amount ?? 0, + $row->total_fee ?? 0, + $row->currency ?? '', + $row->payment_intent_id ?? '', + ], + OrganizerReportTypes::REVENUE_SUMMARY->value => [ + $row->date ?? '', + $row->gross_sales ?? 0, + $row->net_revenue ?? 0, + $row->total_refunded ?? 0, + $row->total_tax ?? 0, + $row->total_fee ?? 0, + $row->order_count ?? 0, + ], + OrganizerReportTypes::EVENTS_PERFORMANCE->value => [ + $row->event_id ?? '', + $row->event_name ?? '', + $row->event_currency ?? '', + $row->start_date ?? '', + $row->end_date ?? '', + $row->status ?? '', + $row->event_state ?? '', + $row->products_sold ?? 0, + $row->gross_revenue ?? 0, + $row->total_refunded ?? 0, + $row->net_revenue ?? 0, + $row->total_tax ?? 0, + $row->total_fee ?? 0, + $row->total_orders ?? 0, + $row->unique_customers ?? 0, + $row->page_views ?? 0, + ], + OrganizerReportTypes::TAX_SUMMARY->value => [ + $row->event_id ?? '', + $row->event_name ?? '', + $row->event_currency ?? '', + $row->tax_name ?? '', + $row->tax_rate ? ($row->tax_rate * 100) . '%' : '', + $row->total_collected ?? 0, + $row->order_count ?? 0, + ], + OrganizerReportTypes::CHECK_IN_SUMMARY->value => [ + $row->event_id ?? '', + $row->event_name ?? '', + $row->start_date ?? '', + $row->total_attendees ?? 0, + $row->total_checked_in ?? 0, + $row->check_in_rate ?? 0, + $row->check_in_lists_count ?? 0, + ], + default => [], + }; + } + + /** + * @throws ValidationException + */ + private function validateDateRange(GetOrganizerReportRequest $request): void + { + $startDate = $request->validated('start_date'); + $endDate = $request->validated('end_date'); + + if (!$startDate || !$endDate) { + return; + } + + $diffInDays = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)); + + if ($diffInDays > 370) { + throw ValidationException::withMessages(['start_date' => __('Date range must be less than 370 days.')]); + } + } +} diff --git a/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php b/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php index 21f3505b4..e97a87f7d 100644 --- a/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php +++ b/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php @@ -8,6 +8,7 @@ use HiEvents\Http\Request\Report\GetOrganizerReportRequest; use HiEvents\Services\Application\Handlers\Reports\DTO\GetOrganizerReportDTO; use HiEvents\Services\Application\Handlers\Reports\GetOrganizerReportHandler; +use HiEvents\Services\Domain\Report\DTO\PaginatedReportDTO; use Illuminate\Http\JsonResponse; use Illuminate\Support\Carbon; use Illuminate\Validation\ValidationException; @@ -39,9 +40,18 @@ public function __invoke(GetOrganizerReportRequest $request, int $organizerId, s startDate: $request->validated('start_date'), endDate: $request->validated('end_date'), currency: $request->validated('currency'), + eventId: $request->validated('event_id'), + page: (int) $request->validated('page', 1), + perPage: (int) $request->validated('per_page', 1000), ), ); + if ($reportData instanceof PaginatedReportDTO) { + return $this->jsonResponse( + data: $reportData->toArray(), + ); + } + return $this->jsonResponse( data: $reportData, wrapInData: true, diff --git a/backend/app/Http/Request/Report/GetOrganizerReportRequest.php b/backend/app/Http/Request/Report/GetOrganizerReportRequest.php index 06ce240a7..afbd35f4e 100644 --- a/backend/app/Http/Request/Report/GetOrganizerReportRequest.php +++ b/backend/app/Http/Request/Report/GetOrganizerReportRequest.php @@ -12,6 +12,9 @@ public function rules(): array 'start_date' => 'date|before:end_date|required_with:end_date|nullable', 'end_date' => 'date|after:start_date|required_with:start_date|nullable', 'currency' => 'string|size:3|nullable', + 'event_id' => 'integer|nullable', + 'page' => 'integer|min:1|nullable', + 'per_page' => 'integer|min:1|max:1000|nullable', ]; } } diff --git a/backend/app/Models/StripePayment.php b/backend/app/Models/StripePayment.php index 4428e7033..cadd3a459 100644 --- a/backend/app/Models/StripePayment.php +++ b/backend/app/Models/StripePayment.php @@ -11,7 +11,7 @@ class StripePayment extends BaseModel protected function getTimestampsEnabled(): bool { - return false; + return true; } protected function getCastMap(): array diff --git a/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php b/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php index e66994b78..d623fcaa4 100644 --- a/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php +++ b/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php @@ -13,6 +13,9 @@ public function __construct( public readonly ?string $startDate, public readonly ?string $endDate, public readonly ?string $currency, + public readonly ?int $eventId = null, + public readonly int $page = 1, + public readonly int $perPage = 1000, ) { } diff --git a/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php b/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php index 751948c91..89f4cdf05 100644 --- a/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php +++ b/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php @@ -3,7 +3,9 @@ namespace HiEvents\Services\Application\Handlers\Reports; use HiEvents\Services\Application\Handlers\Reports\DTO\GetOrganizerReportDTO; +use HiEvents\Services\Domain\Report\DTO\PaginatedReportDTO; use HiEvents\Services\Domain\Report\Factory\OrganizerReportServiceFactory; +use HiEvents\Services\Domain\Report\OrganizerReports\PlatformFeesReport; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -15,15 +17,27 @@ public function __construct( { } - public function handle(GetOrganizerReportDTO $reportData): Collection + public function handle(GetOrganizerReportDTO $reportData): Collection|PaginatedReportDTO { - return $this->reportServiceFactory - ->create($reportData->reportType) - ->generateReport( + $reportService = $this->reportServiceFactory->create($reportData->reportType); + + if ($reportService instanceof PlatformFeesReport) { + return $reportService->generateReport( organizerId: $reportData->organizerId, currency: $reportData->currency, startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null, endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null, + eventId: $reportData->eventId, + page: $reportData->page, + perPage: $reportData->perPage, ); + } + + return $reportService->generateReport( + organizerId: $reportData->organizerId, + currency: $reportData->currency, + startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null, + endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null, + ); } } diff --git a/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php b/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php index 2595ae039..102dd3c57 100644 --- a/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php +++ b/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php @@ -24,7 +24,7 @@ public function generateReport( int $organizerId, ?string $currency = null, ?Carbon $startDate = null, - ?Carbon $endDate = null + ?Carbon $endDate = null, ): Collection { $organizer = $this->organizerRepository->findById($organizerId); diff --git a/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php b/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php new file mode 100644 index 000000000..dde8f813a --- /dev/null +++ b/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php @@ -0,0 +1,32 @@ + $this->data->toArray(), + 'pagination' => [ + 'total' => $this->total, + 'page' => $this->page, + 'per_page' => $this->perPage, + 'last_page' => $this->lastPage, + ], + ]; + } +} diff --git a/backend/app/Services/Domain/Report/Factory/OrganizerReportServiceFactory.php b/backend/app/Services/Domain/Report/Factory/OrganizerReportServiceFactory.php index bd9119910..b9a1cbe66 100644 --- a/backend/app/Services/Domain/Report/Factory/OrganizerReportServiceFactory.php +++ b/backend/app/Services/Domain/Report/Factory/OrganizerReportServiceFactory.php @@ -6,19 +6,21 @@ use HiEvents\Services\Domain\Report\AbstractOrganizerReportService; use HiEvents\Services\Domain\Report\OrganizerReports\CheckInSummaryReport; use HiEvents\Services\Domain\Report\OrganizerReports\EventsPerformanceReport; +use HiEvents\Services\Domain\Report\OrganizerReports\PlatformFeesReport; use HiEvents\Services\Domain\Report\OrganizerReports\RevenueSummaryReport; use HiEvents\Services\Domain\Report\OrganizerReports\TaxSummaryReport; use Illuminate\Support\Facades\App; class OrganizerReportServiceFactory { - public function create(OrganizerReportTypes $reportType): AbstractOrganizerReportService + public function create(OrganizerReportTypes $reportType): AbstractOrganizerReportService|PlatformFeesReport { return match ($reportType) { OrganizerReportTypes::REVENUE_SUMMARY => App::make(RevenueSummaryReport::class), OrganizerReportTypes::EVENTS_PERFORMANCE => App::make(EventsPerformanceReport::class), OrganizerReportTypes::TAX_SUMMARY => App::make(TaxSummaryReport::class), OrganizerReportTypes::CHECK_IN_SUMMARY => App::make(CheckInSummaryReport::class), + OrganizerReportTypes::PLATFORM_FEES => App::make(PlatformFeesReport::class), }; } } diff --git a/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php b/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php new file mode 100644 index 000000000..1ed23edb5 --- /dev/null +++ b/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php @@ -0,0 +1,196 @@ +organizerRepository->findById($organizerId); + $timezone = $organizer->getTimezone(); + + $endDate = $endDate + ? $endDate->copy()->setTimezone($timezone)->endOfDay() + : now($timezone)->endOfDay(); + $startDate = $startDate + ? $startDate->copy()->setTimezone($timezone)->startOfDay() + : $endDate->copy()->subDays(30)->startOfDay(); + + $cacheKey = $this->getCacheKeyWithEvent($organizerId, $currency, $startDate, $endDate, $eventId, $page, $perPage); + + $total = $this->cache->remember( + key: $cacheKey . '.count', + ttl: Carbon::now()->addSeconds(self::CACHE_TTL_SECONDS), + callback: fn() => $this->getCount($organizerId, $startDate, $endDate, $currency, $eventId) + ); + + $results = $this->cache->remember( + key: $cacheKey, + ttl: Carbon::now()->addSeconds(self::CACHE_TTL_SECONDS), + callback: fn() => $this->queryBuilder->select( + $this->buildSqlQuery($startDate, $endDate, $currency, $eventId, $page, $perPage), + [ + 'organizer_id' => $organizerId, + ] + ) + ); + + $data = collect($results)->map(function ($row) { + $currencyCode = strtoupper($row->currency ?? 'USD'); + $divisor = Currency::isZeroDecimalCurrency($currencyCode) ? 1 : 100; + + return (object) [ + 'event_name' => $row->event_name, + 'event_id' => $row->event_id, + 'payment_date' => $row->payment_date, + 'order_reference' => $row->order_reference, + 'order_id' => $row->order_id, + 'amount_paid' => Currency::round(($row->amount_received ?? 0) / $divisor), + 'fee_amount' => Currency::round(($row->application_fee_net ?? 0) / $divisor), + 'vat_rate' => $row->application_fee_vat_rate ?? 0, + 'vat_amount' => Currency::round(($row->application_fee_vat ?? 0) / $divisor), + 'total_fee' => Currency::round(($row->application_fee_gross ?? 0) / $divisor), + 'currency' => $currencyCode, + 'payment_intent_id' => $row->payment_intent_id, + ]; + }); + + return new PaginatedReportDTO( + data: $data, + total: $total, + page: $page, + perPage: $perPage, + lastPage: (int) ceil($total / $perPage), + ); + } + + private function getCount(int $organizerId, Carbon $startDate, Carbon $endDate, ?string $currency, ?int $eventId): int + { + $result = $this->queryBuilder->select( + $this->buildCountQuery($startDate, $endDate, $currency, $eventId), + ['organizer_id' => $organizerId] + ); + + return (int) ($result[0]->count ?? 0); + } + + private function buildCountQuery(Carbon $startDate, Carbon $endDate, ?string $currency, ?int $eventId): string + { + $startDateStr = $startDate->toDateString(); + $endDateStr = $endDate->toDateString(); + $completedStatus = OrderStatus::COMPLETED->name; + $refundedStatus = OrderRefundStatus::REFUNDED->name; + $currencyFilter = $this->buildCurrencyFilter('sp.currency', $currency); + $eventFilter = $this->buildEventFilter($eventId); + + return << 0 + AND sp.created_at >= '$startDateStr 00:00:00' + AND sp.created_at <= '$endDateStr 23:59:59' + $currencyFilter + $eventFilter +SQL; + } + + private function buildSqlQuery(Carbon $startDate, Carbon $endDate, ?string $currency, ?int $eventId, int $page = 1, int $perPage = 1000): string + { + $startDateStr = $startDate->toDateString(); + $endDateStr = $endDate->toDateString(); + $completedStatus = OrderStatus::COMPLETED->name; + $refundedStatus = OrderRefundStatus::REFUNDED->name; + $currencyFilter = $this->buildCurrencyFilter('sp.currency', $currency); + $eventFilter = $this->buildEventFilter($eventId); + $offset = ($page - 1) * $perPage; + + return << 0 + AND sp.created_at >= '$startDateStr 00:00:00' + AND sp.created_at <= '$endDateStr 23:59:59' + $currencyFilter + $eventFilter + ORDER BY sp.created_at DESC + LIMIT $perPage OFFSET $offset +SQL; + } + + private function buildEventFilter(?int $eventId): string + { + if ($eventId === null) { + return ''; + } + return "AND e.id = $eventId"; + } + + private function getCacheKeyWithEvent(int $organizerId, ?string $currency, ?Carbon $startDate, ?Carbon $endDate, ?int $eventId, int $page, int $perPage): string + { + return static::class . "$organizerId.$currency.{$startDate?->toDateString()}.{$endDate?->toDateString()}.$eventId.$page.$perPage"; + } + + private function buildCurrencyFilter(string $column, ?string $currency): string + { + if ($currency === null) { + return ''; + } + $escapedCurrency = addslashes($currency); + return "AND $column = '$escapedCurrency'"; + } +} diff --git a/backend/database/migrations/2025_12_21_111333_backfill_stripe_payments_timestamps.php b/backend/database/migrations/2025_12_21_111333_backfill_stripe_payments_timestamps.php new file mode 100644 index 000000000..4e493d9dc --- /dev/null +++ b/backend/database/migrations/2025_12_21_111333_backfill_stripe_payments_timestamps.php @@ -0,0 +1,24 @@ +get('/organizers/{organizer_id}/settings', GetOrganizerSettingsAction::class); $router->patch('/organizers/{organizer_id}/settings', PartialUpdateOrganizerSettingsAction::class); $router->get('/organizers/{organizer_id}/reports/{report_type}', GetOrganizerReportAction::class); + $router->get('/organizers/{organizer_id}/reports/{report_type}/export', ExportOrganizerReportAction::class); // Email Templates - Organizer level $router->get('/organizers/{organizerId}/email-templates', GetOrganizerEmailTemplatesAction::class); diff --git a/frontend/src/api/organizer.client.ts b/frontend/src/api/organizer.client.ts index 2d2c7b3f3..13df90237 100644 --- a/frontend/src/api/organizer.client.ts +++ b/frontend/src/api/organizer.client.ts @@ -65,19 +65,56 @@ export const organizerClient = { reportType: string, startDate?: string | null, endDate?: string | null, - currency?: string | null + currency?: string | null, + eventId?: IdParam | null, + page?: number, + perPage?: number ) => { const params = new URLSearchParams(); if (startDate) params.append('start_date', startDate); if (endDate) params.append('end_date', endDate); if (currency) params.append('currency', currency); + if (eventId) params.append('event_id', String(eventId)); + if (page) params.append('page', String(page)); + if (perPage) params.append('per_page', String(perPage)); const queryString = params.toString() ? `?${params.toString()}` : ''; - const response = await api.get>( + const response = await api.get<{ + data: any[]; + pagination?: { + total: number; + page: number; + per_page: number; + last_page: number; + }; + }>( `organizers/${organizerId}/reports/${reportType}${queryString}` ); return response.data; }, + + exportOrganizerReport: async ( + organizerId: IdParam, + reportType: string, + startDate?: string | null, + endDate?: string | null, + currency?: string | null, + eventId?: IdParam | null + ): Promise => { + const params = new URLSearchParams(); + if (startDate) params.append('start_date', startDate); + if (endDate) params.append('end_date', endDate); + if (currency) params.append('currency', currency); + if (eventId) params.append('event_id', String(eventId)); + + const queryString = params.toString() ? `?${params.toString()}` : ''; + const response = await api.get( + `organizers/${organizerId}/reports/${reportType}/export${queryString}`, + { responseType: 'blob' } + ); + + return new Blob([response.data]); + }, } export const organizerPublicClient = { diff --git a/frontend/src/components/common/OrganizerReportTable/index.tsx b/frontend/src/components/common/OrganizerReportTable/index.tsx index 0fcab41f9..e10db9c61 100644 --- a/frontend/src/components/common/OrganizerReportTable/index.tsx +++ b/frontend/src/components/common/OrganizerReportTable/index.tsx @@ -1,19 +1,22 @@ -import {ComboboxItem, Group, Select, Skeleton, Table as MantineTable} from '@mantine/core'; +import {Button, ComboboxItem, Group, Select, Skeleton, Table as MantineTable, Text} from '@mantine/core'; import {t} from '@lingui/macro'; import {DatePickerInput} from "@mantine/dates"; -import {IconArrowDown, IconArrowsSort, IconArrowUp, IconCalendar} from "@tabler/icons-react"; +import {IconArrowDown, IconArrowsSort, IconArrowUp, IconCalendar, IconDownload} from "@tabler/icons-react"; import {useMemo, useState} from "react"; import {PageTitle} from "../PageTitle"; -import {DownloadCsvButton} from "../DownloadCsvButton"; import {Table, TableHead} from "../Table"; +import {Pagination} from "../Pagination"; import '@mantine/dates/styles.css'; import {useGetOrganizerReport} from "../../../queries/useGetOrganizerReport.ts"; import {useParams} from "react-router"; import {Organizer} from "../../../types.ts"; +import {organizerClient} from "../../../api/organizer.client.ts"; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import classes from './OrganizerReportTable.module.scss'; +import {downloadBinary} from "../../../utilites/download.ts"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; dayjs.extend(utc); dayjs.extend(timezone); @@ -39,10 +42,10 @@ interface OrganizerReportProps { defaultEndDate?: Date; onDateRangeChange?: (range: [Date | null, Date | null]) => void; enableDownload?: boolean; - downloadFileName?: string; showCustomDatePicker?: boolean; showCurrencyFilter?: boolean; availableCurrencies?: string[]; + eventId?: number | null; } const TIME_PERIODS = [ @@ -58,6 +61,8 @@ const TIME_PERIODS = [ {value: 'custom', label: t`Custom Range`} ]; +const ROWS_PER_PAGE = 1000; + const OrganizerReportTable = >({ title, columns, @@ -66,25 +71,38 @@ const OrganizerReportTable = >({ defaultEndDate = new Date(), onDateRangeChange, enableDownload = true, - downloadFileName = 'report.csv', - showCustomDatePicker = false, organizer, showCurrencyFilter = true, availableCurrencies = [], + eventId, }: OrganizerReportProps) => { - const timezone = organizer.timezone || 'UTC'; + const tz = organizer.timezone || 'UTC'; const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([ - dayjs(defaultStartDate).tz(timezone).toDate(), - dayjs(defaultEndDate).tz(timezone).toDate() + dayjs(defaultStartDate).tz(tz).toDate(), + dayjs(defaultEndDate).tz(tz).toDate() ]); const [selectedPeriod, setSelectedPeriod] = useState('90d'); - const [showDatePickerInput, setShowDatePickerInput] = useState(showCustomDatePicker); + const [showDatePickerInput, setShowDatePickerInput] = useState(false); const [sortField, setSortField] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null); const [selectedCurrency, setSelectedCurrency] = useState(null); + const [currentPage, setCurrentPage] = useState(1); const {reportType, organizerId} = useParams(); - const reportQuery = useGetOrganizerReport(organizerId, reportType || '', dateRange[0], dateRange[1], selectedCurrency); - const data = (reportQuery.data || []) as T[]; + + const reportQuery = useGetOrganizerReport( + organizerId, + reportType || '', + dateRange[0], + dateRange[1], + selectedCurrency, + eventId, + currentPage, + ROWS_PER_PAGE + ); + + const reportData = reportQuery.data; + const data = (reportData?.data || []) as T[]; + const pagination = reportData?.pagination; const calculateDateRange = (period: string): [Date | null, Date | null] => { if (period === 'custom') { @@ -93,8 +111,8 @@ const OrganizerReportTable = >({ } setShowDatePickerInput(false); - let end = dayjs().tz(timezone).endOf('day'); - let start = dayjs().tz(timezone); + let end = dayjs().tz(tz).endOf('day'); + let start = dayjs().tz(tz); switch (period) { case '24h': @@ -138,13 +156,14 @@ const OrganizerReportTable = >({ setSelectedPeriod(value); const newRange = calculateDateRange(value); setDateRange(newRange); + setCurrentPage(1); onDateRangeChange?.(newRange); }; const handleDateRangeChange = (newRange: [Date | null, Date | null]) => { const [start, end] = newRange; - const tzStart = start ? dayjs(start).tz(timezone) : null; - const tzEnd = end ? dayjs(end).tz(timezone) : null; + const tzStart = start ? dayjs(start).tz(tz) : null; + const tzEnd = end ? dayjs(end).tz(tz) : null; const tzRange: [Date | null, Date | null] = [ tzStart?.toDate() || null, @@ -152,11 +171,42 @@ const OrganizerReportTable = >({ ]; setDateRange(tzRange); + setCurrentPage(1); onDateRangeChange?.(tzRange); }; const handleCurrencyChange = (value: string | null) => { setSelectedCurrency(value === '' ? null : value); + setCurrentPage(1); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const [isExporting, setIsExporting] = useState(false); + + const handleExport = async () => { + if (!organizerId || !reportType) return; + + setIsExporting(true); + try { + const blob = await organizerClient.exportOrganizerReport( + organizerId, + reportType, + dateRange[0]?.toISOString(), + dateRange[1]?.toISOString(), + selectedCurrency, + eventId + ); + const filename = `${reportType}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.csv`; + downloadBinary(blob, filename); + showSuccess(t`Export successful`); + } catch { + showError(t`Failed to export report. Please try again.`); + } finally { + setIsExporting(false); + } }; const handleSort = (field: keyof T) => { @@ -202,14 +252,6 @@ const OrganizerReportTable = >({ }); }, [data, sortField, sortDirection]); - const csvHeaders = columns.map(col => col.label); - const csvData = sortedData.map(row => - columns.map(col => { - const value = row[col.key]; - return typeof value === 'number' ? value.toString() : value; - }) - ); - const emptyStateMessage = () => { const wrapper = (message: React.ReactNode) => ( @@ -250,6 +292,9 @@ const OrganizerReportTable = >({ ...availableCurrencies.map(curr => ({value: curr, label: curr})) ]; + const totalPages = pagination?.last_page || 1; + const totalRows = pagination?.total || 0; + return ( <> @@ -286,21 +331,32 @@ const OrganizerReportTable = >({ placeholder="Pick dates range" value={dateRange} onChange={handleDateRangeChange} - minDate={dayjs().subtract(1, 'year').tz(timezone).toDate()} - maxDate={dayjs().tz(timezone).toDate()} + minDate={dayjs().subtract(1, 'year').tz(tz).toDate()} + maxDate={dayjs().tz(tz).toDate()} className={classes.datePicker} /> )} {enableDownload && ( - } + variant="light" + onClick={handleExport} + loading={isExporting} className={classes.downloadButton} - /> + > + {t`Export CSV`} + )} + + {totalRows > 0 && ( + + {t`Showing ${sortedData.length} of ${totalRows} records`} + {totalPages > 1 && ` (${t`Page`} ${currentPage} ${t`of`} ${totalPages})`} + + )} + @@ -311,7 +367,7 @@ const OrganizerReportTable = >({ style={{cursor: column.sortable ? 'pointer' : 'default', minWidth: '180px'}} > - {t`${column.label}`} + {column.label} {column.sortable && getSortIcon(column.key)} @@ -334,6 +390,14 @@ const OrganizerReportTable = >({ ))}
+ + {totalPages > 1 && ( + + )} ); }; diff --git a/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/PlatformFeesReport.module.scss b/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/PlatformFeesReport.module.scss new file mode 100644 index 000000000..7637384e8 --- /dev/null +++ b/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/PlatformFeesReport.module.scss @@ -0,0 +1,4 @@ +.eventFilter { + margin-bottom: 1.5rem; + max-width: 300px; +} diff --git a/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/index.tsx b/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/index.tsx new file mode 100644 index 000000000..6bad53ddc --- /dev/null +++ b/frontend/src/components/routes/organizer/Reports/PlatformFeesReport/index.tsx @@ -0,0 +1,147 @@ +import {Link, useParams} from "react-router"; +import {useGetOrganizer} from "../../../../../queries/useGetOrganizer.ts"; +import {useGetOrganizerStats} from "../../../../../queries/useGetOrganizerStats.ts"; +import {useGetOrganizerEvents} from "../../../../../queries/useGetOrganizerEvents.ts"; +import {formatCurrency} from "../../../../../utilites/currency.ts"; +import OrganizerReportTable from "../../../../common/OrganizerReportTable"; +import {t} from "@lingui/macro"; +import {Alert, Select} from "@mantine/core"; +import {IconAlertTriangle} from "@tabler/icons-react"; +import {useState} from "react"; +import classes from "./PlatformFeesReport.module.scss"; + +const PlatformFeesReport = () => { + const {organizerId} = useParams(); + const organizerQuery = useGetOrganizer(organizerId); + const organizer = organizerQuery.data; + const [selectedEventId, setSelectedEventId] = useState(null); + + const statsQuery = useGetOrganizerStats(organizerId, organizer?.currency); + const allCurrencies = statsQuery.data?.all_organizers_currencies || []; + + const eventsQuery = useGetOrganizerEvents(organizerId, { + pageNumber: 1, + perPage: 100, + }); + const events = eventsQuery.data?.data || []; + + if (!organizer) { + return null; + } + + const eventOptions = [ + {value: '', label: t`All Events`}, + ...events.map(event => ({ + value: String(event.id), + label: event.title || '' + })) + ]; + + const columns = [ + { + key: 'event_name' as const, + label: t`Event`, + sortable: true, + render: (value: string, row: any) => ( + + {value} + + ) + }, + { + key: 'payment_date' as const, + label: t`Payment Date`, + sortable: true, + render: (value: string) => value ? new Date(value).toLocaleDateString() : '-' + }, + { + key: 'order_reference' as const, + label: t`Order Ref`, + sortable: false, + render: (value: string, row: any) => ( + + {value} + + ) + }, + { + key: 'amount_paid' as const, + label: t`Amount Paid`, + sortable: true, + render: (value: number, row: any) => formatCurrency(value, row.currency) + }, + { + key: 'fee_amount' as const, + label: t`Hi.Events Fee`, + sortable: true, + render: (value: number, row: any) => formatCurrency(value, row.currency) + }, + { + key: 'vat_rate' as const, + label: t`VAT Rate`, + sortable: false, + render: (value: number) => value ? `${(value * 100).toFixed(0)}%` : '-' + }, + { + key: 'vat_amount' as const, + label: t`VAT on Fee`, + sortable: true, + render: (value: number, row: any) => value ? formatCurrency(value, row.currency) : '-' + }, + { + key: 'total_fee' as const, + label: t`Total Fee`, + sortable: true, + render: (value: number, row: any) => formatCurrency(value, row.currency) + }, + { + key: 'currency' as const, + label: t`Currency`, + sortable: false + }, + { + key: 'payment_intent_id' as const, + label: t`Stripe Payment ID`, + sortable: false, + render: (value: string) => value || '-' + } + ]; + + return ( + <> + } + title={t`Important Notice`} + color="yellow" + mb="lg" + > + {t`This report is for informational purposes only. Always consult with a tax professional before using this data for accounting or tax purposes. Please cross-reference with your Stripe dashboard as Hi.Events may be missing historical data.`} + + +
+