mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 01:20:55 +00:00
Added financial report
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto";
|
||||
import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReportDto } from "./reports.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@@ -83,6 +83,35 @@ export class ReportsController {
|
||||
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
|
||||
}
|
||||
|
||||
// ── Finance Summary ──────────────────────────────────────────────────────
|
||||
|
||||
@Get("finance")
|
||||
@ApiOperation({
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, and payment method",
|
||||
description:
|
||||
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
|
||||
"destination station pair, and payment method. Filter by originStationId and/or destinationStationId " +
|
||||
"independently to query any station-pair segment (A→B, A→D, B→C), not just a whole predefined route. " +
|
||||
"Returns per-bucket rows plus roll-ups by period, segment, and method for charting.",
|
||||
})
|
||||
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
|
||||
return this.service.getFinanceSummary(query);
|
||||
}
|
||||
|
||||
@Get("finance/export")
|
||||
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
|
||||
@ApiProduces("text/csv")
|
||||
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
|
||||
async exportFinanceSummary(@Query() query: FinanceSummaryQueryDto, @Res() res: Response): Promise<void> {
|
||||
const csv = await this.service.exportFinanceSummaryCsv(query);
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="finance-summary-${new Date().toISOString().split("T")[0]}.csv"`,
|
||||
);
|
||||
res.send(csv);
|
||||
}
|
||||
|
||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||
|
||||
@Get("blocked-seats-revenue-loss")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaymentMethodType } from '@prisma/client';
|
||||
import { SeatBlockReasonCategory } from '../seats/seats.dto';
|
||||
|
||||
export enum ReportType {
|
||||
@@ -103,3 +104,31 @@ export class BlockedSeatsRevenueLossQueryDto {
|
||||
})
|
||||
@IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy;
|
||||
}
|
||||
|
||||
// ── Finance Summary ──────────────────────────────────────────────────────────
|
||||
|
||||
export enum FinanceGranularity {
|
||||
DAILY = 'daily',
|
||||
WEEKLY = 'weekly',
|
||||
MONTHLY = 'monthly',
|
||||
}
|
||||
|
||||
export class FinanceSummaryQueryDto {
|
||||
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
|
||||
@IsDateString() dateFrom: string;
|
||||
|
||||
@ApiProperty({ example: '2026-07-31', description: 'End of the window, inclusive, matched on PaymentIntent.paidAt.' })
|
||||
@IsDateString() dateTo: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FinanceGranularity, default: FinanceGranularity.DAILY })
|
||||
@IsOptional() @IsEnum(FinanceGranularity) granularity?: FinanceGranularity;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to bookings departing from this station.' })
|
||||
@IsOptional() @IsString() originStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Restrict to bookings arriving at this station.' })
|
||||
@IsOptional() @IsString() destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' })
|
||||
@IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import {
|
||||
BlockedSeatsLossSortBy,
|
||||
BlockedSeatsRevenueLossQueryDto,
|
||||
FinanceGranularity,
|
||||
FinanceSummaryQueryDto,
|
||||
GenerateReportDto,
|
||||
ReportType,
|
||||
} from "./reports.dto";
|
||||
@@ -84,6 +86,33 @@ function toCsvCell(value: string | number): string {
|
||||
return `"${String(value).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets a paid-at timestamp into the requested reporting period, keyed so buckets sort
|
||||
* chronologically as plain strings. Weekly buckets are labelled by their Monday (UTC).
|
||||
*/
|
||||
function periodKeyFor(date: Date, granularity: FinanceGranularity): string {
|
||||
if (granularity === FinanceGranularity.MONTHLY) {
|
||||
return date.toISOString().slice(0, 7);
|
||||
}
|
||||
if (granularity === FinanceGranularity.WEEKLY) {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
const isoDay = d.getUTCDay() || 7; // Monday=1 .. Sunday=7
|
||||
d.setUTCDate(d.getUTCDate() - (isoDay - 1));
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
export interface FinanceBucket {
|
||||
period: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
method: string;
|
||||
bookingCount: number;
|
||||
revenueEtbMinor: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
private readonly logger = new Logger(ReportsService.name);
|
||||
@@ -1051,6 +1080,161 @@ export class ReportsService {
|
||||
return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows };
|
||||
}
|
||||
|
||||
// ── Finance Summary ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Revenue collected in the window, grouped by reporting period (day/week/month), origin →
|
||||
* destination station pair, and payment method — the shape finance reconciles against
|
||||
* provider settlement statements.
|
||||
*
|
||||
* Grouped by the booking's own origin/destination, not the parent Route — a route like
|
||||
* "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any
|
||||
* sub-segment of it (e.g. Lebu → Adama). Filtering by station lets finance ask about any
|
||||
* A→B pair, not just whole routes.
|
||||
*
|
||||
* Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`,
|
||||
* so a booking made in one period but paid in another lands in the period it was paid.
|
||||
*/
|
||||
async getFinanceSummary(query: FinanceSummaryQueryDto) {
|
||||
const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z");
|
||||
const dateTo = new Date(query.dateTo + "T23:59:59.999Z");
|
||||
const granularity = query.granularity ?? FinanceGranularity.DAILY;
|
||||
|
||||
const rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||
where: { toCurrency: "ETB" as any },
|
||||
orderBy: { effectiveDate: "desc" },
|
||||
});
|
||||
const rateToEtb = new Map<string, number>();
|
||||
for (const r of rateRows) {
|
||||
if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||
}
|
||||
const toEtbMinor = (minor: number, currency: string): number => {
|
||||
if (currency === "ETB") return minor;
|
||||
const rate = rateToEtb.get(currency);
|
||||
return rate ? Math.round(minor * rate) : minor;
|
||||
};
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
paymentIntent: {
|
||||
paidAt: { gte: dateFrom, lte: dateTo },
|
||||
...(query.method ? { method: query.method } : {}),
|
||||
},
|
||||
...(query.originStationId ? { originStationId: query.originStationId } : {}),
|
||||
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
|
||||
},
|
||||
select: {
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
schedule: { select: { originStationId: true, destinationStationId: true } },
|
||||
paymentIntent: { select: { paidAt: true, method: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
if (origin) stationIds.add(origin);
|
||||
if (destination) stationIds.add(destination);
|
||||
}
|
||||
const stations = stationIds.size > 0
|
||||
? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const stationName = new Map(stations.map((s) => [s.id, s.name]));
|
||||
|
||||
const buckets = new Map<string, FinanceBucket>();
|
||||
const bucketFor = (
|
||||
period: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
segmentLabel: string,
|
||||
method: string,
|
||||
): FinanceBucket => {
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
};
|
||||
|
||||
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"}`;
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method);
|
||||
bucket.bookingCount += 1;
|
||||
bucket.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency);
|
||||
}
|
||||
|
||||
const rows = [...buckets.values()].sort((a, b) =>
|
||||
a.period === b.period
|
||||
? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method)
|
||||
: a.period.localeCompare(b.period),
|
||||
);
|
||||
|
||||
const totals = rows.reduce(
|
||||
(acc, r) => {
|
||||
acc.bookingCount += r.bookingCount;
|
||||
acc.revenueEtbMinor += r.revenueEtbMinor;
|
||||
return acc;
|
||||
},
|
||||
{ bookingCount: 0, revenueEtbMinor: 0 },
|
||||
);
|
||||
|
||||
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => {
|
||||
const map = new Map<string, { key: string; label: string; revenueEtbMinor: number; bookingCount: number }>();
|
||||
for (const r of rows) {
|
||||
const key = keyOf(r);
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { key, label: labelOf(r), revenueEtbMinor: 0, bookingCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.revenueEtbMinor += r.revenueEtbMinor;
|
||||
entry.bookingCount += r.bookingCount;
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor);
|
||||
};
|
||||
|
||||
return {
|
||||
granularity,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
currency: "ETB",
|
||||
totals,
|
||||
byPeriod: rollUp((r) => r.period, (r) => r.period),
|
||||
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel),
|
||||
byMethod: rollUp((r) => r.method, (r) => r.method),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
/** CSV of the finance summary, one row per period + origin/destination segment + payment method. */
|
||||
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
|
||||
const report = await this.getFinanceSummary(query);
|
||||
|
||||
const headers = ["Period", "Origin → Destination", "Payment Method", "Bookings", "Revenue (ETB)"];
|
||||
const rows = report.rows.map((r) => [
|
||||
r.period,
|
||||
r.segmentLabel,
|
||||
r.method,
|
||||
r.bookingCount,
|
||||
(r.revenueEtbMinor / 100).toFixed(2),
|
||||
]);
|
||||
|
||||
return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n");
|
||||
}
|
||||
|
||||
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
|
||||
search?: string;
|
||||
seatClass?: string;
|
||||
|
||||
Reference in New Issue
Block a user