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 3b0bed8f4..8e92c12f4 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -135,9 +135,16 @@ export class ReportsController { "`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, booking type, and method for charting.", + "Pass `revenueType` to isolate one revenue stream: `ticket` is the booking fare, `excess_baggage` a " + + "collected luggage fee, `outstanding` a recovered underpayment, and `other` every remaining " + + "supplementary charge (upgrade, reschedule, and any reason added later). Fare revenue counts " + + "CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same definition as the dashboard and the " + + "/payments confirmed-revenue filter. Charges count as collected on their own status (PAID, plus " + + "CASH_COLLECTED for baggage) and are deliberately NOT re-checked against the booking: a fee that was " + + "collected stays collected even if the booking is cancelled afterwards, unlike the fare. Charge rows " + + "report `UNKNOWN` as their payment method because settling a charge records only a providerTxnId, and " + + "a `method` filter therefore excludes them. Returns per-bucket rows plus roll-ups by period, segment, " + + "trip type, booking type, revenue 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 a86e28933..6c824bea8 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts @@ -134,6 +134,20 @@ export enum FinanceBookingType { PACKAGE = 'package', } +/** + * Which revenue stream a row came from. `ticket` is the booking fare — all this report used to + * count. The rest are fees collected after the fare: `excess_baggage` from ExcessBaggageCharge, + * `outstanding` from a PAID SupplementaryCharge with reason UNDERPAYMENT, and `other` from every + * remaining supplementary reason (UPGRADE, RESCHEDULE, and anything added later — `reason` is a + * free-text column, so this bucket is deliberately open-ended). + */ +export enum FinanceRevenueType { + TICKET = 'ticket', + EXCESS_BAGGAGE = 'excess_baggage', + OUTSTANDING = 'outstanding', + OTHER = 'other', +} + export class FinanceSummaryQueryDto { @ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' }) @IsDateString() dateFrom: string; @@ -168,4 +182,13 @@ export class FinanceSummaryQueryDto { 'Omit for all bookings. Unrelated to the ONE_WAY/ROUND_TRIP booking type.', }) @IsOptional() @IsEnum(FinanceBookingType) bookingType?: FinanceBookingType; + + @ApiPropertyOptional({ + enum: FinanceRevenueType, + description: + 'Restrict to one revenue stream: the booking fare (ticket), excess-baggage fees ' + + '(excess_baggage), recovered underpayments (outstanding), or every other supplementary ' + + 'charge such as upgrades and reschedules (other). Omit for all revenue.', + }) + @IsOptional() @IsEnum(FinanceRevenueType) revenueType?: FinanceRevenueType; } 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 f08f67609..2af120d02 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -13,6 +13,7 @@ import { BlockedSeatsRevenueLossQueryDto, FinanceBookingType, FinanceGranularity, + FinanceRevenueType, FinanceSummaryQueryDto, FinanceTripType, GenerateReportDto, @@ -149,6 +150,25 @@ function periodKeyFor(date: Date, granularity: FinanceGranularity): string { return date.toISOString().split("T")[0]; } +/** The `SupplementaryCharge.reason` that means a passenger under-paid and later settled up. */ +const UNDERPAYMENT_REASON = "UNDERPAYMENT"; + +/** + * Payment method reported for a charge row. Settling a supplementary or excess-baggage fee + * records only a providerTxnId — the method is never stored — so charge revenue lands in one + * explicit bucket rather than being dropped from the method breakdown, which keeps that + * roll-up summing to the report total. + */ +const CHARGE_METHOD_UNKNOWN = "UNKNOWN"; + +/** The dimensions every revenue row takes off its booking, whether it is a fare or a fee. */ +interface FinanceRowBooking { + originStationId: string | null; + destinationStationId: string | null; + packageId: string | null; + schedule: { originStationId: string; destinationStationId: string }; +} + export interface FinanceBucket { period: string; originStationId: string; @@ -157,6 +177,9 @@ export interface FinanceBucket { tripType: FinanceTripType; /** regular vs package — from `Booking.packageId`, not the ONE_WAY/ROUND_TRIP column. */ bookingType: FinanceBookingType; + /** Which revenue stream this row came from — the fare, or a fee collected after it. */ + revenueType: FinanceRevenueType; + /** `UNKNOWN` on every charge row: nothing records how a supplementary or baggage fee was paid. */ method: string; currency: string; bookingCount: number; @@ -1772,49 +1795,120 @@ export class ReportsService { const dateTo = new Date(query.dateTo + "T23:59:59.999Z"); const granularity = query.granularity ?? FinanceGranularity.DAILY; - const bookings = await this.prisma.booking.findMany({ - where: { - // Same revenue definition as the dashboard's backoffice-stats and the /payments - // "confirmed revenue" filter: the booking must still be CONFIRMED/BOARDED (a booking - // that was paid and later cancelled is not revenue) and the payment itself must have - // actually succeeded, not just carry a stale paidAt. - status: { in: ["CONFIRMED", "BOARDED"] }, - paymentIntent: { - status: "SUCCEEDED", - paidAt: { gte: dateFrom, lte: dateTo }, - ...(query.method ? { method: query.method } : {}), - }, - ...(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 } - : {}), - }, + // Charge rows carry no payment method — paying a supplementary or baggage fee records only + // a providerTxnId — so a method filter can never match one, and asking for a charge stream + // means the booking query has nothing to contribute. Skip the queries the filter rules out + // rather than fetching rows that will all be discarded. + const wantsTicket = !query.revenueType || query.revenueType === FinanceRevenueType.TICKET; + const wantsCharges = !query.method && query.revenueType !== FinanceRevenueType.TICKET; + const wantsBaggage = + wantsCharges && (!query.revenueType || query.revenueType === FinanceRevenueType.EXCESS_BAGGAGE); + const wantsSupplementary = + wantsCharges && + (!query.revenueType || + query.revenueType === FinanceRevenueType.OUTSTANDING || + query.revenueType === FinanceRevenueType.OTHER); + + // The station / package filters reach a charge through its parent booking, exactly as they + // reach a fare through the booking itself. + const bookingScope = { + ...(query.originStationId ? { originStationId: query.originStationId } : {}), + ...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}), + ...(query.bookingType + ? { packageId: query.bookingType === FinanceBookingType.PACKAGE ? { not: null } : null } + : {}), + }; + // Every charge needs the same dimensions a fare row carries, and they all live on the booking. + const chargeBookingSelect = { select: { - totalMinor: true, - packageId: true, - currency: true, - displayTotalMinor: true, - displayCurrency: true, originStationId: true, destinationStationId: true, + packageId: true, schedule: { select: { originStationId: true, destinationStationId: true } }, - paymentIntent: { select: { paidAt: true, method: true } }, }, - }); + }; + + const bookings = wantsTicket + ? await this.prisma.booking.findMany({ + where: { + // Same revenue definition as the dashboard's backoffice-stats and the /payments + // "confirmed revenue" filter: the booking must still be CONFIRMED/BOARDED (a booking + // that was paid and later cancelled is not revenue) and the payment itself must have + // actually succeeded, not just carry a stale paidAt. + status: { in: ["CONFIRMED", "BOARDED"] }, + paymentIntent: { + status: "SUCCEEDED", + paidAt: { gte: dateFrom, lte: dateTo }, + ...(query.method ? { method: query.method } : {}), + }, + ...bookingScope, + }, + select: { + totalMinor: true, + packageId: true, + currency: true, + displayTotalMinor: true, + displayCurrency: true, + originStationId: true, + destinationStationId: true, + schedule: { select: { originStationId: true, destinationStationId: true } }, + paymentIntent: { select: { paidAt: true, method: true } }, + }, + }) + : []; + + // Charges are NOT re-checked against the booking's status. A fee that was collected stays + // collected even if the booking is cancelled afterwards — unlike the fare, which this report + // drops on cancellation. CASH_COLLECTED counts as settled: it is the paid test the + // excess-baggage service itself uses, and it stamps paidAt when the cash is taken. + const baggageCharges = wantsBaggage + ? await this.prisma.excessBaggageCharge.findMany({ + where: { + status: { in: ["PAID", "CASH_COLLECTED"] }, + paidAt: { gte: dateFrom, lte: dateTo }, + ...(Object.keys(bookingScope).length > 0 ? { booking: bookingScope } : {}), + }, + select: { totalMinor: true, currency: true, paidAt: true, booking: chargeBookingSelect }, + }) + : []; + + const supplementaryCharges = wantsSupplementary + ? await this.prisma.supplementaryCharge.findMany({ + where: { + status: "PAID", + paidAt: { gte: dateFrom, lte: dateTo }, + ...(query.revenueType === FinanceRevenueType.OUTSTANDING + ? { reason: UNDERPAYMENT_REASON } + : {}), + ...(query.revenueType === FinanceRevenueType.OTHER + ? { reason: { not: UNDERPAYMENT_REASON } } + : {}), + ...(Object.keys(bookingScope).length > 0 ? { booking: bookingScope } : {}), + }, + select: { + amountMinor: true, + currency: true, + paidAt: true, + reason: true, + booking: chargeBookingSelect, + }, + }) + : []; // Booking.originStationId/destinationStationId are set on every create path (guest and // authenticated booking both pass them from the DTO); the schedule's own endpoints are // only a fallback for the rare legacy row that predates those columns. const stationIds = new Set(); - for (const b of bookings) { - const origin = b.originStationId ?? b.schedule.originStationId; - const destination = b.destinationStationId ?? b.schedule.destinationStationId; + const collectStations = (row: FinanceRowBooking) => { + const origin = row.originStationId ?? row.schedule.originStationId; + const destination = row.destinationStationId ?? row.schedule.destinationStationId; if (origin) stationIds.add(origin); if (destination) stationIds.add(destination); - } + }; + for (const b of bookings) collectStations(b); + for (const c of baggageCharges) collectStations(c.booking); + for (const c of supplementaryCharges) collectStations(c.booking); + const stations = stationIds.size > 0 ? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, @@ -1825,50 +1919,94 @@ export class ReportsService { const stationCountry = new Map(stations.map((s) => [s.id, s.countryCode])); 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. `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, + + /** + * One revenue item — a paid fare or a collected fee — folded into its bucket. Every source + * takes its dimensions off the same booking shape, so they share this path instead of each + * re-deriving the segment label and the trip-type rule. + * + * `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. `bookingType` and `revenueType` are not: a package and a + * regular booking, or a baggage fee and an underpayment on the same booking, can share every + * other dimension, so both belong in the key or a mixed bucket would take whichever label + * happened to land first. + */ + const addRow = ( + booking: FinanceRowBooking, + paidAt: Date, + revenueType: FinanceRevenueType, method: string, currency: string, - ): FinanceBucket => { - const key = `${period}|${originStationId}|${destinationStationId}|${bookingType}|${method}|${currency}`; + amountMinor: number, + ) => { + const originStationId = booking.originStationId ?? booking.schedule.originStationId ?? "UNKNOWN"; + const destinationStationId = + booking.destinationStationId ?? booking.schedule.destinationStationId ?? "UNKNOWN"; + + // Filtered here rather than pushed into the Prisma `where`: the endpoints that decide the + // trip type are `booking.originStationId ?? schedule.originStationId`, and a + // `{ in: ethiopianStationIds }` clause would mis-bucket any legacy row whose booking-level + // station columns are null. + const tripType = tripTypeFor( + stationCountry.get(originStationId), + stationCountry.get(destinationStationId), + ); + if (query.tripType && tripType !== query.tripType) return; + + const period = periodKeyFor(paidAt, granularity); + const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`; + const bookingType = booking.packageId ? FinanceBookingType.PACKAGE : FinanceBookingType.REGULAR; + const key = `${period}|${originStationId}|${destinationStationId}|${bookingType}|${revenueType}|${method}|${currency}`; + let bucket = buckets.get(key); if (!bucket) { - bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, bookingType, method, currency, bookingCount: 0, revenueMinor: 0 }; + bucket = { + period, + originStationId, + destinationStationId, + segmentLabel, + tripType, + bookingType, + revenueType, + method, + currency, + bookingCount: 0, + revenueMinor: 0, + }; buckets.set(key, bucket); } - return bucket; + bucket.bookingCount += 1; + bucket.revenueMinor += amountMinor; }; for (const b of bookings) { const pi = b.paymentIntent!; - const period = periodKeyFor(pi.paidAt!, granularity); - const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN"; - const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN"; - const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`; + addRow( + b, + pi.paidAt!, + FinanceRevenueType.TICKET, + pi.method, + (b.displayCurrency as string | null) ?? b.currency, + b.displayTotalMinor ?? b.totalMinor, + ); + } - // Filtered here rather than pushed into the Prisma `where`: the endpoints that decide - // the trip type are `booking.originStationId ?? schedule.originStationId`, and a - // `{ in: ethiopianStationIds }` clause would mis-bucket any legacy row whose - // booking-level station columns are null. - const tripType = tripTypeFor(stationCountry.get(originStationId), stationCountry.get(destinationStationId)); - if (query.tripType && tripType !== query.tripType) continue; + for (const c of baggageCharges) { + addRow( + c.booking, + c.paidAt!, + FinanceRevenueType.EXCESS_BAGGAGE, + CHARGE_METHOD_UNKNOWN, + c.currency, + c.totalMinor, + ); + } - 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, bookingType, pi.method, currency); - bucket.bookingCount += 1; - bucket.revenueMinor += amountMinor; + for (const c of supplementaryCharges) { + const revenueType = + c.reason === UNDERPAYMENT_REASON ? FinanceRevenueType.OUTSTANDING : FinanceRevenueType.OTHER; + addRow(c.booking, c.paidAt!, revenueType, CHARGE_METHOD_UNKNOWN, c.currency, c.amountMinor); } const rows = [...buckets.values()].sort((a, b) => @@ -1902,12 +2040,14 @@ export class ReportsService { dateTo: query.dateTo, tripType: query.tripType ?? null, bookingType: query.bookingType ?? null, + revenueType: query.revenueType ?? 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), + byRevenueType: rollUp((r) => `${r.revenueType}|${r.currency}`, (r) => r.revenueType), rows, }; } @@ -1916,12 +2056,13 @@ export class ReportsService { async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise { const report = await this.getFinanceSummary(query); - const headers = ["Period", "Origin → Destination", "Trip Type", "Booking Type", "Payment Method", "Currency", "Bookings", "Revenue"]; + const headers = ["Period", "Origin → Destination", "Trip Type", "Booking Type", "Revenue Type", "Payment Method", "Currency", "Items", "Revenue"]; const rows = report.rows.map((r) => [ r.period, r.segmentLabel, r.tripType, r.bookingType, + r.revenueType, 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 2d03e69bf..b6e2ea714 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 FinanceBookingType, type FinanceGranularity, type FinanceSummaryFilters, type FinanceTripType } from '@/lib/api/finance'; +import { financeApi, type FinanceBookingType, type FinanceGranularity, type FinanceRevenueType, 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'; @@ -64,6 +64,18 @@ function bookingTypeLabel(bookingType: string): string { return BOOKING_TYPE_LABELS[bookingType] ?? bookingType; } +// The fare, and the fees collected after it. Fixed order so each keeps its colour when filtered. +const REVENUE_TYPE_ORDER = ['ticket', 'excess_baggage', 'outstanding', 'other'] as const; +const REVENUE_TYPE_LABELS: Record = { + ticket: 'Ticket fare', + excess_baggage: 'Excess baggage', + outstanding: 'Outstanding', + other: 'Other charges', +}; +function revenueTypeLabel(revenueType: string): string { + return REVENUE_TYPE_LABELS[revenueType] ?? revenueType; +} + function periodLabel(period: string, granularity: FinanceGranularity): string { if (granularity === 'monthly') { return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); @@ -136,6 +148,7 @@ export default function FinanceReportPage() { const [method, setMethod] = useState(''); const [tripType, setTripType] = useState<'' | FinanceTripType>(''); const [bookingType, setBookingType] = useState<'' | FinanceBookingType>(''); + const [revenueType, setRevenueType] = useState<'' | FinanceRevenueType>(''); const [exporting, setExporting] = useState(false); // Chart cards are captured for the Excel export. There's one Trend/Segment/Method set per @@ -181,8 +194,9 @@ export default function FinanceReportPage() { method: method || undefined, tripType: tripType || undefined, bookingType: bookingType || undefined, + revenueType: revenueType || undefined, }), - [dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType, bookingType], + [dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType, bookingType, revenueType], ); const { data: stations = [] } = useQuery({ @@ -209,6 +223,7 @@ export default function FinanceReportPage() { setMethod(''); setTripType(''); setBookingType(''); + setRevenueType(''); }; /** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */ @@ -223,17 +238,18 @@ 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, bookingTypeImg] = await Promise.all([ + const [trend, segment, methodImg, tripTypeImg, bookingTypeImg, revenueTypeImg] = 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`]), + captureCard(chartRefs.current[`${section.currency}-revenueType`]), ]); - imagesByCurrency[section.currency] = { trend, segment, method: methodImg, tripType: tripTypeImg, bookingType: bookingTypeImg }; + imagesByCurrency[section.currency] = { trend, segment, method: methodImg, tripType: tripTypeImg, bookingType: bookingTypeImg, revenueType: revenueTypeImg }; }), ); @@ -250,10 +266,12 @@ export default function FinanceReportPage() { methodLabel: method ? methodLabel(method) : 'All', tripTypeLabel: tripType ? tripTypeLabel(tripType) : 'All', bookingTypeLabel: bookingType ? bookingTypeLabel(bookingType) : 'All', + revenueTypeLabel: revenueType ? revenueTypeLabel(revenueType) : 'All', }, methodLabel, tripTypeLabel, bookingTypeLabel, + revenueTypeLabel, periodLabel, imagesByCurrency, }); @@ -273,8 +291,10 @@ export default function FinanceReportPage() { () => (data?.totals ?? []).slice().sort((a, b) => b.revenueMinor - a.revenueMinor), [data], ); - const totalBookings = totals.reduce((sum, t) => sum + t.bookingCount, 0); - const hasData = totalBookings > 0; + // Every revenue item, not every booking: a collected baggage fee or underpayment counts here + // too, so this is deliberately no longer labelled "Bookings" in the UI. + const totalItems = totals.reduce((sum, t) => sum + t.bookingCount, 0); + const hasData = totalItems > 0; // Money is never comparable across currencies, so rather than scoping every chart to // whichever currency happens to be biggest overall (which would silently drop a @@ -333,6 +353,18 @@ export default function FinanceReportPage() { sharePercent: bookingTypeTotal > 0 ? (r.revenueMinor / bookingTypeTotal) * 100 : 0, })); + // Fare vs the fees collected after it, same share rule as the other breakdown cards. + const revenueTypeRows = data.byRevenueType.filter((r) => r.currency === t.currency); + const revenueTypeTotal = revenueTypeRows.reduce((sum, r) => sum + r.revenueMinor, 0); + const revenueTypeBreakdown = revenueTypeRows + .slice() + .sort((a, b) => REVENUE_TYPE_ORDER.indexOf(a.label as any) - REVENUE_TYPE_ORDER.indexOf(b.label as any)) + .map((r) => ({ + ...r, + color: categoricalColor(palette, REVENUE_TYPE_ORDER.indexOf(r.label as any)), + sharePercent: revenueTypeTotal > 0 ? (r.revenueMinor / revenueTypeTotal) * 100 : 0, + })); + return { currency: t.currency, revenueMinor: t.revenueMinor, @@ -342,6 +374,7 @@ export default function FinanceReportPage() { methodBreakdown, tripTypeBreakdown, bookingTypeBreakdown, + revenueTypeBreakdown, }; }); }, [data, totals, palette]); @@ -426,6 +459,16 @@ export default function FinanceReportPage() { +
+ + +