diff --git a/apps/edr-passenger-api/src/common/throttle-sign-in.decorator.ts b/apps/edr-passenger-api/src/common/throttle-sign-in.decorator.ts new file mode 100644 index 000000000..40c1ee106 --- /dev/null +++ b/apps/edr-passenger-api/src/common/throttle-sign-in.decorator.ts @@ -0,0 +1,4 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; +export const ThrottleSignIn = (limit = 20) => + applyDecorators(UseGuards(ThrottlerGuard), Throttle({ auth: { limit, ttl: 60_000 } })); diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index ce076c970..76cbb8477 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -21,7 +21,7 @@ import { ApiBearerAuth, } from "@nestjs/swagger"; import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; -import { Throttle, ThrottlerGuard } from "@nestjs/throttler"; +import { ThrottleSignIn } from "../../common/throttle-sign-in.decorator"; import { PassengerAuthService } from "./passenger-auth.service"; import { RegisterDto, @@ -37,16 +37,12 @@ import { JwtGuard } from "../../common/jwt.guard"; @ApiTags("Passenger Auth") @Controller("auth") -// Scoped to this controller rather than registered as a global APP_GUARD: the staged sign-in -// exposes an account-existence lookup, and rate limiting is the mitigation for it. Applying the -// guard app-wide would change the behaviour of every other module at the same time. -@UseGuards(ThrottlerGuard) -@Throttle({ auth: { limit: 20, ttl: 60_000 } }) export class AuthController { constructor(private passengerAuthService: PassengerAuthService) {} @Post("register") @IsPublic() + @ThrottleSignIn() @ApiOperation({ summary: "Register new passenger account (sends SMS verification code)", }) @@ -66,6 +62,7 @@ export class AuthController { @Post("register/resend-code") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Resend the registration verification code for a pending account", @@ -84,6 +81,7 @@ export class AuthController { @Post("login") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Login with email and password" }) @ApiResponse({ @@ -199,11 +197,11 @@ export class AuthController { @Post("identifier/lookup") @IsPublic() + // Tighter than the other sign-in endpoints: this is the one that answers "does this account + // exist", so it is the one worth making expensive to sweep. Still roomy enough that a + // passenger correcting a typo two or three times is unaffected. + @ThrottleSignIn(10) @HttpCode(HttpStatus.OK) - // Tighter than the rest of the controller: this is the endpoint that answers "does this - // account exist", so it is the one worth making expensive to sweep. Still roomy enough - // that a passenger correcting a typo two or three times is unaffected. - @Throttle({ auth: { limit: 10, ttl: 60_000 } }) @ApiOperation({ summary: "Step 1 of sign-in — decide what to ask the user for next", description: @@ -222,6 +220,7 @@ export class AuthController { @Post("password-setup/request") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Send the SMS code that lets an account with no password set one", @@ -237,6 +236,7 @@ export class AuthController { @Post("password-setup/complete") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Redeem the code, set the password, and sign in", @@ -256,6 +256,7 @@ export class AuthController { @Post("fayda/request-password-setup") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Send OTP to phone for Fayda-verified account password setup", @@ -277,6 +278,7 @@ export class AuthController { @Post("fayda/verify-and-login") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Verify OTP and receive session token for Fayda-verified account", diff --git a/apps/edr-passenger-api/src/modules/reports/finance-trip-type.spec.ts b/apps/edr-passenger-api/src/modules/reports/finance-trip-type.spec.ts new file mode 100644 index 000000000..8c25abd35 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reports/finance-trip-type.spec.ts @@ -0,0 +1,35 @@ +import { tripTypeFor } from './finance-trip-type'; +import { FinanceTripType } from './reports.dto'; + +describe('tripTypeFor', () => { + it('counts a trip between two Ethiopian stations as intercity', () => { + // Sebeta → Dire Dawa + expect(tripTypeFor('ET', 'ET')).toBe(FinanceTripType.INTERCITY); + }); + + it('counts a trip leaving Ethiopia as international', () => { + // Sebeta → Nagad + expect(tripTypeFor('ET', 'DJ')).toBe(FinanceTripType.INTERNATIONAL); + }); + + it('counts a trip arriving in Ethiopia as international', () => { + // Nagad → Sebeta + expect(tripTypeFor('DJ', 'ET')).toBe(FinanceTripType.INTERNATIONAL); + }); + + it('counts a trip wholly inside Djibouti as international', () => { + // Alisabieh → Nagad — international because neither endpoint is Ethiopian, not because + // the trip crosses a border. + expect(tripTypeFor('DJ', 'DJ')).toBe(FinanceTripType.INTERNATIONAL); + }); + + it('treats a station with no country code as Ethiopian', () => { + expect(tripTypeFor(null, null)).toBe(FinanceTripType.INTERCITY); + expect(tripTypeFor(undefined, 'ET')).toBe(FinanceTripType.INTERCITY); + }); + + it('still reports international when only the known endpoint is outside Ethiopia', () => { + expect(tripTypeFor(null, 'DJ')).toBe(FinanceTripType.INTERNATIONAL); + expect(tripTypeFor('DJ', undefined)).toBe(FinanceTripType.INTERNATIONAL); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/reports/finance-trip-type.ts b/apps/edr-passenger-api/src/modules/reports/finance-trip-type.ts new file mode 100644 index 000000000..401a11834 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reports/finance-trip-type.ts @@ -0,0 +1,34 @@ +/** + * Finance trip type — the domestic / cross-border rule. + * + * Deliberately free of Prisma and Nest, in the same spirit as + * `blocked-seats-loss.calculator.ts`: `ReportsService` fetches the stations, this module + * decides what the station pair means. Every case below has a unit test in + * `finance-trip-type.spec.ts`. + */ + +import { FinanceTripType } from './reports.dto'; + +/** The country a station has to sit in for a trip to count as domestic Ethiopian traffic. */ +export const DOMESTIC_COUNTRY = 'ET'; + +/** + * Classifies a trip from its endpoints' `Station.countryCode`: intercity when both endpoints + * are Ethiopian, international as soon as either is not — so Sebeta → Nagad, Nagad → Sebeta + * and Alisabieh → Nagad are all international. + * + * A station with no `countryCode` is treated as Ethiopian. The column is nullable and the + * back-office station form defaults it to `'ET'`, which is the same assumption the portal + * makes when deciding whether a passenger needs a Fayda ID. Defaulting rather than carrying + * an "unknown" bucket keeps the invariant that intercity + international equals the + * unfiltered total. + */ +export function tripTypeFor( + originCountry: string | null | undefined, + destinationCountry: string | null | undefined, +): FinanceTripType { + const bothDomestic = + (originCountry ?? DOMESTIC_COUNTRY) === DOMESTIC_COUNTRY && + (destinationCountry ?? DOMESTIC_COUNTRY) === DOMESTIC_COUNTRY; + return bothDomestic ? FinanceTripType.INTERCITY : FinanceTripType.INTERNATIONAL; +} diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 6cf12a9f6..eba60ec07 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -122,16 +122,19 @@ export class ReportsController { @Get("finance") @ApiOperation({ - summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency", + summary: "Finance summary — revenue by period, origin/destination segment, trip 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 " + "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.", + "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 " + + "dashboard and /payments confirmed-revenue filter. Returns per-bucket rows plus roll-ups by period, " + + "segment, trip type, and method for charting.", }) getFinanceSummary(@Query() query: FinanceSummaryQueryDto) { return this.service.getFinanceSummary(query); diff --git a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts index 6c6b304d4..bddc84ee8 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts @@ -113,6 +113,17 @@ export enum FinanceGranularity { MONTHLY = 'monthly', } +/** + * Domestic vs cross-border traffic, derived from the endpoints' `Station.countryCode`: + * a trip is INTERCITY only when both endpoints sit in Ethiopia, and INTERNATIONAL as soon + * as either endpoint is outside it — which on this line means Djibouti (Alisabieh, Holhol, + * Nagad). A trip wholly inside Djibouti counts as INTERNATIONAL too. + */ +export enum FinanceTripType { + INTERCITY = 'intercity', + INTERNATIONAL = 'international', +} + export class FinanceSummaryQueryDto { @ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' }) @IsDateString() dateFrom: string; @@ -131,4 +142,12 @@ export class FinanceSummaryQueryDto { @ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' }) @IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType; + + @ApiPropertyOptional({ + enum: FinanceTripType, + description: + 'Restrict to trips wholly inside Ethiopia (intercity) or trips touching a station outside ' + + 'Ethiopia (international — in practice Djibouti). Omit for all trips.', + }) + @IsOptional() @IsEnum(FinanceTripType) tripType?: FinanceTripType; } diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index d727014ee..a354122c3 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -13,9 +13,11 @@ import { BlockedSeatsRevenueLossQueryDto, FinanceGranularity, FinanceSummaryQueryDto, + FinanceTripType, GenerateReportDto, ReportType, } from "./reports.dto"; +import { tripTypeFor } from "./finance-trip-type"; import { assembleReport, isDiningCoach, @@ -151,6 +153,7 @@ export interface FinanceBucket { originStationId: string; destinationStationId: string; segmentLabel: string; + tripType: FinanceTripType; method: string; currency: string; bookingCount: number; @@ -1804,23 +1807,31 @@ export class ReportsService { if (destination) stationIds.add(destination); } const stations = stationIds.size > 0 - ? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } }) + ? await this.prisma.station.findMany({ + where: { id: { in: [...stationIds] } }, + select: { id: true, name: true, countryCode: true }, + }) : []; const stationName = new Map(stations.map((s) => [s.id, s.name])); + const stationCountry = new Map(stations.map((s) => [s.id, s.countryCode])); const buckets = new Map(); + // `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. const bucketFor = ( period: string, originStationId: string, destinationStationId: string, segmentLabel: string, + tripType: FinanceTripType, 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 }; + bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, method, currency, bookingCount: 0, revenueMinor: 0 }; buckets.set(key, bucket); } return bucket; @@ -1832,9 +1843,17 @@ export class ReportsService { 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"}`; + + // 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; + 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); + const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, pi.method, currency); bucket.bookingCount += 1; bucket.revenueMinor += amountMinor; } @@ -1868,10 +1887,12 @@ export class ReportsService { granularity, dateFrom: query.dateFrom, dateTo: query.dateTo, + tripType: query.tripType ?? 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), rows, }; } @@ -1880,10 +1901,11 @@ export class ReportsService { async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise { const report = await this.getFinanceSummary(query); - const headers = ["Period", "Origin → Destination", "Payment Method", "Currency", "Bookings", "Revenue"]; + const headers = ["Period", "Origin → Destination", "Trip Type", "Payment Method", "Currency", "Bookings", "Revenue"]; const rows = report.rows.map((r) => [ r.period, r.segmentLabel, + r.tripType, r.method, r.currency, r.bookingCount, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx index b83e96ca7..c9ad70e1f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/finance/page.tsx @@ -9,7 +9,7 @@ import { } from 'recharts'; import { toPng } from 'html-to-image'; import { apiClient } from '@/lib/api-client'; -import { financeApi, type FinanceGranularity, type FinanceSummaryFilters } from '@/lib/api/finance'; +import { financeApi, type FinanceGranularity, type FinanceSummaryFilters, type FinanceTripType } from '@/lib/api/finance'; import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook'; import ActionButton from '@/components/ui/ActionButton'; import Pagination from '@/components/ui/Pagination'; @@ -42,6 +42,17 @@ function methodLabel(method: string): string { return PAYMENT_METHOD_LABELS[method] ?? method; } +// Intercity = both endpoints in Ethiopia; international = either endpoint outside it (Djibouti), +// including a trip wholly inside Djibouti. Fixed order so each keeps its colour when filtered. +const TRIP_TYPE_ORDER = ['intercity', 'international'] as const; +const TRIP_TYPE_LABELS: Record = { + intercity: 'Intercity (Ethiopia)', + international: 'International (Djibouti)', +}; +function tripTypeLabel(tripType: string): string { + return TRIP_TYPE_LABELS[tripType] ?? tripType; +} + function periodLabel(period: string, granularity: FinanceGranularity): string { if (granularity === 'monthly') { return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); @@ -112,6 +123,7 @@ export default function FinanceReportPage() { const [originStationId, setOriginStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState(''); const [method, setMethod] = useState(''); + const [tripType, setTripType] = useState<'' | FinanceTripType>(''); const [exporting, setExporting] = useState(false); // Chart cards are captured for the Excel export. There's one Trend/Segment/Method set per @@ -155,8 +167,9 @@ export default function FinanceReportPage() { originStationId: originStationId || undefined, destinationStationId: destinationStationId || undefined, method: method || undefined, + tripType: tripType || undefined, }), - [dateFrom, dateTo, granularity, originStationId, destinationStationId, method], + [dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType], ); const { data: stations = [] } = useQuery({ @@ -181,6 +194,7 @@ export default function FinanceReportPage() { setOriginStationId(''); setDestinationStationId(''); setMethod(''); + setTripType(''); }; /** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */ @@ -195,15 +209,16 @@ export default function FinanceReportPage() { if (!data) return; setExporting(true); try { - const imagesByCurrency: Record = {}; + const imagesByCurrency: Record = {}; await Promise.all( currencySections.map(async (section) => { - const [trend, segment, methodImg] = await Promise.all([ + const [trend, segment, methodImg, tripTypeImg] = await Promise.all([ captureCard(chartRefs.current[`${section.currency}-trend`]), captureCard(chartRefs.current[`${section.currency}-segment`]), captureCard(chartRefs.current[`${section.currency}-method`]), + captureCard(chartRefs.current[`${section.currency}-tripType`]), ]); - imagesByCurrency[section.currency] = { trend, segment, method: methodImg }; + imagesByCurrency[section.currency] = { trend, segment, method: methodImg, tripType: tripTypeImg }; }), ); @@ -218,8 +233,10 @@ export default function FinanceReportPage() { originLabel: originStationId ? stationLabel(originStationId) : 'Any', destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any', methodLabel: method ? methodLabel(method) : 'All', + tripTypeLabel: tripType ? tripTypeLabel(tripType) : 'All', }, methodLabel, + tripTypeLabel, periodLabel, imagesByCurrency, }); @@ -272,6 +289,19 @@ export default function FinanceReportPage() { sharePercent: methodTotal > 0 ? (r.revenueMinor / methodTotal) * 100 : 0, })); + // Intercity vs international for this currency. Shares are against this currency's own + // trip-type total, never a cross-currency sum. + const tripTypeRows = data.byTripType.filter((r) => r.currency === t.currency); + const tripTypeTotal = tripTypeRows.reduce((sum, r) => sum + r.revenueMinor, 0); + const tripTypeBreakdown = tripTypeRows + .slice() + .sort((a, b) => TRIP_TYPE_ORDER.indexOf(a.label as any) - TRIP_TYPE_ORDER.indexOf(b.label as any)) + .map((r) => ({ + ...r, + color: categoricalColor(palette, TRIP_TYPE_ORDER.indexOf(r.label as any)), + sharePercent: tripTypeTotal > 0 ? (r.revenueMinor / tripTypeTotal) * 100 : 0, + })); + return { currency: t.currency, revenueMinor: t.revenueMinor, @@ -279,6 +309,7 @@ export default function FinanceReportPage() { trendData, segmentData, methodBreakdown, + tripTypeBreakdown, }; }); }, [data, totals, palette]); @@ -347,6 +378,14 @@ export default function FinanceReportPage() { ))} +
+ + +