Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-08-21 18:26:54 +03:00
843 changed files with 76899 additions and 16046 deletions

View File

@@ -101,14 +101,20 @@ export class PaymentsController {
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
@ApiQuery({ name: "search", required: false })
@ApiQuery({ name: "status", required: false })
@ApiQuery({ name: "status", required: false, description: "PaymentIntentStatus value, e.g. SUCCEEDED" })
@ApiQuery({ name: "method", required: false })
@ApiQuery({
name: "bookingStatus",
required: false,
description: "Comma-separated Booking.status values, e.g. CONFIRMED,BOARDED — restricts to payments backing bookings in those states.",
})
@ApiQuery({ name: "page", required: false })
@ApiQuery({ name: "pageSize", required: false })
async getAll(
@Query("search") search?: string,
@Query("status") status?: string,
@Query("method") method?: string,
@Query("bookingStatus") bookingStatus?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
@@ -116,6 +122,7 @@ export class PaymentsController {
search,
status,
method,
bookingStatus,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
});

View File

@@ -122,10 +122,13 @@ export class PaymentsService {
search?: string;
status?: string;
method?: string;
/** Comma-separated Booking.status values, e.g. "CONFIRMED,BOARDED" — lets a caller ask
* for exactly the payments that back confirmed revenue, not every payment attempt. */
bookingStatus?: string;
page?: number;
pageSize?: number;
}) {
const { search, status, method, page = 1, pageSize = 10 } = filters;
const { search, status, method, bookingStatus, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
@@ -141,6 +144,12 @@ export class PaymentsService {
if (method) {
where.method = method;
}
if (bookingStatus) {
const statuses = bookingStatus.split(",").map((s) => s.trim()).filter(Boolean);
if (statuses.length > 0) {
where.booking = { status: { in: statuses } };
}
}
const [items, total] = await Promise.all([
this.prisma.paymentIntent.findMany({
@@ -156,6 +165,7 @@ export class PaymentsService {
childCount: true,
totalMinor: true,
currency: true,
status: true,
priceTier: { select: { priceMinor: true } },
},
},
@@ -195,6 +205,7 @@ export class PaymentsService {
bookingRef: b?.bookingRef,
totalMinor: b?.totalMinor,
currency: b?.currency,
status: b?.status,
},
amountMinor,
currency: item.currency,

View File

@@ -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";
@@ -118,6 +118,39 @@ 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, 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 " +
"Waafi payment is reported in whatever currency Waafi actually charged, and with no method filter " +
"every currency present is listed separately rather than summed. 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. 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, 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")

View File

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

View File

@@ -11,6 +11,8 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
import {
BlockedSeatsLossSortBy,
BlockedSeatsRevenueLossQueryDto,
FinanceGranularity,
FinanceSummaryQueryDto,
GenerateReportDto,
ReportType,
} from "./reports.dto";
@@ -127,6 +129,42 @@ 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;
currency: string;
bookingCount: number;
revenueMinor: number;
}
export interface FinanceRollupRow {
key: string;
label: string;
currency: string;
revenueMinor: number;
bookingCount: number;
}
@Injectable()
export class ReportsService {
private readonly logger = new Logger(ReportsService.name);
@@ -1691,6 +1729,170 @@ 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, payment method, and currency — the shape finance reconciles
* against provider settlement statements.
*
* Amounts are never converted to ETB. A Waafi payment settles in whatever currency Waafi
* actually charged (DJF/USD), not an exchange-rate estimate of its ETB equivalent — so
* filtering to one method shows exactly what that method collected, in its own currency,
* and leaving every method selected lists each currency's total separately rather than
* summing unlike currencies into one converted figure.
*
* The "actual" amount/currency is `displayTotalMinor`/`displayCurrency` when set, falling
* back to `totalMinor`/`currency` — the same resolution `getPaymentDiscrepancyReport` and
* `getPaymentsReport` use, because `Booking.currency` is often just the internal ETB
* charge basis (many booking-creation paths hardcode it to ETB); the currency the
* passenger was actually shown and charged in lives in the display fields.
*
* 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.
*
* Same revenue definition as `getBackofficeStats` and the `/payments` "confirmed revenue"
* filter: `Booking.status` must still be CONFIRMED/BOARDED (a booking that was paid and
* later cancelled is not revenue) and `PaymentIntent.status` must be SUCCEEDED, not just
* carry a stale `paidAt` from before a cancellation.
*/
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 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 } : {}),
},
select: {
totalMinor: true,
currency: true,
displayTotalMinor: true,
displayCurrency: 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,
currency: string,
): FinanceBucket => {
const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`;
let bucket = buckets.get(key);
if (!bucket) {
bucket = { period, originStationId, destinationStationId, segmentLabel, method, currency, bookingCount: 0, revenueMinor: 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 currency = (b.displayCurrency as string | null) ?? b.currency;
const amountMinor = b.displayTotalMinor ?? b.totalMinor;
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method, currency);
bucket.bookingCount += 1;
bucket.revenueMinor += amountMinor;
}
const rows = [...buckets.values()].sort((a, b) =>
a.period === b.period
? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method) || a.currency.localeCompare(b.currency)
: a.period.localeCompare(b.period),
);
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string): FinanceRollupRow[] => {
const map = new Map<string, FinanceRollupRow>();
for (const r of rows) {
const key = keyOf(r);
let entry = map.get(key);
if (!entry) {
entry = { key, label: labelOf(r), currency: r.currency, revenueMinor: 0, bookingCount: 0 };
map.set(key, entry);
}
entry.revenueMinor += r.revenueMinor;
entry.bookingCount += r.bookingCount;
}
return [...map.values()].sort((a, b) => b.revenueMinor - a.revenueMinor);
};
// Currency is folded into every rollup key so amounts in different currencies are never
// summed together — see class-level note on why this endpoint doesn't convert to ETB.
const totals = rollUp((r) => r.currency, (r) => r.currency);
return {
granularity,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
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),
rows,
};
}
/** CSV of the finance summary, one row per period + origin/destination segment + payment method + currency. */
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
const report = await this.getFinanceSummary(query);
const headers = ["Period", "Origin → Destination", "Payment Method", "Currency", "Bookings", "Revenue"];
const rows = report.rows.map((r) => [
r.period,
r.segmentLabel,
r.method,
r.currency,
r.bookingCount,
(r.revenueMinor / 100).toFixed(2),
]);
return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n");
}
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
search?: string;
seatClass?: string;