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

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

View File

@@ -122,7 +122,7 @@ export class ReportsController {
@Get("finance")
@ApiOperation({
summary: "Finance summary — revenue by period, origin/destination segment, trip type, payment method, and currency",
summary: "Finance summary — revenue by period, origin/destination segment, trip type, booking type, payment method, and currency",
description:
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
"destination station pair, payment method, and currency. Amounts are never converted to ETB — a " +
@@ -131,10 +131,13 @@ export class ReportsController {
"destinationStationId independently to query any station-pair segment (A→B, A→D, B→C), not just a " +
"whole predefined route. Pass `tripType` to split domestic from cross-border traffic: a trip is " +
"`intercity` only when both endpoints sit in Ethiopia, and `international` as soon as either endpoint " +
"is outside it — so Sebeta → Nagad, Nagad → Sebeta and Alisabieh → Nagad are all international. Only " +
"counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same revenue definition as the " +
"is outside it — so Sebeta → Nagad, Nagad → Sebeta and Alisabieh → Nagad are all international. Pass " +
"`bookingType` to separate travel-package revenue from ordinary ticket sales: `package` is a booking " +
"carrying a `packageId`, `regular` is one without — unrelated to the ONE_WAY/ROUND_TRIP booking type, " +
"and not counting the legacy standalone `PackageBooking` table, which this report has never included. " +
"Only counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same revenue definition as the " +
"dashboard and /payments confirmed-revenue filter. Returns per-bucket rows plus roll-ups by period, " +
"segment, trip type, and method for charting.",
"segment, trip type, booking type, and method for charting.",
})
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
return this.service.getFinanceSummary(query);

View File

@@ -124,6 +124,16 @@ export enum FinanceTripType {
INTERNATIONAL = 'international',
}
/**
* Travel-package revenue vs ordinary ticket sales, derived from `Booking.packageId`.
* NOT the `Booking.bookingType` column, which holds ONE_WAY / ROUND_TRIP — every package
* booking happens to be ROUND_TRIP, but that is a different question from this one.
*/
export enum FinanceBookingType {
REGULAR = 'regular',
PACKAGE = 'package',
}
export class FinanceSummaryQueryDto {
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
@IsDateString() dateFrom: string;
@@ -150,4 +160,12 @@ export class FinanceSummaryQueryDto {
'Ethiopia (international — in practice Djibouti). Omit for all trips.',
})
@IsOptional() @IsEnum(FinanceTripType) tripType?: FinanceTripType;
@ApiPropertyOptional({
enum: FinanceBookingType,
description:
'Restrict to ordinary ticket sales (regular) or travel-package bookings (package). ' +
'Omit for all bookings. Unrelated to the ONE_WAY/ROUND_TRIP booking type.',
})
@IsOptional() @IsEnum(FinanceBookingType) bookingType?: FinanceBookingType;
}

View File

@@ -11,6 +11,7 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
import {
BlockedSeatsLossSortBy,
BlockedSeatsRevenueLossQueryDto,
FinanceBookingType,
FinanceGranularity,
FinanceSummaryQueryDto,
FinanceTripType,
@@ -154,6 +155,8 @@ export interface FinanceBucket {
destinationStationId: string;
segmentLabel: string;
tripType: FinanceTripType;
/** regular vs package — from `Booking.packageId`, not the ONE_WAY/ROUND_TRIP column. */
bookingType: FinanceBookingType;
method: string;
currency: string;
bookingCount: number;
@@ -1783,9 +1786,15 @@ export class ReportsService {
},
...(query.originStationId ? { originStationId: query.originStationId } : {}),
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
// Unlike tripType, this one pushes down: `packageId` sits on the booking row itself,
// so there is no schedule fallback to preserve and the database can do the filtering.
...(query.bookingType
? { packageId: query.bookingType === FinanceBookingType.PACKAGE ? { not: null } : null }
: {}),
},
select: {
totalMinor: true,
packageId: true,
currency: true,
displayTotalMinor: true,
displayCurrency: true,
@@ -1818,20 +1827,23 @@ export class ReportsService {
const buckets = new Map<string, FinanceBucket>();
// `tripType` is a pure function of the station pair, so it never splits a bucket that the
// origin/destination part of the key hasn't split already — it rides along on the bucket
// rather than joining the key.
// rather than joining the key. `bookingType` is not: a package and a regular booking can
// share the same period, segment, method and currency, so it has to be part of the key or
// a mixed bucket would take whichever label happened to land first.
const bucketFor = (
period: string,
originStationId: string,
destinationStationId: string,
segmentLabel: string,
tripType: FinanceTripType,
bookingType: FinanceBookingType,
method: string,
currency: string,
): FinanceBucket => {
const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`;
const key = `${period}|${originStationId}|${destinationStationId}|${bookingType}|${method}|${currency}`;
let bucket = buckets.get(key);
if (!bucket) {
bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, method, currency, bookingCount: 0, revenueMinor: 0 };
bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, bookingType, method, currency, bookingCount: 0, revenueMinor: 0 };
buckets.set(key, bucket);
}
return bucket;
@@ -1851,9 +1863,10 @@ export class ReportsService {
const tripType = tripTypeFor(stationCountry.get(originStationId), stationCountry.get(destinationStationId));
if (query.tripType && tripType !== query.tripType) continue;
const bookingType = b.packageId ? FinanceBookingType.PACKAGE : FinanceBookingType.REGULAR;
const currency = (b.displayCurrency as string | null) ?? b.currency;
const amountMinor = b.displayTotalMinor ?? b.totalMinor;
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, pi.method, currency);
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, bookingType, pi.method, currency);
bucket.bookingCount += 1;
bucket.revenueMinor += amountMinor;
}
@@ -1888,11 +1901,13 @@ export class ReportsService {
dateFrom: query.dateFrom,
dateTo: query.dateTo,
tripType: query.tripType ?? null,
bookingType: query.bookingType ?? null,
totals,
byPeriod: rollUp((r) => `${r.period}|${r.currency}`, (r) => r.period),
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}|${r.currency}`, (r) => r.segmentLabel),
byMethod: rollUp((r) => `${r.method}|${r.currency}`, (r) => r.method),
byTripType: rollUp((r) => `${r.tripType}|${r.currency}`, (r) => r.tripType),
byBookingType: rollUp((r) => `${r.bookingType}|${r.currency}`, (r) => r.bookingType),
rows,
};
}
@@ -1901,11 +1916,12 @@ export class ReportsService {
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
const report = await this.getFinanceSummary(query);
const headers = ["Period", "Origin → Destination", "Trip Type", "Payment Method", "Currency", "Bookings", "Revenue"];
const headers = ["Period", "Origin → Destination", "Trip Type", "Booking Type", "Payment Method", "Currency", "Bookings", "Revenue"];
const rows = report.rows.map((r) => [
r.period,
r.segmentLabel,
r.tripType,
r.bookingType,
r.method,
r.currency,
r.bookingCount,