From d86867b2f7cfd0f28a54aa0c611d1113343bf830 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 3 Sep 2026 10:30:21 +0300 Subject: [PATCH 1/3] feat: ( reports ) add regular/package booking-type filter to finance summary --- .../src/modules/reports/reports.controller.ts | 11 +- .../src/modules/reports/reports.dto.ts | 18 +++ .../src/modules/reports/reports.service.ts | 26 ++++- .../src/app/reports/finance/page.tsx | 105 ++++++++++++++++-- .../backoffice/src/lib/api/finance.ts | 13 +++ .../src/lib/export/finance-workbook.ts | 56 +++++++--- 6 files changed, 199 insertions(+), 30 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index eba60ec07..3b0bed8f4 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -122,7 +122,7 @@ export class ReportsController { @Get("finance") @ApiOperation({ - summary: "Finance summary — revenue by period, origin/destination segment, trip type, payment method, and currency", + summary: "Finance summary — revenue by period, origin/destination segment, trip type, booking type, payment method, and currency", description: "Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " + "destination station pair, payment method, and currency. Amounts are never converted to ETB — a " + @@ -131,10 +131,13 @@ export class ReportsController { "destinationStationId independently to query any station-pair segment (A→B, A→D, B→C), not just a " + "whole predefined route. Pass `tripType` to split domestic from cross-border traffic: a trip is " + "`intercity` only when both endpoints sit in Ethiopia, and `international` as soon as either endpoint " + - "is outside it — so Sebeta → Nagad, Nagad → Sebeta and Alisabieh → Nagad are all international. Only " + - "counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same revenue definition as the " + + "is outside it — so Sebeta → Nagad, Nagad → Sebeta and Alisabieh → Nagad are all international. Pass " + + "`bookingType` to separate travel-package revenue from ordinary ticket sales: `package` is a booking " + + "carrying a `packageId`, `regular` is one without — unrelated to the ONE_WAY/ROUND_TRIP booking type, " + + "and not counting the legacy standalone `PackageBooking` table, which this report has never included. " + + "Only counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same revenue definition as the " + "dashboard and /payments confirmed-revenue filter. Returns per-bucket rows plus roll-ups by period, " + - "segment, trip type, and method for charting.", + "segment, trip type, booking type, and method for charting.", }) getFinanceSummary(@Query() query: FinanceSummaryQueryDto) { return this.service.getFinanceSummary(query); diff --git a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts index bddc84ee8..a86e28933 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts @@ -124,6 +124,16 @@ export enum FinanceTripType { INTERNATIONAL = 'international', } +/** + * Travel-package revenue vs ordinary ticket sales, derived from `Booking.packageId`. + * NOT the `Booking.bookingType` column, which holds ONE_WAY / ROUND_TRIP — every package + * booking happens to be ROUND_TRIP, but that is a different question from this one. + */ +export enum FinanceBookingType { + REGULAR = 'regular', + PACKAGE = 'package', +} + export class FinanceSummaryQueryDto { @ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' }) @IsDateString() dateFrom: string; @@ -150,4 +160,12 @@ export class FinanceSummaryQueryDto { 'Ethiopia (international — in practice Djibouti). Omit for all trips.', }) @IsOptional() @IsEnum(FinanceTripType) tripType?: FinanceTripType; + + @ApiPropertyOptional({ + enum: FinanceBookingType, + description: + 'Restrict to ordinary ticket sales (regular) or travel-package bookings (package). ' + + 'Omit for all bookings. Unrelated to the ONE_WAY/ROUND_TRIP booking type.', + }) + @IsOptional() @IsEnum(FinanceBookingType) bookingType?: FinanceBookingType; } diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index a354122c3..f08f67609 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -11,6 +11,7 @@ import { FareEngineService } from "../fare-engine/fare-engine.service"; import { BlockedSeatsLossSortBy, BlockedSeatsRevenueLossQueryDto, + FinanceBookingType, FinanceGranularity, FinanceSummaryQueryDto, FinanceTripType, @@ -154,6 +155,8 @@ export interface FinanceBucket { destinationStationId: string; segmentLabel: string; tripType: FinanceTripType; + /** regular vs package — from `Booking.packageId`, not the ONE_WAY/ROUND_TRIP column. */ + bookingType: FinanceBookingType; method: string; currency: string; bookingCount: number; @@ -1783,9 +1786,15 @@ export class ReportsService { }, ...(query.originStationId ? { originStationId: query.originStationId } : {}), ...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}), + // Unlike tripType, this one pushes down: `packageId` sits on the booking row itself, + // so there is no schedule fallback to preserve and the database can do the filtering. + ...(query.bookingType + ? { packageId: query.bookingType === FinanceBookingType.PACKAGE ? { not: null } : null } + : {}), }, select: { totalMinor: true, + packageId: true, currency: true, displayTotalMinor: true, displayCurrency: true, @@ -1818,20 +1827,23 @@ export class ReportsService { const buckets = new Map(); // `tripType` is a pure function of the station pair, so it never splits a bucket that the // origin/destination part of the key hasn't split already — it rides along on the bucket - // rather than joining the key. + // rather than joining the key. `bookingType` is not: a package and a regular booking can + // share the same period, segment, method and currency, so it has to be part of the key or + // a mixed bucket would take whichever label happened to land first. const bucketFor = ( period: string, originStationId: string, destinationStationId: string, segmentLabel: string, tripType: FinanceTripType, + bookingType: FinanceBookingType, method: string, currency: string, ): FinanceBucket => { - const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`; + const key = `${period}|${originStationId}|${destinationStationId}|${bookingType}|${method}|${currency}`; let bucket = buckets.get(key); if (!bucket) { - bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, method, currency, bookingCount: 0, revenueMinor: 0 }; + bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, bookingType, method, currency, bookingCount: 0, revenueMinor: 0 }; buckets.set(key, bucket); } return bucket; @@ -1851,9 +1863,10 @@ export class ReportsService { const tripType = tripTypeFor(stationCountry.get(originStationId), stationCountry.get(destinationStationId)); if (query.tripType && tripType !== query.tripType) continue; + const bookingType = b.packageId ? FinanceBookingType.PACKAGE : FinanceBookingType.REGULAR; const currency = (b.displayCurrency as string | null) ?? b.currency; const amountMinor = b.displayTotalMinor ?? b.totalMinor; - const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, pi.method, currency); + const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, bookingType, pi.method, currency); bucket.bookingCount += 1; bucket.revenueMinor += amountMinor; } @@ -1888,11 +1901,13 @@ export class ReportsService { dateFrom: query.dateFrom, dateTo: query.dateTo, tripType: query.tripType ?? null, + bookingType: query.bookingType ?? null, totals, byPeriod: rollUp((r) => `${r.period}|${r.currency}`, (r) => r.period), bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}|${r.currency}`, (r) => r.segmentLabel), byMethod: rollUp((r) => `${r.method}|${r.currency}`, (r) => r.method), byTripType: rollUp((r) => `${r.tripType}|${r.currency}`, (r) => r.tripType), + byBookingType: rollUp((r) => `${r.bookingType}|${r.currency}`, (r) => r.bookingType), rows, }; } @@ -1901,11 +1916,12 @@ export class ReportsService { async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise { const report = await this.getFinanceSummary(query); - const headers = ["Period", "Origin → Destination", "Trip Type", "Payment Method", "Currency", "Bookings", "Revenue"]; + const headers = ["Period", "Origin → Destination", "Trip Type", "Booking Type", "Payment Method", "Currency", "Bookings", "Revenue"]; const rows = report.rows.map((r) => [ r.period, r.segmentLabel, r.tripType, + r.bookingType, r.method, r.currency, r.bookingCount, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx index c9ad70e1f..f366a1ffa 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx @@ -9,7 +9,7 @@ import { } from 'recharts'; import { toPng } from 'html-to-image'; import { apiClient } from '@/lib/api-client'; -import { financeApi, type FinanceGranularity, type FinanceSummaryFilters, type FinanceTripType } from '@/lib/api/finance'; +import { financeApi, type FinanceBookingType, type FinanceGranularity, type FinanceSummaryFilters, type FinanceTripType } from '@/lib/api/finance'; import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook'; import ActionButton from '@/components/ui/ActionButton'; import Pagination from '@/components/ui/Pagination'; @@ -53,6 +53,17 @@ function tripTypeLabel(tripType: string): string { return TRIP_TYPE_LABELS[tripType] ?? tripType; } +// Package = a booking carrying a packageId; regular = ordinary ticket sales. Not the +// ONE_WAY/ROUND_TRIP booking type. Fixed order so each keeps its colour when filtered. +const BOOKING_TYPE_ORDER = ['regular', 'package'] as const; +const BOOKING_TYPE_LABELS: Record = { + regular: 'Regular', + package: 'Package', +}; +function bookingTypeLabel(bookingType: string): string { + return BOOKING_TYPE_LABELS[bookingType] ?? bookingType; +} + function periodLabel(period: string, granularity: FinanceGranularity): string { if (granularity === 'monthly') { return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); @@ -124,6 +135,7 @@ export default function FinanceReportPage() { const [destinationStationId, setDestinationStationId] = useState(''); const [method, setMethod] = useState(''); const [tripType, setTripType] = useState<'' | FinanceTripType>(''); + const [bookingType, setBookingType] = useState<'' | FinanceBookingType>(''); const [exporting, setExporting] = useState(false); // Chart cards are captured for the Excel export. There's one Trend/Segment/Method set per @@ -168,8 +180,9 @@ export default function FinanceReportPage() { destinationStationId: destinationStationId || undefined, method: method || undefined, tripType: tripType || undefined, + bookingType: bookingType || undefined, }), - [dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType], + [dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType, bookingType], ); const { data: stations = [] } = useQuery({ @@ -195,6 +208,7 @@ export default function FinanceReportPage() { setDestinationStationId(''); setMethod(''); setTripType(''); + setBookingType(''); }; /** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */ @@ -209,16 +223,17 @@ export default function FinanceReportPage() { if (!data) return; setExporting(true); try { - const imagesByCurrency: Record = {}; + const imagesByCurrency: Record = {}; await Promise.all( currencySections.map(async (section) => { - const [trend, segment, methodImg, tripTypeImg] = await Promise.all([ + const [trend, segment, methodImg, tripTypeImg, bookingTypeImg] = await Promise.all([ captureCard(chartRefs.current[`${section.currency}-trend`]), captureCard(chartRefs.current[`${section.currency}-segment`]), captureCard(chartRefs.current[`${section.currency}-method`]), captureCard(chartRefs.current[`${section.currency}-tripType`]), + captureCard(chartRefs.current[`${section.currency}-bookingType`]), ]); - imagesByCurrency[section.currency] = { trend, segment, method: methodImg, tripType: tripTypeImg }; + imagesByCurrency[section.currency] = { trend, segment, method: methodImg, tripType: tripTypeImg, bookingType: bookingTypeImg }; }), ); @@ -234,9 +249,11 @@ export default function FinanceReportPage() { destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any', methodLabel: method ? methodLabel(method) : 'All', tripTypeLabel: tripType ? tripTypeLabel(tripType) : 'All', + bookingTypeLabel: bookingType ? bookingTypeLabel(bookingType) : 'All', }, methodLabel, tripTypeLabel, + bookingTypeLabel, periodLabel, imagesByCurrency, }); @@ -302,6 +319,18 @@ export default function FinanceReportPage() { sharePercent: tripTypeTotal > 0 ? (r.revenueMinor / tripTypeTotal) * 100 : 0, })); + // Regular vs package for this currency, same share rule as the trip-type card. + const bookingTypeRows = data.byBookingType.filter((r) => r.currency === t.currency); + const bookingTypeTotal = bookingTypeRows.reduce((sum, r) => sum + r.revenueMinor, 0); + const bookingTypeBreakdown = bookingTypeRows + .slice() + .sort((a, b) => BOOKING_TYPE_ORDER.indexOf(a.label as any) - BOOKING_TYPE_ORDER.indexOf(b.label as any)) + .map((r) => ({ + ...r, + color: categoricalColor(palette, BOOKING_TYPE_ORDER.indexOf(r.label as any)), + sharePercent: bookingTypeTotal > 0 ? (r.revenueMinor / bookingTypeTotal) * 100 : 0, + })); + return { currency: t.currency, revenueMinor: t.revenueMinor, @@ -310,6 +339,7 @@ export default function FinanceReportPage() { segmentData, methodBreakdown, tripTypeBreakdown, + bookingTypeBreakdown, }; }); }, [data, totals, palette]); @@ -386,6 +416,14 @@ export default function FinanceReportPage() { +
+ + +
+
+ + +