diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml
index 751118839..15ca4faeb 100644
--- a/.github/workflows/malware-scan.yml
+++ b/.github/workflows/malware-scan.yml
@@ -35,7 +35,7 @@ jobs:
# Plain `self-hosted` — GitHub applies this label to every self-hosted
# runner automatically. The scan is host-agnostic, unlike the deploy jobs
# which pin to a branch-specific runner.
- runs-on: self-hosted
+ runs-on: [self-hosted, dev]
outputs:
infected: ${{ steps.scan.outputs.infected }}
steps:
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
index df81fd5f6..ba6fece8c 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -279,7 +279,9 @@ export class BookingPricingService {
return {
lineItems,
- totalAmount: total,
+ // Grand total is billed in whole currency units — fractional line sums
+ // (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
+ totalAmount: Math.round(total),
currency: booking.paymentCurrency,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index 6273be365..0ff9b7c24 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -1074,16 +1074,23 @@ export class ContractsController {
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings')
- @BookingStaff(FREIGHT_PERMS.contracts.createBooking)
+ // Path A is a customer flow — both audiences must reach the service, whose
+ // assertGate decides per role. Staff still need contracts:create_booking.
+ @MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
})
- createBooking(
+ async createBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
- @CurrentUser() user: AuthUserPayload,
+ @CurrentUser() user: TCurrentUser & { sub?: string },
) {
+ // Customer callers may only book on their own contract.
+ if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
+ const contract = await this.contractsService.findById(id);
+ await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
+ }
// The service decides the execution path from the contract:
// Path A (customs disabled) → customer/staff create; status checks apply.
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
@@ -1096,16 +1103,25 @@ export class ContractsController {
}
@Post(':id/bookings/initiate')
- @BookingStaff(FREIGHT_PERMS.contracts.createBooking)
+ // Customer initiates their own ONE_TIME instance; GL initiates on customs
+ // contracts — the service's assertGate decides per role, so both audiences
+ // must reach it. Staff still need contracts:create_booking.
+ @MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
})
- initiateBooking(
+ async initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
- @CurrentUser() user: AuthUserPayload,
+ @CurrentUser() user: TCurrentUser & { sub?: string },
) {
+ // Customer callers may only initiate on their own contract; the service's
+ // assertGate then decides what a customer may do on it.
+ if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
+ const contract = await this.contractsService.findById(id);
+ await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
+ }
return this.contractBookingService.initiateUnderContract(
id,
{ contractRouteId: dto?.contractRouteId },
@@ -1115,17 +1131,24 @@ export class ContractsController {
}
@Post(':id/bookings/:bookingId/complete')
- @BookingStaff(FREIGHT_PERMS.contracts.createBooking)
+ // Customers complete their own initiated (non-customs) instances; the
+ // service keeps customs completion GL-only via the actor's permissions.
+ @MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
})
- completeBooking(
+ async completeBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CreateBookingUnderContractDto,
- @CurrentUser() user: AuthUserPayload,
+ @CurrentUser() user: TCurrentUser & { sub?: string },
) {
+ // Customer callers may only complete bookings on their own contract.
+ if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
+ const contract = await this.contractsService.findById(id);
+ await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
+ }
// Customs (Path B) instances may only be completed by GL Ethiopia — the
// service checks the actor's contracts:create_booking permission.
return this.contractBookingService.completeUnderContract(
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
index 8a7949410..ca4ed18ce 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
@@ -161,10 +161,10 @@ export default function ClearanceDocumentsPage() {
-
+
{customer}
-
+
{b.reference}
@@ -182,7 +182,7 @@ export default function ClearanceDocumentsPage() {
) : (
—
@@ -409,7 +409,7 @@ export default function ClearanceDocumentsPage() {
manualPagination: true,
pageCount,
}}
- containerClassName="border-0 shadow-none bg-transparent"
+ containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
footer={DataTableFooter}
/>
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
index 3d1e20bb6..8719f644a 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
@@ -393,10 +393,10 @@ function ShipmentBookingsTable({
-
+
{row.original.reference}
-
+
{row.original.customerLabel}
@@ -413,7 +413,7 @@ function ShipmentBookingsTable({
-
+
{r.contractReference ?? "—"}
@@ -431,13 +431,9 @@ function ShipmentBookingsTable({
header: () => Route ,
cell: ({ row }) => (
-
- {row.original.originLabel}
-
+ {row.original.originLabel}
-
- {row.original.destinationLabel}
-
+ {row.original.destinationLabel}
),
},
@@ -610,7 +606,7 @@ function ShipmentBookingsTable({
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
- containerClassName="border-0 shadow-none bg-transparent"
+ containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
/>
);
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx
index c82cfd152..77ad33f56 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx
@@ -226,11 +226,11 @@ function RouteCell({
return (
-
+
{origin}
-
+
{destination}
@@ -385,10 +385,10 @@ export default function GlDjiboutiClearanceListPage() {
-
+
{r.reference}
-
+
{r.customerLabel}
@@ -403,9 +403,7 @@ export default function GlDjiboutiClearanceListPage() {
cell: ({ row }) => (
-
- {row.original.contractReference}
-
+ {row.original.contractReference}
),
},
@@ -710,7 +708,7 @@ export default function GlDjiboutiClearanceListPage() {
manualPagination: true,
pageCount,
}}
- containerClassName="border-0 shadow-none bg-transparent"
+ containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
footer={DataTableFooter}
/>
diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts
index 29ef49e59..e125bb0bf 100644
--- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts
+++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts
@@ -8,7 +8,7 @@
*/
/** Maximum time (hours) a passenger has to pay after booking. */
-export const MAX_PAYMENT_HOURS = 240;
+export const MAX_PAYMENT_HOURS = 2;
/** Minutes before departure: cutoff for new bookings and payment deadline. */
export const CUTOFF_MINUTES = 30;
diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts
index 5a541452c..09b3717b4 100644
--- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts
+++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts
@@ -1,13 +1,6 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
-import { ReportsModule } from '../reports/reports.module';
-@Module({
- // ReportsModule owns the blocked-seat revenue loss rule; the dashboard's roll-up
- // reads it from there instead of keeping a second copy of the definition.
- imports: [ReportsModule],
- controllers: [DashboardController],
- providers: [DashboardService],
-})
+@Module({ controllers: [DashboardController], providers: [DashboardService] })
export class DashboardModule {}
diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts
index 6a5d83b89..f7e015e11 100644
--- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts
+++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts
@@ -1,31 +1,17 @@
-import { Injectable, Logger } from '@nestjs/common';
+import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
-import { BlockedSeatRevenueLossStat } from '@edr/types';
import { PrismaService } from '../../common/prisma.service';
-import { ReportsService } from '../reports/reports.service';
-
-/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
-const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
- periodDays: null,
- lossByCurrency: [],
- schedulesAffected: 0,
- blockedSeatCount: 0,
- topReasonCategory: null,
-};
@Injectable()
export class DashboardService {
- private readonly logger = new Logger(DashboardService.name);
-
constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
- private reports: ReportsService,
) {}
async getBackofficeStats() {
- const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows, blockedSeatRevenueLoss] =
+ const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
await Promise.all([
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
@@ -52,9 +38,6 @@ export class DashboardService {
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
`,
- // Joined into this same call on purpose: the dashboard's request count stays
- // exactly where it was, and the card renders from the payload it already fetches.
- this.getBlockedSeatRevenueLossStat(),
]);
const totalPackageTickets = await this.prisma.ticket.count({
@@ -75,43 +58,11 @@ export class DashboardService {
totalNormalTickets: totalTickets - totalPackageTickets,
totalPassengers,
blockedSeatsCount,
- blockedSeatRevenueLoss,
revenueByCurrency: toMap(revenueRows),
packageRevenueByCurrency: toMap(packageRevenueRows),
};
}
- /**
- * Compact roll-up of the Blocked Seat Revenue Loss report over its full history.
- *
- * Reuses the report service rather than re-deriving the rule — there is exactly one
- * definition of what a blocked seat costs. A failure here degrades to zeroes instead of
- * taking the whole dashboard down with it.
- */
- private async getBlockedSeatRevenueLossStat(): Promise
{
- try {
- // pageSize 1: only the summary is read, and paging does not change what it covers.
- const report = await this.reports.getBlockedSeatsRevenueLoss({ page: 1, pageSize: 1 });
- const { summary } = report;
-
- return {
- periodDays: null,
- lossByCurrency: summary.lossByCurrency,
- schedulesAffected: summary.schedulesAffected,
- blockedSeatCount: summary.blockedSeatCount,
- // topReasonCategories is already sorted by estimated loss, descending.
- topReasonCategory: summary.topReasonCategories[0]?.reasonCategory ?? null,
- };
- } catch (err) {
- this.logger.warn(
- `Blocked-seat revenue loss roll-up unavailable — ${
- err instanceof Error ? err.message : String(err)
- }`,
- );
- return EMPTY_BLOCKED_SEAT_LOSS;
- }
- }
-
async getHomeDashboard(passengerId: string) {
const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
diff --git a/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts
index 6ce581067..efee9a11c 100644
--- a/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts
+++ b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts
@@ -193,7 +193,10 @@ function supersedes(candidate: CountedBlock, existing: CountedBlock): boolean {
return candidate.block.blockedAt.getTime() > existing.block.blockedAt.getTime();
}
-function isGlobalBlockInEffectAt(block: LossBlock, departureAt: Date): boolean {
+export function isGlobalBlockInEffectAt(
+ block: Pick,
+ departureAt: Date,
+): boolean {
if (block.blockedAt.getTime() > departureAt.getTime()) return false;
if (block.unblockAt === null) return true;
return block.unblockAt.getTime() >= departureAt.getTime();
@@ -210,9 +213,10 @@ export function isPlaceholderSeat(seat: Pick): boolean {
* inflates the blocked-seat count. Match on a substring of type *or* name so both the
* documented convention and the data as it actually exists are covered.
*/
-export function isDiningCoach(
- coach: Pick,
-): boolean {
+export function isDiningCoach(coach: {
+ coachTypeType?: string | null;
+ coachTypeName?: string | null;
+}): boolean {
const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase();
return haystack.includes('dining');
}
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 8811a3855..e899fdb4b 100644
--- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts
+++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts
@@ -15,12 +15,16 @@ import {
} from "./reports.dto";
import {
assembleReport,
+ isDiningCoach,
+ isGlobalBlockInEffectAt,
+ isPlaceholderSeat,
LossCalculatorInput,
LossCoach,
LossFare,
LossSeat,
selectCountedBlocks,
soldKey,
+ TICKETING_BLOCK_REASON_PREFIX,
} from "./blocked-seats-loss.calculator";
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
@@ -528,7 +532,8 @@ export class ReportsService {
}
async getSeatStatusReport(scheduleId: string) {
- // Confirmed/boarded seats — exclude dining coaches
+ // Confirmed/boarded seats. Dining coaches are dropped in JS below — `CoachType.type`
+ // holds display names in real data, so an exact match here would not catch them.
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
OR: [
@@ -536,7 +541,6 @@ export class ReportsService {
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
],
- seat: { coach: { coachType: { type: { not: 'dining' } } } },
},
include: {
booking: {
@@ -565,12 +569,6 @@ export class ReportsService {
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
});
- // Active seat holds for this schedule
- const activeHolds = await this.prisma.seatHold.findMany({
- where: { scheduleId },
- orderBy: { createdAt: 'desc' },
- });
-
// Expired holds (last 24h) — held but never converted to a booking
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const expiredHolds = await this.prisma.seatHold.findMany({
@@ -581,35 +579,89 @@ export class ReportsService {
orderBy: { expiresAt: 'desc' },
});
- // Manually blocked seats — schedule-scoped blocks for this schedule OR global blocks (scheduleId null)
- // Exclude MAINTENANCE and booking-system-created blocks
- const blocks = await this.prisma.seatBlock.findMany({
- where: {
- OR: [
- { scheduleId },
- { scheduleId: null },
- ],
- NOT: [
- { reason: { startsWith: 'MAINTENANCE:' } },
- { reason: { startsWith: 'Booked in tickets' } },
- ],
- },
- include: {
- seat: {
- select: {
- seatNumber: true,
- bedPosition: true,
- coach: {
- select: {
- number: true,
- coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } },
+ // Manually blocked seats. Counted the same way the blocked-seat revenue loss report
+ // counts them (see `selectCountedBlocks`), so the two reports never disagree:
+ // - a schedule-scoped block naming this schedule, or
+ // - a global block (scheduleId null) that was in effect at departure AND sits on a
+ // coach actually assigned to this train.
+ // A global block on a coach that never joined this consist is not a blocked seat here.
+ // Excluded: dining coaches, placeholder seats, ticket-issuance bookkeeping blocks, and
+ // MAINTENANCE (a seat out of service, not one withheld by hand).
+ const [schedule, assignments, blockRows] = await Promise.all([
+ this.prisma.trainSchedule.findUnique({
+ where: { id: scheduleId },
+ select: { departureAt: true },
+ }),
+ this.prisma.coachAssignment.findMany({
+ where: { scheduleId },
+ select: { coachId: true },
+ }),
+ this.prisma.seatBlock.findMany({
+ where: {
+ OR: [
+ { scheduleId },
+ { scheduleId: null },
+ ],
+ NOT: [
+ { reason: { startsWith: 'MAINTENANCE:' } },
+ { reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } },
+ ],
+ },
+ include: {
+ seat: {
+ select: {
+ id: true,
+ coachId: true,
+ seatNumber: true,
+ bedPosition: true,
+ coach: {
+ select: {
+ number: true,
+ coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } },
+ },
},
},
},
},
- },
- orderBy: { blockedAt: 'desc' },
- });
+ orderBy: { blockedAt: 'desc' },
+ }),
+ ]);
+
+ const assignedCoachIds = new Set(assignments.map((a) => a.coachId));
+ const departureAt = schedule?.departureAt ?? null;
+
+ // One counted block per seat: a schedule-scoped block beats a global one, and between
+ // two of the same kind the most recent wins — the rows arrive newest-first, so the
+ // first of a kind seen for a seat is already the most recent.
+ const countedBySeat = new Map();
+ for (const block of blockRows) {
+ const seat = block.seat;
+ if (!seat || isPlaceholderSeat(seat)) continue;
+
+ const coachType = seat.coach?.coachType;
+ if (
+ isDiningCoach({
+ coachTypeType: coachType?.type ?? null,
+ coachTypeName: coachType?.name ?? null,
+ })
+ ) {
+ continue;
+ }
+
+ if (block.scheduleId === null) {
+ if (!assignedCoachIds.has(seat.coachId)) continue;
+ if (!departureAt || !isGlobalBlockInEffectAt(block, departureAt)) continue;
+ }
+
+ const existing = countedBySeat.get(seat.id);
+ if (!existing || (existing.scheduleId === null && block.scheduleId !== null)) {
+ countedBySeat.set(seat.id, block);
+ }
+ }
+
+ const blocks = [...countedBySeat.values()].sort(
+ (a, b) => b.blockedAt.getTime() - a.blockedAt.getTime(),
+ );
const resolveSeatClass = (seat: any): string | null => {
const classes = seat?.coach?.coachType?.seatClasses ?? [];
@@ -619,10 +671,18 @@ export class ReportsService {
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
};
- const paidSeats = bookingSeats.filter(bs =>
+ const passengerSeats = bookingSeats.filter(
+ bs =>
+ !isDiningCoach({
+ coachTypeType: bs.seat?.coach?.coachType?.type ?? null,
+ coachTypeName: bs.seat?.coach?.coachType?.name ?? null,
+ }),
+ );
+
+ const paidSeats = passengerSeats.filter(bs =>
bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED'
);
- const unpaidSeats = bookingSeats.filter(bs =>
+ const unpaidSeats = passengerSeats.filter(bs =>
bs.booking.status === 'PENDING_PAYMENT'
);
@@ -645,7 +705,7 @@ export class ReportsService {
paidCount: paidSeats.length,
unpaidCount: unpaidSeats.length,
expiredHoldCount: expiredHolds.length,
- blockedCount: blocks.filter(b => b.seat?.coach?.coachType?.type !== 'dining').length,
+ blockedCount: blocks.length,
},
paidSeats: paidSeats.map(mapSeat),
unpaidSeats: unpaidSeats.map(mapSeat),
@@ -655,18 +715,16 @@ export class ReportsService {
expiresAt: h.expiresAt,
createdAt: h.createdAt,
})),
- blockedSeats: blocks
- .filter(b => b.seat?.coach?.coachType?.type !== 'dining')
- .map(b => ({
- id: b.id,
- coachNumber: b.seat?.coach?.number ?? null,
- seatNumber: b.seat?.seatNumber ?? null,
- seatClassName: resolveSeatClass(b.seat),
- reason: b.reason,
- blockedBy: b.blockedBy,
- blockedAt: b.blockedAt,
- unblockAt: b.unblockAt,
- })),
+ blockedSeats: blocks.map(b => ({
+ id: b.id,
+ coachNumber: b.seat?.coach?.number ?? null,
+ seatNumber: b.seat?.seatNumber ?? null,
+ seatClassName: resolveSeatClass(b.seat),
+ reason: b.reason,
+ blockedBy: b.blockedBy,
+ blockedAt: b.blockedAt,
+ unblockAt: b.unblockAt,
+ })),
};
}
diff --git a/apps/edr-passenger-api/src/modules/reports/seat-status-blocked.spec.ts b/apps/edr-passenger-api/src/modules/reports/seat-status-blocked.spec.ts
new file mode 100644
index 000000000..044ebe868
--- /dev/null
+++ b/apps/edr-passenger-api/src/modules/reports/seat-status-blocked.spec.ts
@@ -0,0 +1,261 @@
+import { ReportsService } from './reports.service';
+
+/**
+ * Covers the blocked-seat half of the seat status report.
+ *
+ * The count used to be a raw `SeatBlock` row count with an exact `type === 'dining'`
+ * exclusion. Real EDR data stores display names in `CoachType.type` ('Dining Coach '),
+ * so dining seats slipped through, and every global block counted even when its coach
+ * never joined the train. These cases pin the corrected rule.
+ */
+
+const SCHEDULE_ID = 'sched-1';
+const DEPARTURE = new Date('2026-03-10T06:00:00.000Z');
+
+interface CoachSpec {
+ id: string;
+ number: string;
+ typeType?: string;
+ typeName?: string;
+}
+
+const passengerCoach: CoachSpec = { id: 'coach-1', number: 'C1' };
+const diningCoach: CoachSpec = {
+ id: 'coach-dining',
+ number: 'D1',
+ // As the data actually looks: display name in `type`, trailing space included.
+ typeType: 'Dining Coach ',
+ typeName: 'Dining Coach',
+};
+
+function seatRow(
+ id: string,
+ coach: CoachSpec,
+ seatNumber: string,
+ bedPosition: string | null = null,
+) {
+ return {
+ id,
+ coachId: coach.id,
+ seatNumber,
+ bedPosition,
+ coach: {
+ number: coach.number,
+ coachType: {
+ name: coach.typeName ?? 'Standard',
+ type: coach.typeType ?? 'passenger',
+ seatClasses: [{ name: 'Economy', bedPosition: null }],
+ },
+ },
+ };
+}
+
+function blockRow(
+ overrides: Partial<{
+ id: string;
+ scheduleId: string | null;
+ reason: string;
+ blockedAt: Date;
+ unblockAt: Date | null;
+ seat: ReturnType;
+ }> = {},
+) {
+ return {
+ id: 'block-1',
+ scheduleId: SCHEDULE_ID as string | null,
+ reason: 'VIP hold',
+ blockedBy: 'user-1',
+ blockedAt: new Date('2026-03-01T00:00:00.000Z'),
+ unblockAt: null as Date | null,
+ seat: seatRow('seat-1', passengerCoach, '1'),
+ ...overrides,
+ };
+}
+
+function makeService(opts: {
+ blocks: ReturnType[];
+ assignedCoachIds?: string[];
+ departureAt?: Date | null;
+ bookingSeats?: any[];
+}) {
+ const prisma = {
+ bookingSeat: { findMany: jest.fn().mockResolvedValue(opts.bookingSeats ?? []) },
+ seatHold: { findMany: jest.fn().mockResolvedValue([]) },
+ trainSchedule: {
+ findUnique: jest.fn().mockResolvedValue(
+ opts.departureAt === null ? null : { departureAt: opts.departureAt ?? DEPARTURE },
+ ),
+ },
+ coachAssignment: {
+ findMany: jest
+ .fn()
+ .mockResolvedValue(
+ (opts.assignedCoachIds ?? [passengerCoach.id, diningCoach.id]).map((coachId) => ({
+ coachId,
+ })),
+ ),
+ },
+ seatBlock: { findMany: jest.fn().mockResolvedValue(opts.blocks) },
+ };
+
+ return {
+ service: new ReportsService(prisma as any, {} as any, {} as any),
+ prisma,
+ };
+}
+
+describe('getSeatStatusReport — blocked seats', () => {
+ it('counts a schedule-scoped block on a passenger coach', async () => {
+ const { service } = makeService({ blocks: [blockRow()] });
+
+ const report = await service.getSeatStatusReport(SCHEDULE_ID);
+
+ expect(report.summary.blockedCount).toBe(1);
+ expect(report.blockedSeats).toHaveLength(1);
+ expect(report.blockedSeats[0]).toMatchObject({ coachNumber: 'C1', seatNumber: '1' });
+ });
+
+ it('leaves out a dining coach whose type carries a display name', async () => {
+ const { service } = makeService({
+ blocks: [blockRow({ seat: seatRow('seat-d', diningCoach, '1') })],
+ });
+
+ const report = await service.getSeatStatusReport(SCHEDULE_ID);
+
+ expect(report.summary.blockedCount).toBe(0);
+ expect(report.blockedSeats).toEqual([]);
+ });
+
+ it('leaves out placeholder seats', async () => {
+ const { service } = makeService({
+ blocks: [blockRow({ seat: seatRow('seat-p', passengerCoach, '-1') })],
+ });
+
+ expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
+ });
+
+ it('counts a global block on a coach assigned to this train', async () => {
+ const { service } = makeService({
+ blocks: [blockRow({ scheduleId: null })],
+ assignedCoachIds: [passengerCoach.id],
+ });
+
+ expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(1);
+ });
+
+ it('ignores a global block whose coach never joined this train', async () => {
+ const { service } = makeService({
+ blocks: [blockRow({ scheduleId: null })],
+ assignedCoachIds: ['some-other-coach'],
+ });
+
+ expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
+ });
+
+ it('ignores a global block that had already been lifted by departure', async () => {
+ const { service } = makeService({
+ blocks: [
+ blockRow({
+ scheduleId: null,
+ unblockAt: new Date('2026-03-05T00:00:00.000Z'),
+ }),
+ ],
+ assignedCoachIds: [passengerCoach.id],
+ });
+
+ expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
+ });
+
+ it('ignores a global block created after departure', async () => {
+ const { service } = makeService({
+ blocks: [blockRow({ scheduleId: null, blockedAt: new Date('2026-03-20T00:00:00.000Z') })],
+ assignedCoachIds: [passengerCoach.id],
+ });
+
+ expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
+ });
+
+ it('counts a seat blocked both globally and for this schedule once', async () => {
+ const seat = seatRow('seat-1', passengerCoach, '1');
+ const { service } = makeService({
+ blocks: [
+ blockRow({ id: 'block-schedule', seat, reason: 'Crew seat' }),
+ blockRow({ id: 'block-global', scheduleId: null, seat, reason: 'Broken armrest' }),
+ ],
+ assignedCoachIds: [passengerCoach.id],
+ });
+
+ const report = await service.getSeatStatusReport(SCHEDULE_ID);
+
+ expect(report.summary.blockedCount).toBe(1);
+ // The schedule-scoped block is the more specific statement, so it is the one shown.
+ expect(report.blockedSeats[0].reason).toBe('Crew seat');
+ });
+
+ it('reports nothing blocked when the schedule does not exist', async () => {
+ const { service } = makeService({
+ blocks: [blockRow({ scheduleId: null })],
+ departureAt: null,
+ });
+
+ expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0);
+ });
+
+ it('asks the database only for non-maintenance, non-ticketing blocks', async () => {
+ const { service, prisma } = makeService({ blocks: [] });
+
+ await service.getSeatStatusReport(SCHEDULE_ID);
+
+ const where = prisma.seatBlock.findMany.mock.calls[0][0].where;
+ expect(where.NOT).toEqual([
+ { reason: { startsWith: 'MAINTENANCE:' } },
+ { reason: { startsWith: 'Booked in tickets' } },
+ ]);
+ });
+});
+
+describe('getSeatStatusReport — booked seats', () => {
+ const booking = {
+ bookingRef: 'BK-1',
+ status: 'CONFIRMED',
+ totalMinor: 20000,
+ currency: 'ETB',
+ createdAt: new Date('2026-03-01T00:00:00.000Z'),
+ paymentIntent: { status: 'SUCCEEDED' },
+ };
+
+ it('keeps dining-coach seats out of the paid and unpaid counts', async () => {
+ const { service } = makeService({
+ blocks: [],
+ bookingSeats: [
+ {
+ passengerName: 'Abebe',
+ passengerCategory: 'ADULT',
+ fareMinor: 20000,
+ booking,
+ seat: seatRow('seat-1', passengerCoach, '1'),
+ },
+ {
+ passengerName: 'Diner',
+ passengerCategory: 'ADULT',
+ fareMinor: 0,
+ booking,
+ seat: seatRow('seat-d', diningCoach, '1'),
+ },
+ {
+ passengerName: 'Kebede',
+ passengerCategory: 'ADULT',
+ fareMinor: 20000,
+ booking: { ...booking, status: 'PENDING_PAYMENT', paymentIntent: null },
+ seat: seatRow('seat-2', passengerCoach, '2'),
+ },
+ ],
+ });
+
+ const report = await service.getSeatStatusReport(SCHEDULE_ID);
+
+ expect(report.summary.paidCount).toBe(1);
+ expect(report.summary.unpaidCount).toBe(1);
+ expect(report.paidSeats.map((s) => s.passengerName)).toEqual(['Abebe']);
+ });
+});
diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
index 649796cc5..7eede0089 100644
--- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
@@ -10,9 +10,7 @@ import {
Banknote,
ArrowRight,
ScanLine,
- Ban,
} from "lucide-react";
-import { SEAT_BLOCK_REASON_CATEGORY_LABELS } from "@edr/types";
import { dashboardApi } from "@/lib/api/dashboard";
import { apiClient } from "@/lib/api-client";
import { formatCurrency } from "@/lib/utils";
@@ -163,10 +161,6 @@ function DashboardPageContent() {
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
}, 0);
- const blockedLoss = stats?.blockedSeatRevenueLoss;
- // Never summed across currencies — each is shown on its own line, largest first.
- const blockedLossRows = blockedLoss?.lossByCurrency ?? [];
-
const normalRows = stats?.revenueByCurrency ?? [];
const packageRows = stats?.packageRevenueByCurrency ?? [];
const normalGrand = calcGrand(normalRows);
@@ -326,73 +320,6 @@ function DashboardPageContent() {
>
)}
-
- {/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
- dashboard makes no extra request for it. */}
-
-
-
-
-
-
- Blocked Seats / Revenue Not Collected
-
-
- {blockedLoss?.periodDays ? `Last ${blockedLoss.periodDays}d` : "All time"}
-
-
- {statsLoading ? (
-
Loading…
- ) : (
- <>
- {blockedLossRows.length === 0 ? (
-
- {formatCurrency(0, "ETB")}
-
- ) : (
- blockedLossRows.map((row, i) => (
-
- {formatCurrency(row.estimatedLossMinor, row.currency)}
-
- ))
- )}
-
- Estimated potential revenue never earned
-
-
-
- Seats blocked
-
- {(blockedLoss?.blockedSeatCount ?? 0).toLocaleString()} across{" "}
- {(blockedLoss?.schedulesAffected ?? 0).toLocaleString()} schedules
-
-
-
- Top reason
-
- {blockedLoss?.topReasonCategory
- ? (SEAT_BLOCK_REASON_CATEGORY_LABELS[blockedLoss.topReasonCategory] ??
- blockedLoss.topReasonCategory)
- : "—"}
-
-
-
-
- View full report
-
- >
- )}
-
{/* Revenue breakdown */}
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts
index 0a9737041..bff0eaf08 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts
@@ -1,5 +1,4 @@
import { apiClient } from '@/lib/api-client';
-import type { BlockedSeatRevenueLossStat } from '@edr/types';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
@@ -13,7 +12,6 @@ export const dashboardApi = {
totalPackageTickets: number;
totalPassengers: number;
blockedSeatsCount: number;
- blockedSeatRevenueLoss: BlockedSeatRevenueLossStat;
revenueByCurrency: { currency: string; totalMinor: number }[];
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
}>('/dashboard/backoffice-stats');
diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js
index 1bbfa07ed..651afb46f 100644
--- a/apps/edr-passenger-web/portal/tailwind.config.js
+++ b/apps/edr-passenger-web/portal/tailwind.config.js
@@ -1,4 +1,3 @@
-
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts
new file mode 100644
index 000000000..aabf4f416
--- /dev/null
+++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts
@@ -0,0 +1,22 @@
+import { amountsMatchToTheCent } from "./cbe-bill.service";
+
+describe("amountsMatchToTheCent (CBE payment amount gate)", () => {
+ it("accepts the exact amount", () => {
+ expect(amountsMatchToTheCent(1234.34, 1234.34)).toBe(true);
+ });
+
+ it("rejects a cents-only difference (the 1234.89 vs 1234.34 bug)", () => {
+ expect(amountsMatchToTheCent(1234.89, 1234.34)).toBe(false);
+ expect(amountsMatchToTheCent(1234.35, 1234.34)).toBe(false);
+ });
+
+ it("rejects whole-unit differences", () => {
+ expect(amountsMatchToTheCent(1235.34, 1234.34)).toBe(false);
+ });
+
+ it("absorbs double-precision storage noise", () => {
+ expect(amountsMatchToTheCent(1234.34, 1234.3399999999999)).toBe(true);
+ // classic float artifact: 0.1 + 0.2 !== 0.3
+ expect(amountsMatchToTheCent(0.1 + 0.2, 0.3)).toBe(true);
+ });
+});
diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
index 1c6926b8a..d7f9eb8d6 100644
--- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
+++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
@@ -38,8 +38,15 @@ import {
/** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */
const PG_UNIQUE_VIOLATION = "23505";
-/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
-const AMOUNT_TOLERANCE = 0.01;
+/**
+ * CBE must pay the bill to the exact cent — compare in integer cents so
+ * double-precision storage noise (1234.34 stored as 1234.33999…) can neither
+ * mask nor fabricate a difference. A relative tolerance is wrong here: 1% of a
+ * 1234.34 bill would wave through anything up to ±12.34.
+ */
+export function amountsMatchToTheCent(a: number, b: number): boolean {
+ return Math.round(a * 100) === Math.round(b * 100);
+}
/**
* Translate an intent's own terminal state into the same reason vocabulary the domain apps
@@ -265,11 +272,15 @@ export class CbeBillService {
);
}
+ // Validate against the freshly-quoted amount — the same figure bill-query
+ // just showed the payer — not the intent's amount asserted at creation,
+ // which can go stale when the domain re-prices the invoice. Fallback to
+ // the intent amount only for domain builds that return no current amount.
+ const expectedAmount = billQuery.currentAmountMinor ?? intent.amountMinor;
const amount = Number(dto.Amount);
if (
!Number.isFinite(amount) ||
- Math.abs(amount - intent.amountMinor) >
- intent.amountMinor * AMOUNT_TOLERANCE
+ !amountsMatchToTheCent(amount, expectedAmount)
) {
throw new CbeBillError("Amount mismatch", "BUSINESS");
}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 3659e963f..e40d8b49e 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -4,6 +4,7 @@
#
# Build: DOCKER_BUILDKIT=1 docker compose build
# Run: docker compose up -d
+
services:
freight-api:
build: