mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
@@ -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 } }));
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<StationOption[]>({
|
||||
@@ -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<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage }> = {};
|
||||
const imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage }> = {};
|
||||
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() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Trip Type</label>
|
||||
<select className="input" value={tripType} onChange={(e) => setTripType(e.target.value as '' | FinanceTripType)}>
|
||||
<option value="">All trips</option>
|
||||
<option value="intercity">Intercity — within Ethiopia</option>
|
||||
<option value="international">International — to/from Djibouti</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment Method</label>
|
||||
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
@@ -463,6 +502,59 @@ export default function FinanceReportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intercity vs international — same part-to-whole treatment as the method card.
|
||||
A trip is intercity only when both endpoints are Ethiopian; anything touching
|
||||
Djibouti (including a trip wholly inside it) is international. */}
|
||||
<div className="card" ref={setChartRef(`${section.currency}-tripType`)}>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Revenue by Trip Type <span className="text-xs font-normal text-muted-foreground">({section.currency})</span>
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
Share of {section.currency} revenue between trips within Ethiopia and trips touching Djibouti
|
||||
</p>
|
||||
<div
|
||||
className="flex w-full h-7 rounded-md overflow-hidden"
|
||||
role="img"
|
||||
aria-label={`${section.currency} revenue by trip type: ${section.tripTypeBreakdown
|
||||
.map((t) => `${tripTypeLabel(t.label)} ${t.sharePercent.toFixed(0)}%`)
|
||||
.join(', ')}`}
|
||||
>
|
||||
{section.tripTypeBreakdown.map((t, i) => (
|
||||
<div
|
||||
key={t.key}
|
||||
className="h-full"
|
||||
style={{ width: `${t.sharePercent}%`, background: t.color, marginRight: i < section.tripTypeBreakdown.length - 1 ? 2 : 0 }}
|
||||
title={`${tripTypeLabel(t.label)} — ${formatCurrency(t.revenueMinor, t.currency)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium py-2">Trip Type</th>
|
||||
<th className="text-right font-medium py-2">Bookings</th>
|
||||
<th className="text-right font-medium py-2">Share</th>
|
||||
<th className="text-right font-medium py-2">Revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{section.tripTypeBreakdown.map((t) => (
|
||||
<tr key={t.key}>
|
||||
<td className="py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ background: t.color }} aria-hidden="true" />
|
||||
<span className="text-foreground">{tripTypeLabel(t.label)}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{t.bookingCount.toLocaleString()}</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{t.sharePercent.toFixed(1)}%</td>
|
||||
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(t.revenueMinor, t.currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Payment method breakdown — part-to-whole stacked bar + legend table */}
|
||||
<div className="card" ref={setChartRef(`${section.currency}-method`)}>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
@@ -525,7 +617,7 @@ export default function FinanceReportPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{['Period', 'Segment', 'Method', 'Currency', 'Bookings', 'Revenue'].map((h) => (
|
||||
{['Period', 'Segment', 'Type', 'Method', 'Currency', 'Bookings', 'Revenue'].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
@@ -537,6 +629,7 @@ export default function FinanceReportPage() {
|
||||
<tr key={`${r.period}-${r.originStationId}-${r.destinationStationId}-${r.method}-${i}`} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-foreground">{periodLabel(r.period, granularity)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.segmentLabel}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{tripTypeLabel(r.tripType)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{methodLabel(r.method)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.currency}</td>
|
||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{r.bookingCount.toLocaleString()}</td>
|
||||
@@ -545,7 +638,7 @@ export default function FinanceReportPage() {
|
||||
))}
|
||||
{pg.paged.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
|
||||
<td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -2,6 +2,13 @@ import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export type FinanceGranularity = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
/**
|
||||
* Domestic vs cross-border traffic. `intercity` means both endpoints sit in Ethiopia;
|
||||
* `international` means either endpoint is outside it — in practice Djibouti, including a
|
||||
* trip wholly inside Djibouti. Mirrors `FinanceTripType` on the API.
|
||||
*/
|
||||
export type FinanceTripType = 'intercity' | 'international';
|
||||
|
||||
export interface FinanceSummaryFilters {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
@@ -9,6 +16,7 @@ export interface FinanceSummaryFilters {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
method?: string;
|
||||
tripType?: FinanceTripType;
|
||||
}
|
||||
|
||||
export interface FinanceBucketRow {
|
||||
@@ -16,6 +24,7 @@ export interface FinanceBucketRow {
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
tripType: FinanceTripType;
|
||||
method: string;
|
||||
currency: string;
|
||||
bookingCount: number;
|
||||
@@ -35,11 +44,15 @@ export interface FinanceSummaryReport {
|
||||
granularity: FinanceGranularity;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
/** The trip-type filter that was applied, or `null` when every trip is included. */
|
||||
tripType: FinanceTripType | null;
|
||||
/** Grand totals, one entry per currency present — never summed across currencies. */
|
||||
totals: FinanceRollupRow[];
|
||||
byPeriod: FinanceRollupRow[];
|
||||
bySegment: FinanceRollupRow[];
|
||||
byMethod: FinanceRollupRow[];
|
||||
/** Intercity vs international split. One entry per trip type per currency. */
|
||||
byTripType: FinanceRollupRow[];
|
||||
rows: FinanceBucketRow[];
|
||||
}
|
||||
|
||||
@@ -50,6 +63,7 @@ function toParams(filters: FinanceSummaryFilters): Record<string, string> {
|
||||
if (filters.originStationId) params.originStationId = filters.originStationId;
|
||||
if (filters.destinationStationId) params.destinationStationId = filters.destinationStationId;
|
||||
if (filters.method) params.method = filters.method;
|
||||
if (filters.tripType) params.tripType = filters.tripType;
|
||||
return params;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,11 +32,12 @@ export interface ChartImage {
|
||||
|
||||
export interface FinanceWorkbookInput {
|
||||
report: FinanceSummaryReport;
|
||||
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string };
|
||||
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string; tripTypeLabel: string };
|
||||
methodLabel: (method: string) => string;
|
||||
tripTypeLabel: (tripType: string) => string;
|
||||
periodLabel: (period: string, granularity: FinanceGranularity) => string;
|
||||
/** One Trend/Segment/Method image set per currency present — mirrors the on-screen per-currency sections. */
|
||||
imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage }>;
|
||||
/** One Trend/Segment/Method/Trip-type image set per currency present — mirrors the on-screen per-currency sections. */
|
||||
imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage }>;
|
||||
}
|
||||
|
||||
function styleHeaderCell(cell: ExcelJS.Cell) {
|
||||
@@ -159,6 +160,7 @@ function addImage(wb: ExcelJS.Workbook, ws: ExcelJS.Worksheet, image: ChartImage
|
||||
export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise<Blob> {
|
||||
const { report, filters, imagesByCurrency } = input;
|
||||
const methodLabel = input.methodLabel;
|
||||
const tripTypeLabel = input.tripTypeLabel;
|
||||
const periodLabel = input.periodLabel;
|
||||
|
||||
const totals = [...report.totals].sort((a, b) => b.revenueMinor - a.revenueMinor);
|
||||
@@ -175,7 +177,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
|
||||
titleBanner(
|
||||
summary,
|
||||
'EDR Passenger — Finance Summary',
|
||||
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Generated ${new Date().toLocaleString('en-US')}`,
|
||||
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Trip type: ${filters.tripTypeLabel} · Generated ${new Date().toLocaleString('en-US')}`,
|
||||
6,
|
||||
);
|
||||
|
||||
@@ -198,6 +200,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
|
||||
const images = imagesByCurrency[t.currency] ?? {};
|
||||
cursor = addImage(wb, summary, images.trend, cursor, `Revenue Trend (${t.currency})`) + 1;
|
||||
cursor = addImage(wb, summary, images.segment, cursor, `Revenue by Segment (${t.currency})`) + 1;
|
||||
cursor = addImage(wb, summary, images.tripType, cursor, `Revenue by Trip Type (${t.currency})`) + 1;
|
||||
cursor = addImage(wb, summary, images.method, cursor, `Revenue by Payment Method (${t.currency})`) + 1;
|
||||
}
|
||||
|
||||
@@ -236,6 +239,31 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
|
||||
});
|
||||
bySegment.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } };
|
||||
|
||||
// ── By Trip Type sheet ───────────────────────────────────────────────────
|
||||
// Intercity = both endpoints in Ethiopia; international = either endpoint outside it
|
||||
// (Djibouti), including a trip wholly inside Djibouti. Share is against the same
|
||||
// currency's grand total, never a cross-currency sum.
|
||||
const byTripType = wb.addWorksheet('By Trip Type', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
byTripType.columns = [{ width: 26 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
|
||||
addTableHeader(byTripType, 1, ['Trip Type', 'Currency', 'Bookings', 'Revenue', 'Share'], new Set([1, 2, 3]));
|
||||
const tripTypeCurrencyTotal = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
|
||||
report.byTripType.forEach((t, i) => {
|
||||
const r = byTripType.getRow(i + 2);
|
||||
const currencyTotal = tripTypeCurrencyTotal.get(t.currency) ?? 0;
|
||||
r.getCell(1).value = tripTypeLabel(t.label);
|
||||
r.getCell(2).value = t.currency;
|
||||
r.getCell(3).value = t.bookingCount;
|
||||
r.getCell(3).alignment = { horizontal: 'right' };
|
||||
r.getCell(4).value = t.revenueMinor / 100;
|
||||
r.getCell(4).numFmt = currencyFmt(t.currency);
|
||||
r.getCell(4).alignment = { horizontal: 'right' };
|
||||
r.getCell(5).value = currencyTotal > 0 ? t.revenueMinor / currencyTotal : 0;
|
||||
r.getCell(5).numFmt = '0.0%';
|
||||
r.getCell(5).alignment = { horizontal: 'right' };
|
||||
bandRow(byTripType, i + 2, 5, i % 2 === 1);
|
||||
});
|
||||
byTripType.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
|
||||
|
||||
// ── By Method sheet ──────────────────────────────────────────────────────
|
||||
// Share is computed against the grand total for that same currency (`totals`), never
|
||||
// against a sum spanning multiple currencies.
|
||||
@@ -262,22 +290,23 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
|
||||
|
||||
// ── Detail sheet — every row, unpaginated ───────────────────────────────
|
||||
const detail = wb.addWorksheet('Detail', { views: [{ state: 'frozen', ySplit: 1 }] });
|
||||
detail.columns = [{ width: 18 }, { width: 34 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
|
||||
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Payment Method', 'Currency', 'Bookings', 'Revenue'], new Set([2, 3]));
|
||||
detail.columns = [{ width: 18 }, { width: 34 }, { width: 26 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
|
||||
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Trip Type', 'Payment Method', 'Currency', 'Bookings', 'Revenue'], new Set([3, 4]));
|
||||
report.rows.forEach((row, i) => {
|
||||
const r = detail.getRow(i + 2);
|
||||
r.getCell(1).value = periodLabel(row.period, report.granularity);
|
||||
r.getCell(2).value = row.segmentLabel;
|
||||
r.getCell(3).value = methodLabel(row.method);
|
||||
r.getCell(4).value = row.currency;
|
||||
r.getCell(5).value = row.bookingCount;
|
||||
r.getCell(5).alignment = { horizontal: 'right' };
|
||||
r.getCell(6).value = row.revenueMinor / 100;
|
||||
r.getCell(6).numFmt = currencyFmt(row.currency);
|
||||
r.getCell(3).value = tripTypeLabel(row.tripType);
|
||||
r.getCell(4).value = methodLabel(row.method);
|
||||
r.getCell(5).value = row.currency;
|
||||
r.getCell(6).value = row.bookingCount;
|
||||
r.getCell(6).alignment = { horizontal: 'right' };
|
||||
bandRow(detail, i + 2, 6, i % 2 === 1);
|
||||
r.getCell(7).value = row.revenueMinor / 100;
|
||||
r.getCell(7).numFmt = currencyFmt(row.currency);
|
||||
r.getCell(7).alignment = { horizontal: 'right' };
|
||||
bandRow(detail, i + 2, 7, i % 2 === 1);
|
||||
});
|
||||
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 6 } };
|
||||
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 7 } };
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
|
||||
Reference in New Issue
Block a user