feat: ( reports ) add regular/package booking-type filter to finance summary

This commit is contained in:
Abubeker Yasin
2026-09-03 10:30:21 +03:00
parent db97c3682f
commit d86867b2f7
6 changed files with 199 additions and 30 deletions

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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<string, FinanceBucket>();
// `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<string> {
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,

View File

@@ -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<string, string> = {
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<StationOption[]>({
@@ -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<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage }> = {};
const imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage; bookingType?: ChartImage }> = {};
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() {
<option value="international">International to/from Djibouti</option>
</select>
</div>
<div>
<label className="label">Booking Type</label>
<select className="input" value={bookingType} onChange={(e) => setBookingType(e.target.value as '' | FinanceBookingType)}>
<option value="">All bookings</option>
<option value="regular">Regular ticket sales</option>
<option value="package">Package travel packages</option>
</select>
</div>
<div>
<label className="label">Payment Method</label>
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
@@ -555,6 +593,58 @@ export default function FinanceReportPage() {
</table>
</div>
{/* Regular vs package — package revenue is a booking carrying a packageId; the
legacy standalone PackageBooking table is not counted here, as it never was. */}
<div className="card" ref={setChartRef(`${section.currency}-bookingType`)}>
<h3 className="text-base font-semibold text-foreground">
Revenue by Booking Type <span className="text-xs font-normal text-muted-foreground">({section.currency})</span>
</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">
Share of {section.currency} revenue between ordinary ticket sales and travel packages
</p>
<div
className="flex w-full h-7 rounded-md overflow-hidden"
role="img"
aria-label={`${section.currency} revenue by booking type: ${section.bookingTypeBreakdown
.map((b) => `${bookingTypeLabel(b.label)} ${b.sharePercent.toFixed(0)}%`)
.join(', ')}`}
>
{section.bookingTypeBreakdown.map((b, i) => (
<div
key={b.key}
className="h-full"
style={{ width: `${b.sharePercent}%`, background: b.color, marginRight: i < section.bookingTypeBreakdown.length - 1 ? 2 : 0 }}
title={`${bookingTypeLabel(b.label)}${formatCurrency(b.revenueMinor, b.currency)}`}
/>
))}
</div>
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Booking Type</th>
<th className="text-right font-medium py-2">Bookings</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Revenue</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{section.bookingTypeBreakdown.map((b) => (
<tr key={b.key}>
<td className="py-2">
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ background: b.color }} aria-hidden="true" />
<span className="text-foreground">{bookingTypeLabel(b.label)}</span>
</span>
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{b.bookingCount.toLocaleString()}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{b.sharePercent.toFixed(1)}%</td>
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(b.revenueMinor, b.currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Payment method breakdown — part-to-whole stacked bar + legend table */}
<div className="card" ref={setChartRef(`${section.currency}-method`)}>
<h3 className="text-base font-semibold text-foreground">
@@ -617,7 +707,7 @@ export default function FinanceReportPage() {
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{['Period', 'Segment', 'Type', 'Method', 'Currency', 'Bookings', 'Revenue'].map((h) => (
{['Period', 'Segment', 'Type', 'Booking', 'Method', 'Currency', 'Bookings', 'Revenue'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
{h}
</th>
@@ -630,6 +720,7 @@ export default function FinanceReportPage() {
<td className="px-4 py-3 whitespace-nowrap text-foreground">{periodLabel(r.period, granularity)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.segmentLabel}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{tripTypeLabel(r.tripType)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{bookingTypeLabel(r.bookingType)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{methodLabel(r.method)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.currency}</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{r.bookingCount.toLocaleString()}</td>
@@ -638,7 +729,7 @@ export default function FinanceReportPage() {
))}
{pg.paged.length === 0 && (
<tr>
<td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
<td colSpan={8} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
</tr>
)}
</tbody>

View File

@@ -9,6 +9,12 @@ export type FinanceGranularity = 'daily' | 'weekly' | 'monthly';
*/
export type FinanceTripType = 'intercity' | 'international';
/**
* Travel-package revenue vs ordinary ticket sales. `package` is a booking carrying a
* `packageId`; `regular` is one without. Unrelated to the ONE_WAY/ROUND_TRIP booking type.
*/
export type FinanceBookingType = 'regular' | 'package';
export interface FinanceSummaryFilters {
dateFrom: string;
dateTo: string;
@@ -17,6 +23,7 @@ export interface FinanceSummaryFilters {
destinationStationId?: string;
method?: string;
tripType?: FinanceTripType;
bookingType?: FinanceBookingType;
}
export interface FinanceBucketRow {
@@ -25,6 +32,7 @@ export interface FinanceBucketRow {
destinationStationId: string;
segmentLabel: string;
tripType: FinanceTripType;
bookingType: FinanceBookingType;
method: string;
currency: string;
bookingCount: number;
@@ -46,6 +54,8 @@ export interface FinanceSummaryReport {
dateTo: string;
/** The trip-type filter that was applied, or `null` when every trip is included. */
tripType: FinanceTripType | null;
/** The booking-type filter that was applied, or `null` when every booking is included. */
bookingType: FinanceBookingType | null;
/** Grand totals, one entry per currency present — never summed across currencies. */
totals: FinanceRollupRow[];
byPeriod: FinanceRollupRow[];
@@ -53,6 +63,8 @@ export interface FinanceSummaryReport {
byMethod: FinanceRollupRow[];
/** Intercity vs international split. One entry per trip type per currency. */
byTripType: FinanceRollupRow[];
/** Regular vs package split. One entry per booking type per currency. */
byBookingType: FinanceRollupRow[];
rows: FinanceBucketRow[];
}
@@ -64,6 +76,7 @@ function toParams(filters: FinanceSummaryFilters): Record<string, string> {
if (filters.destinationStationId) params.destinationStationId = filters.destinationStationId;
if (filters.method) params.method = filters.method;
if (filters.tripType) params.tripType = filters.tripType;
if (filters.bookingType) params.bookingType = filters.bookingType;
return params;
}

View File

@@ -32,12 +32,13 @@ export interface ChartImage {
export interface FinanceWorkbookInput {
report: FinanceSummaryReport;
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string; tripTypeLabel: string };
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string; tripTypeLabel: string; bookingTypeLabel: string };
methodLabel: (method: string) => string;
tripTypeLabel: (tripType: string) => string;
bookingTypeLabel: (bookingType: string) => string;
periodLabel: (period: string, granularity: FinanceGranularity) => string;
/** One Trend/Segment/Method/Trip-type image set per currency present — mirrors the on-screen per-currency sections. */
imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage }>;
/** One Trend/Segment/Method/Trip-type/Booking-type image set per currency present — mirrors the on-screen per-currency sections. */
imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage; bookingType?: ChartImage }>;
}
function styleHeaderCell(cell: ExcelJS.Cell) {
@@ -161,6 +162,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
const { report, filters, imagesByCurrency } = input;
const methodLabel = input.methodLabel;
const tripTypeLabel = input.tripTypeLabel;
const bookingTypeLabel = input.bookingTypeLabel;
const periodLabel = input.periodLabel;
const totals = [...report.totals].sort((a, b) => b.revenueMinor - a.revenueMinor);
@@ -177,7 +179,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
titleBanner(
summary,
'EDR Passenger — Finance Summary',
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Trip type: ${filters.tripTypeLabel} · Generated ${new Date().toLocaleString('en-US')}`,
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Trip type: ${filters.tripTypeLabel} · Booking type: ${filters.bookingTypeLabel} · Generated ${new Date().toLocaleString('en-US')}`,
6,
);
@@ -201,6 +203,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
cursor = addImage(wb, summary, images.trend, cursor, `Revenue Trend (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.segment, cursor, `Revenue by Segment (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.tripType, cursor, `Revenue by Trip Type (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.bookingType, cursor, `Revenue by Booking Type (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.method, cursor, `Revenue by Payment Method (${t.currency})`) + 1;
}
@@ -264,6 +267,30 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
});
byTripType.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
// ── By Booking Type sheet ────────────────────────────────────────────────
// Package = a booking carrying a packageId; regular = ordinary ticket sales. Share is
// against the same currency's grand total, never a cross-currency sum.
const byBookingType = wb.addWorksheet('By Booking Type', { views: [{ state: 'frozen', ySplit: 1 }] });
byBookingType.columns = [{ width: 26 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
addTableHeader(byBookingType, 1, ['Booking Type', 'Currency', 'Bookings', 'Revenue', 'Share'], new Set([1, 2, 3]));
const bookingTypeCurrencyTotal = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
report.byBookingType.forEach((b, i) => {
const r = byBookingType.getRow(i + 2);
const currencyTotal = bookingTypeCurrencyTotal.get(b.currency) ?? 0;
r.getCell(1).value = bookingTypeLabel(b.label);
r.getCell(2).value = b.currency;
r.getCell(3).value = b.bookingCount;
r.getCell(3).alignment = { horizontal: 'right' };
r.getCell(4).value = b.revenueMinor / 100;
r.getCell(4).numFmt = currencyFmt(b.currency);
r.getCell(4).alignment = { horizontal: 'right' };
r.getCell(5).value = currencyTotal > 0 ? b.revenueMinor / currencyTotal : 0;
r.getCell(5).numFmt = '0.0%';
r.getCell(5).alignment = { horizontal: 'right' };
bandRow(byBookingType, i + 2, 5, i % 2 === 1);
});
byBookingType.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
// ── By Method sheet ──────────────────────────────────────────────────────
// Share is computed against the grand total for that same currency (`totals`), never
// against a sum spanning multiple currencies.
@@ -290,23 +317,24 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
// ── Detail sheet — every row, unpaginated ───────────────────────────────
const detail = wb.addWorksheet('Detail', { views: [{ state: 'frozen', ySplit: 1 }] });
detail.columns = [{ width: 18 }, { width: 34 }, { width: 26 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Trip Type', 'Payment Method', 'Currency', 'Bookings', 'Revenue'], new Set([3, 4]));
detail.columns = [{ width: 18 }, { width: 34 }, { width: 26 }, { width: 16 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Trip Type', 'Booking Type', 'Payment Method', 'Currency', 'Bookings', 'Revenue'], new Set([4, 5]));
report.rows.forEach((row, i) => {
const r = detail.getRow(i + 2);
r.getCell(1).value = periodLabel(row.period, report.granularity);
r.getCell(2).value = row.segmentLabel;
r.getCell(3).value = tripTypeLabel(row.tripType);
r.getCell(4).value = methodLabel(row.method);
r.getCell(5).value = row.currency;
r.getCell(6).value = row.bookingCount;
r.getCell(6).alignment = { horizontal: 'right' };
r.getCell(7).value = row.revenueMinor / 100;
r.getCell(7).numFmt = currencyFmt(row.currency);
r.getCell(4).value = bookingTypeLabel(row.bookingType);
r.getCell(5).value = methodLabel(row.method);
r.getCell(6).value = row.currency;
r.getCell(7).value = row.bookingCount;
r.getCell(7).alignment = { horizontal: 'right' };
bandRow(detail, i + 2, 7, i % 2 === 1);
r.getCell(8).value = row.revenueMinor / 100;
r.getCell(8).numFmt = currencyFmt(row.currency);
r.getCell(8).alignment = { horizontal: 'right' };
bandRow(detail, i + 2, 8, i % 2 === 1);
});
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 7 } };
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 8 } };
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });