mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 17:43:39 +00:00
@@ -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,20 @@ 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 " +
|
||||
"dashboard and /payments confirmed-revenue filter. Returns per-bucket rows plus roll-ups by period, " +
|
||||
"segment, trip type, and method for charting.",
|
||||
"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. " +
|
||||
"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);
|
||||
|
||||
@@ -124,6 +124,30 @@ 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',
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -150,4 +174,21 @@ 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;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import {
|
||||
BlockedSeatsLossSortBy,
|
||||
BlockedSeatsRevenueLossQueryDto,
|
||||
FinanceBookingType,
|
||||
FinanceGranularity,
|
||||
FinanceRevenueType,
|
||||
FinanceSummaryQueryDto,
|
||||
FinanceTripType,
|
||||
GenerateReportDto,
|
||||
@@ -148,12 +150,36 @@ 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;
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
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;
|
||||
@@ -1769,43 +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 } : {}),
|
||||
},
|
||||
// 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,
|
||||
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<string>();
|
||||
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] } },
|
||||
@@ -1816,46 +1919,94 @@ export class ReportsService {
|
||||
const stationCountry = new Map(stations.map((s) => [s.id, s.countryCode]));
|
||||
|
||||
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.
|
||||
const bucketFor = (
|
||||
period: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
segmentLabel: string,
|
||||
tripType: FinanceTripType,
|
||||
|
||||
/**
|
||||
* 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}|${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, 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 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);
|
||||
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) =>
|
||||
@@ -1888,11 +2039,15 @@ export class ReportsService {
|
||||
dateFrom: query.dateFrom,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1901,11 +2056,13 @@ 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", "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,
|
||||
|
||||
@@ -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 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';
|
||||
@@ -53,6 +53,29 @@ 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;
|
||||
}
|
||||
|
||||
// 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<string, string> = {
|
||||
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' });
|
||||
@@ -124,6 +147,8 @@ export default function FinanceReportPage() {
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
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
|
||||
@@ -168,8 +193,10 @@ export default function FinanceReportPage() {
|
||||
destinationStationId: destinationStationId || undefined,
|
||||
method: method || undefined,
|
||||
tripType: tripType || undefined,
|
||||
bookingType: bookingType || undefined,
|
||||
revenueType: revenueType || undefined,
|
||||
}),
|
||||
[dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType],
|
||||
[dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType, bookingType, revenueType],
|
||||
);
|
||||
|
||||
const { data: stations = [] } = useQuery<StationOption[]>({
|
||||
@@ -195,6 +222,8 @@ export default function FinanceReportPage() {
|
||||
setDestinationStationId('');
|
||||
setMethod('');
|
||||
setTripType('');
|
||||
setBookingType('');
|
||||
setRevenueType('');
|
||||
};
|
||||
|
||||
/** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */
|
||||
@@ -209,16 +238,18 @@ 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; revenueType?: ChartImage }> = {};
|
||||
await Promise.all(
|
||||
currencySections.map(async (section) => {
|
||||
const [trend, segment, methodImg, tripTypeImg] = 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 };
|
||||
imagesByCurrency[section.currency] = { trend, segment, method: methodImg, tripType: tripTypeImg, bookingType: bookingTypeImg, revenueType: revenueTypeImg };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -234,9 +265,13 @@ export default function FinanceReportPage() {
|
||||
destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any',
|
||||
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,
|
||||
});
|
||||
@@ -256,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
|
||||
@@ -276,7 +313,9 @@ export default function FinanceReportPage() {
|
||||
.filter((r) => r.currency === t.currency)
|
||||
.slice()
|
||||
.sort((a, b) => b.revenueMinor - a.revenueMinor)
|
||||
.map((r) => ({ label: r.label, revenue: r.revenueMinor }));
|
||||
// Minor units → major, same as trendData above. Plotting revenueMinor raw made every
|
||||
// segment bar read 100× its real value (an ETB 10.9M segment charted as 1.09 billion).
|
||||
.map((r) => ({ label: r.label, revenue: r.revenueMinor / 100 }));
|
||||
|
||||
const methodRows = data.byMethod.filter((r) => r.currency === t.currency);
|
||||
const methodTotal = methodRows.reduce((sum, r) => sum + r.revenueMinor, 0);
|
||||
@@ -302,6 +341,30 @@ 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,
|
||||
}));
|
||||
|
||||
// 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,
|
||||
@@ -310,6 +373,8 @@ export default function FinanceReportPage() {
|
||||
segmentData,
|
||||
methodBreakdown,
|
||||
tripTypeBreakdown,
|
||||
bookingTypeBreakdown,
|
||||
revenueTypeBreakdown,
|
||||
};
|
||||
});
|
||||
}, [data, totals, palette]);
|
||||
@@ -386,6 +451,24 @@ 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">Revenue Type</label>
|
||||
<select className="input" value={revenueType} onChange={(e) => setRevenueType(e.target.value as '' | FinanceRevenueType)}>
|
||||
<option value="">All revenue</option>
|
||||
<option value="ticket">Ticket fare</option>
|
||||
<option value="excess_baggage">Excess baggage — luggage</option>
|
||||
<option value="outstanding">Outstanding — underpayments</option>
|
||||
<option value="other">Other charges — upgrade, reschedule</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment Method</label>
|
||||
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
@@ -410,7 +493,7 @@ export default function FinanceReportPage() {
|
||||
) : !hasData ? (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Banknote className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No paid bookings in this window.</p>
|
||||
<p>No revenue collected in this window.</p>
|
||||
<p className="text-xs mt-1">Widen the date range, or clear the origin/destination/method filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -430,19 +513,19 @@ export default function FinanceReportPage() {
|
||||
<span className="text-sm font-medium">{t.currency}</span>
|
||||
<span className="text-right">
|
||||
<span className="text-sm font-semibold tabular-nums">{formatCurrency(t.revenueMinor, t.currency)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{t.bookingCount.toLocaleString()} bookings</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{t.bookingCount.toLocaleString()} items</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bookings</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue items</p>
|
||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{totalBookings.toLocaleString()}</p>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{totalItems.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
|
||||
Across {totals.length} currenc{totals.length === 1 ? 'y' : 'ies'}
|
||||
</p>
|
||||
@@ -458,7 +541,7 @@ export default function FinanceReportPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">{section.currency}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatCurrency(section.revenueMinor, section.currency)} · {section.bookingCount.toLocaleString()} bookings
|
||||
{formatCurrency(section.revenueMinor, section.currency)} · {section.bookingCount.toLocaleString()} items
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -532,7 +615,7 @@ export default function FinanceReportPage() {
|
||||
<thead>
|
||||
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium py-2">Trip Type</th>
|
||||
<th className="text-right font-medium py-2">Bookings</th>
|
||||
<th className="text-right font-medium py-2">Items</th>
|
||||
<th className="text-right font-medium py-2">Share</th>
|
||||
<th className="text-right font-medium py-2">Revenue</th>
|
||||
</tr>
|
||||
@@ -555,6 +638,110 @@ 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">Items</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>
|
||||
|
||||
{/* Ticket fare vs the fees collected after it. Charge revenue is new to this
|
||||
report — it was invisible here until the revenue-type work. */}
|
||||
<div className="card" ref={setChartRef(`${section.currency}-revenueType`)}>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Revenue by Revenue 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 the ticket fare and the fees collected after it
|
||||
</p>
|
||||
<div
|
||||
className="flex w-full h-7 rounded-md overflow-hidden"
|
||||
role="img"
|
||||
aria-label={`${section.currency} revenue by revenue type: ${section.revenueTypeBreakdown
|
||||
.map((r) => `${revenueTypeLabel(r.label)} ${r.sharePercent.toFixed(0)}%`)
|
||||
.join(', ')}`}
|
||||
>
|
||||
{section.revenueTypeBreakdown.map((r, i) => (
|
||||
<div
|
||||
key={r.key}
|
||||
className="h-full"
|
||||
style={{ width: `${r.sharePercent}%`, background: r.color, marginRight: i < section.revenueTypeBreakdown.length - 1 ? 2 : 0 }}
|
||||
title={`${revenueTypeLabel(r.label)} — ${formatCurrency(r.revenueMinor, r.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">Revenue Type</th>
|
||||
<th className="text-right font-medium py-2">Items</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.revenueTypeBreakdown.map((r) => (
|
||||
<tr key={r.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: r.color }} aria-hidden="true" />
|
||||
<span className="text-foreground">{revenueTypeLabel(r.label)}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{r.bookingCount.toLocaleString()}</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{r.sharePercent.toFixed(1)}%</td>
|
||||
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(r.revenueMinor, r.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">
|
||||
@@ -581,7 +768,7 @@ export default function FinanceReportPage() {
|
||||
<thead>
|
||||
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium py-2">Method</th>
|
||||
<th className="text-right font-medium py-2">Bookings</th>
|
||||
<th className="text-right font-medium py-2">Items</th>
|
||||
<th className="text-right font-medium py-2">Share</th>
|
||||
<th className="text-right font-medium py-2">Revenue</th>
|
||||
</tr>
|
||||
@@ -617,7 +804,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', 'Revenue Type', 'Method', 'Currency', 'Items', '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 +817,8 @@ 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">{revenueTypeLabel(r.revenueType)}</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 +827,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={9} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -9,6 +9,19 @@ 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';
|
||||
|
||||
/**
|
||||
* Which revenue stream a row came from. `ticket` is the booking fare; the rest are fees
|
||||
* collected after it — an excess-baggage charge, a recovered underpayment, or any other
|
||||
* supplementary charge (upgrade, reschedule, and anything added later).
|
||||
*/
|
||||
export type FinanceRevenueType = 'ticket' | 'excess_baggage' | 'outstanding' | 'other';
|
||||
|
||||
export interface FinanceSummaryFilters {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
@@ -17,6 +30,8 @@ export interface FinanceSummaryFilters {
|
||||
destinationStationId?: string;
|
||||
method?: string;
|
||||
tripType?: FinanceTripType;
|
||||
bookingType?: FinanceBookingType;
|
||||
revenueType?: FinanceRevenueType;
|
||||
}
|
||||
|
||||
export interface FinanceBucketRow {
|
||||
@@ -25,6 +40,9 @@ export interface FinanceBucketRow {
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
tripType: FinanceTripType;
|
||||
bookingType: FinanceBookingType;
|
||||
revenueType: FinanceRevenueType;
|
||||
/** `UNKNOWN` on charge rows — settling a charge records no payment method. */
|
||||
method: string;
|
||||
currency: string;
|
||||
bookingCount: number;
|
||||
@@ -46,6 +64,10 @@ 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;
|
||||
/** The revenue-type filter that was applied, or `null` when every stream is included. */
|
||||
revenueType: FinanceRevenueType | null;
|
||||
/** Grand totals, one entry per currency present — never summed across currencies. */
|
||||
totals: FinanceRollupRow[];
|
||||
byPeriod: FinanceRollupRow[];
|
||||
@@ -53,6 +75,10 @@ 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[];
|
||||
/** Fare vs baggage/outstanding/other split. One entry per revenue type per currency. */
|
||||
byRevenueType: FinanceRollupRow[];
|
||||
rows: FinanceBucketRow[];
|
||||
}
|
||||
|
||||
@@ -64,6 +90,8 @@ 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;
|
||||
if (filters.revenueType) params.revenueType = filters.revenueType;
|
||||
return params;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,14 @@ 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; revenueTypeLabel: string };
|
||||
methodLabel: (method: string) => string;
|
||||
tripTypeLabel: (tripType: string) => string;
|
||||
bookingTypeLabel: (bookingType: string) => string;
|
||||
revenueTypeLabel: (revenueType: 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 chart 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; revenueType?: ChartImage }>;
|
||||
}
|
||||
|
||||
function styleHeaderCell(cell: ExcelJS.Cell) {
|
||||
@@ -161,10 +163,13 @@ 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 revenueTypeLabel = input.revenueTypeLabel;
|
||||
const periodLabel = input.periodLabel;
|
||||
|
||||
const totals = [...report.totals].sort((a, b) => b.revenueMinor - a.revenueMinor);
|
||||
const totalBookings = totals.reduce((sum, t) => sum + t.bookingCount, 0);
|
||||
// Every revenue item, not every booking — a collected baggage fee or underpayment counts too.
|
||||
const totalItems = totals.reduce((sum, t) => sum + t.bookingCount, 0);
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'EDR Passenger Backoffice';
|
||||
@@ -177,7 +182,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} · Revenue type: ${filters.revenueTypeLabel} · Generated ${new Date().toLocaleString('en-US')}`,
|
||||
6,
|
||||
);
|
||||
|
||||
@@ -189,7 +194,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
|
||||
accent: BRAND_DARK,
|
||||
}));
|
||||
const cursorAfterKpis = kpiRow(summary, 4, [
|
||||
{ label: 'Bookings', value: totalBookings.toLocaleString('en-US'), accent: INK },
|
||||
{ label: 'Revenue items', value: totalItems.toLocaleString('en-US'), accent: INK },
|
||||
...revenueCards,
|
||||
]);
|
||||
|
||||
@@ -201,13 +206,15 @@ 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.revenueType, cursor, `Revenue by Revenue Type (${t.currency})`) + 1;
|
||||
cursor = addImage(wb, summary, images.method, cursor, `Revenue by Payment Method (${t.currency})`) + 1;
|
||||
}
|
||||
|
||||
// ── By Period sheet ──────────────────────────────────────────────────────
|
||||
const byPeriod = wb.addWorksheet('By Period', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
byPeriod.columns = [{ width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
|
||||
addTableHeader(byPeriod, 1, ['Period', 'Currency', 'Bookings', 'Revenue'], new Set([1, 2]));
|
||||
addTableHeader(byPeriod, 1, ['Period', 'Currency', 'Items', 'Revenue'], new Set([1, 2]));
|
||||
const periodRows = [...report.byPeriod].sort((a, b) => a.key.localeCompare(b.key));
|
||||
periodRows.forEach((p, i) => {
|
||||
const r = byPeriod.getRow(i + 2);
|
||||
@@ -225,7 +232,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
|
||||
// ── By Segment sheet ─────────────────────────────────────────────────────
|
||||
const bySegment = wb.addWorksheet('By Segment', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
bySegment.columns = [{ width: 34 }, { width: 12 }, { width: 14 }, { width: 20 }];
|
||||
addTableHeader(bySegment, 1, ['Origin → Destination', 'Currency', 'Bookings', 'Revenue'], new Set([1, 2]));
|
||||
addTableHeader(bySegment, 1, ['Origin → Destination', 'Currency', 'Items', 'Revenue'], new Set([1, 2]));
|
||||
report.bySegment.forEach((s, i) => {
|
||||
const r = bySegment.getRow(i + 2);
|
||||
r.getCell(1).value = s.label;
|
||||
@@ -245,7 +252,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
|
||||
// currency's grand total, never a cross-currency sum.
|
||||
const byTripType = wb.addWorksheet('By Trip Type', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
byTripType.columns = [{ width: 26 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
|
||||
addTableHeader(byTripType, 1, ['Trip Type', 'Currency', 'Bookings', 'Revenue', 'Share'], new Set([1, 2, 3]));
|
||||
addTableHeader(byTripType, 1, ['Trip Type', 'Currency', 'Items', 'Revenue', 'Share'], new Set([1, 2, 3]));
|
||||
const tripTypeCurrencyTotal = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
|
||||
report.byTripType.forEach((t, i) => {
|
||||
const r = byTripType.getRow(i + 2);
|
||||
@@ -264,12 +271,60 @@ 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', 'Items', '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 Revenue Type sheet ────────────────────────────────────────────────
|
||||
// Ticket fare vs the fees collected after it (baggage, recovered underpayments, other
|
||||
// supplementary charges). Share is against the same currency's grand total.
|
||||
const byRevenueType = wb.addWorksheet('By Revenue Type', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
byRevenueType.columns = [{ width: 26 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
|
||||
addTableHeader(byRevenueType, 1, ['Revenue Type', 'Currency', 'Items', 'Revenue', 'Share'], new Set([1, 2, 3]));
|
||||
const revenueTypeCurrencyTotal = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
|
||||
report.byRevenueType.forEach((rt, i) => {
|
||||
const r = byRevenueType.getRow(i + 2);
|
||||
const currencyTotal = revenueTypeCurrencyTotal.get(rt.currency) ?? 0;
|
||||
r.getCell(1).value = revenueTypeLabel(rt.label);
|
||||
r.getCell(2).value = rt.currency;
|
||||
r.getCell(3).value = rt.bookingCount;
|
||||
r.getCell(3).alignment = { horizontal: 'right' };
|
||||
r.getCell(4).value = rt.revenueMinor / 100;
|
||||
r.getCell(4).numFmt = currencyFmt(rt.currency);
|
||||
r.getCell(4).alignment = { horizontal: 'right' };
|
||||
r.getCell(5).value = currencyTotal > 0 ? rt.revenueMinor / currencyTotal : 0;
|
||||
r.getCell(5).numFmt = '0.0%';
|
||||
r.getCell(5).alignment = { horizontal: 'right' };
|
||||
bandRow(byRevenueType, i + 2, 5, i % 2 === 1);
|
||||
});
|
||||
byRevenueType.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.
|
||||
const byMethod = wb.addWorksheet('By Method', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
byMethod.columns = [{ width: 20 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
|
||||
addTableHeader(byMethod, 1, ['Payment Method', 'Currency', 'Bookings', 'Revenue', 'Share'], new Set([1, 2, 3]));
|
||||
addTableHeader(byMethod, 1, ['Payment Method', 'Currency', 'Items', 'Revenue', 'Share'], new Set([1, 2, 3]));
|
||||
const totalByCurrency = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
|
||||
report.byMethod.forEach((m, i) => {
|
||||
const r = byMethod.getRow(i + 2);
|
||||
@@ -290,23 +345,25 @@ 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: 20 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
|
||||
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Trip Type', 'Booking Type', 'Revenue Type', 'Payment Method', 'Currency', 'Items', 'Revenue'], new Set([5, 6]));
|
||||
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(7).alignment = { horizontal: 'right' };
|
||||
bandRow(detail, i + 2, 7, i % 2 === 1);
|
||||
r.getCell(4).value = bookingTypeLabel(row.bookingType);
|
||||
r.getCell(5).value = revenueTypeLabel(row.revenueType);
|
||||
r.getCell(6).value = methodLabel(row.method);
|
||||
r.getCell(7).value = row.currency;
|
||||
r.getCell(8).value = row.bookingCount;
|
||||
r.getCell(8).alignment = { horizontal: 'right' };
|
||||
r.getCell(9).value = row.revenueMinor / 100;
|
||||
r.getCell(9).numFmt = currencyFmt(row.currency);
|
||||
r.getCell(9).alignment = { horizontal: 'right' };
|
||||
bandRow(detail, i + 2, 9, 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: 9 } };
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
|
||||
Reference in New Issue
Block a user