feat: ( reports ) add revenue-type filter for baggage, outstanding and other charges

This commit is contained in:
Abubeker Yasin
2026-09-03 11:35:52 +03:00
parent e71a525c15
commit 599fa932c5
6 changed files with 416 additions and 105 deletions

View File

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

View File

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

View File

@@ -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<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] } },
@@ -1825,50 +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. `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<string> {
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,