Merge branch 'dev' into alpha

This commit is contained in:
Abubeker Yasin
2026-08-10 14:38:44 +03:00
439 changed files with 42473 additions and 6778 deletions

View File

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

View File

@@ -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<BlockedSeatRevenueLossStat> {
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([

View File

@@ -40,6 +40,7 @@ describe("PaymentsService", () => {
findUniqueOrThrow: jest.fn(),
upsert: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
create: jest.fn(),
},
paymentMethod: {
@@ -81,6 +82,7 @@ describe("PaymentsService", () => {
const mockPaymentClient = {
initiate: jest.fn(),
getIntentByReference: jest.fn(),
reconcileByReference: jest.fn(),
};
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
@@ -242,6 +244,14 @@ describe("PaymentsService", () => {
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
// syncIntentProjection applies the status in a separate guarded write (never demoting a
// SUCCEEDED row), then reads the projection back — so this is what it returns.
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
const result = await service.initiatePayment({
bookingId: "booking-1",
@@ -479,6 +489,14 @@ describe("PaymentsService", () => {
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
// See above: the projection is read back after the guarded status write.
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: "PSG-MERCH-123",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
});
const result = await service.getIntentByBookingId("booking-1");
@@ -499,4 +517,49 @@ describe("PaymentsService", () => {
);
});
});
// Regression: a booking confirmed between a sweep's candidate query and its turn in the loop
// used to have its SUCCEEDED projection demoted to PROCESSING by the re-sync, with nothing
// able to restore it (finalizePaymentSuccess only writes SUCCEEDED while the booking is still
// PENDING_PAYMENT). A later stale payment.failed from an abandoned sibling attempt could then
// push that same row to FAILED, because markPaymentFailed only shields SUCCEEDED/CANCELLED.
describe("confirmed-booking projection integrity", () => {
it("does not re-sync or cancel a booking confirmed since the caller's snapshot", async () => {
mockPrisma.booking.findUnique.mockResolvedValue({ status: "CONFIRMED" });
const result = await service.reconcileAndConfirmIfPaid("booking-1");
expect(result).toEqual({ paid: true, verified: true });
// Neither the payment service nor the projection is touched.
expect(mockPaymentClient.reconcileByReference).not.toHaveBeenCalled();
expect(mockPrisma.paymentIntent.upsert).not.toHaveBeenCalled();
});
it("writes the mirrored status only where the row is not already SUCCEEDED", async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPaymentClient.getIntentByReference.mockResolvedValue(
requiresActionSnapshot(ProviderMethod.TELEBIRR),
);
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: "intent-1",
bookingId: "booking-1",
status: PaymentIntentStatus.REQUIRES_ACTION,
});
await service.getIntentByBookingId("booking-1");
// The upsert must never carry a status on its update path...
const upsertArg = mockPrisma.paymentIntent.upsert.mock.calls[0][0];
expect(upsertArg.update).not.toHaveProperty("status");
// ...the status arrives through a write guarded on the row not being SUCCEEDED, which is
// what makes demoting the confirming payment structurally impossible.
expect(mockPrisma.paymentIntent.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: { not: PaymentIntentStatus.SUCCEEDED },
}),
}),
);
});
});
});

View File

@@ -668,12 +668,13 @@ export class PaymentsService {
bookingId: string,
snapshot: PaymentIntentSnapshot,
) {
// Writing SUCCEEDED is finalizePaymentSuccess's job alone — it is the only place that can
// enforce confirm-once atomically — so a SUCCEEDED snapshot syncs as PROCESSING here.
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? PaymentIntentStatus.PROCESSING
: (snapshot.status as unknown as PaymentIntentStatus);
const data = {
status,
method: snapshot.provider as unknown as PaymentMethodType,
merchantOrderId: snapshot.merchantOrderId,
clientAction: snapshot.clientAction
@@ -688,7 +689,7 @@ export class PaymentsService {
.providerResponse as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
};
return this.prisma.paymentIntent.upsert({
await this.prisma.paymentIntent.upsert({
where: { bookingId },
// amountMinor/currency are refreshed on update too: a cross-currency method switch
// (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
@@ -702,9 +703,17 @@ export class PaymentsService {
bookingId,
amountMinor: snapshot.amountMinor,
currency: snapshot.currency,
status,
...data,
},
});
await this.prisma.paymentIntent.updateMany({
where: { bookingId, status: { not: PaymentIntentStatus.SUCCEEDED } },
data: { status },
});
return this.prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId } });
}
private async initiateWalletPayment(
@@ -1111,6 +1120,14 @@ export class PaymentsService {
async reconcileAndConfirmIfPaid(
bookingId: string,
): Promise<{ paid: boolean; verified: boolean }> {
const current = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: { status: true },
});
if (current?.status === "CONFIRMED") {
return { paid: true, verified: true };
}
const settlement = await this.paymentClient.reconcileByReference(
PaymentReferenceType.BOOKING,
bookingId,
@@ -1247,12 +1264,31 @@ export class PaymentsService {
});
if (confirmed === 0) {
// Booking already confirmed by another payment (or not payable and not forced). This capture
// is registered on the payment-api ledger; do not confirm, ticket, or touch this row.
this.logger.error(
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
);
const recordsConfirmingCapture =
booking.status === "CONFIRMED" && intent.paidAt != null;
if (recordsConfirmingCapture) {
const { count } = await this.prisma.paymentIntent.updateMany({
where: { id: intent.id, status: { not: PaymentIntentStatus.SUCCEEDED } },
data: { status: PaymentIntentStatus.SUCCEEDED },
});
if (count > 0) {
this.logger.warn(
`Restored demoted payment projection for booking ${booking.id} ` +
`(intent ${intent.id}): ${intent.status} → SUCCEEDED`,
);
}
}
const duplicateCapture =
input.providerTxnId != null &&
intent.providerTxnId != null &&
input.providerTxnId !== intent.providerTxnId;
if (duplicateCapture || !recordsConfirmingCapture) {
this.logger.error(
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
);
}
return { alreadyFinalized: true };
}

View File

@@ -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<LossBlock, 'blockedAt' | 'unblockAt'>,
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<LossSeat, 'seatNumber'>): 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<LossCoach, 'coachTypeType' | 'coachTypeName'>,
): boolean {
export function isDiningCoach(coach: {
coachTypeType?: string | null;
coachTypeName?: string | null;
}): boolean {
const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase();
return haystack.includes('dining');
}

View File

@@ -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<string, (typeof blockRows)[number]>();
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,
})),
};
}

View File

@@ -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<typeof seatRow>;
}> = {},
) {
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<typeof blockRow>[];
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']);
});
});