mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
feat: ( reports ) add revenue-type filter for baggage, outstanding and other charges
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user