Merge pull request #1474 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-09-02 11:59:02 +03:00
committed by GitHub
10 changed files with 294 additions and 39 deletions

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<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.
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<string> {
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,