Commiting stop based booking

This commit is contained in:
Muluhabt
2026-07-23 20:11:11 +03:00
parent 8bba882710
commit 64e1130f7d
14 changed files with 354 additions and 147 deletions

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ e2e-ui-report/
test-results/
playwright-report/
blob-report/
RUNNING_LOCALLY.md

View File

@@ -1078,6 +1078,7 @@ model RouteStop {
sequence Int
distanceKm Float?
checkinMinutesBefore Int?
travelMinutesToStop Int?
plannedArrivalTime DateTime?
plannedDepartureTime DateTime?
createdAt DateTime @default(now())

View File

@@ -42,7 +42,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@Patch(':id')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' })
@ApiResponse({ status: 404, description: 'Route not found' })

View File

@@ -5,8 +5,9 @@ import { Type } from 'class-transformer';
export class RouteStopInputDto {
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 120.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Ignored for sequence 1 (origin, no predecessor). Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number;
}
export class CreateRouteDto {
@@ -14,8 +15,9 @@ export class CreateRouteDto {
@ApiProperty({ example: 'Addis Ababa Djibouti' }) @IsString() name: string;
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string | null;
@ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route (defaults to 30 if omitted)' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiProperty({
type: [RouteStopInputDto],
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',
@@ -35,15 +37,17 @@ export class CreateRouteDto {
export class AddRouteStopDto {
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 75.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number;
}
export class UpdateRouteDto {
@ApiPropertyOptional({ example: 'Addis Ababa Djibouti Express' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsOptional() @IsDateString() effectiveFrom?: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z', description: 'Send null to clear (open-ended route)' }) @IsOptional() @IsDateString() effectiveUntil?: string | null;
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
import { parseEthiopianTime } from '../../common/utils/timezone.utils';
@Injectable()
export class RoutesService {
@@ -10,6 +11,33 @@ export class RoutesService {
// ── Route CRUD ─────────────────────────────────────────────────────────────
/**
* distanceKm is CUMULATIVE distance from the route origin, not distance from the previous
* stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance
* as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values
* across stops silently produces zero/negative segment distances, which the fare engine
* rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch
* the mistake here instead, with a message that names the exact stops involved.
*/
private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void {
const sorted = [...stops].sort((a, b) => a.sequence - b.sequence);
let prevDistance = sorted[0]?.distanceKm ?? 0;
for (let i = 1; i < sorted.length; i++) {
const stop = sorted[i];
if (stop.distanceKm == null) {
throw new BadRequestException(
`Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`,
);
}
if (stop.distanceKm <= prevDistance) {
throw new BadRequestException(
`Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`,
);
}
prevDistance = stop.distanceKm;
}
}
async createRoute(dto: CreateRouteDto) {
const existing = await this.prisma.route.findUnique({ where: { code: dto.code } });
if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`);
@@ -19,6 +47,8 @@ export class RoutesService {
const seqs = dto.stops.map(s => s.sequence);
if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list');
this.validateStopDistances(dto.stops);
const stationIds = [...new Set(dto.stops.map(s => s.stationId))];
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found');
@@ -29,14 +59,16 @@ export class RoutesService {
name: dto.name,
description: dto.description,
active: dto.active ?? true,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
effectiveFrom: parseEthiopianTime(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null,
stops: {
create: dto.stops.map(s => ({
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
travelMinutesToStop: s.travelMinutesToStop ?? null,
})),
},
},
@@ -86,13 +118,20 @@ export class RoutesService {
const route = await this.prisma.route.findUnique({ where: { id } });
if (!route) throw new NotFoundException('Route not found');
if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops);
await this.prisma.route.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}),
// effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave
// untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy.
...(dto.effectiveUntil !== undefined
? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null }
: {}),
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
},
});
@@ -106,6 +145,7 @@ export class RoutesService {
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
travelMinutesToStop: s.travelMinutesToStop ?? null,
})),
});
}
@@ -218,6 +258,9 @@ export class RoutesService {
});
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } });
this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]);
return this.prisma.routeStop.create({
data: {
routeId,
@@ -225,6 +268,7 @@ export class RoutesService {
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
travelMinutesToStop: dto.travelMinutesToStop ?? null,
},
});
}

View File

@@ -52,6 +52,10 @@ export class CreateScheduleDto {
})
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
}
export class UpdateScheduleDto {

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
@@ -9,6 +9,8 @@ import { AuditService } from '../../common/audit.service';
@Injectable()
export class SchedulesService {
private readonly logger = new Logger(SchedulesService.name);
constructor(
private prisma: PrismaService,
private routesService: RoutesService,
@@ -16,6 +18,44 @@ export class SchedulesService {
private auditService: AuditService,
) { }
/**
* Computes each stop's planned arrival/departure time by walking the route in sequence
* order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the
* previous stop). Falls back to distance-proportional interpolation over `distanceKm` for
* any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed
* overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays
* authoritative even if per-stop estimates drift.
*/
private computePlannedTimes(
route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] },
dep: Date,
arr: Date,
) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
let cursor = dep;
return route.stops.map((stop, index) => {
if (index === 0) {
cursor = dep;
} else if (index === route.stops.length - 1) {
cursor = arr;
} else if (stop.travelMinutesToStop != null) {
cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000);
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
cursor = new Date(dep.getTime() + totalDuration * progress);
this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(),
};
});
}
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
const startDate = parseEthiopianTime(dto.startDateTime);
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
@@ -43,20 +83,14 @@ export class SchedulesService {
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
plannedTimes: dto.plannedTimes || [],
coachIds: dto.coachIds,
};
// createSchedule applies coachIds if given, else auto-applies the route coach template,
// and rejects the day outright (caught below) if it would end up with zero coaches.
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
// createSchedule already auto-applies the route coach template;
// only override if explicit coachIds are provided
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
}
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
@@ -135,26 +169,7 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
plannedTimes = this.computePlannedTimes(route, dep, arr);
}
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
@@ -183,7 +198,14 @@ export class SchedulesService {
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
// Auto-apply route coach template if one is defined
// Explicit coachIds (from the schedule form's Coaches step) override the route's coach
// template; otherwise auto-apply the template if one is defined.
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
} else {
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
where: { routeId: dto.routeId },
orderBy: { positionNumber: 'asc' },
@@ -194,6 +216,18 @@ export class SchedulesService {
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
);
}
}
// A schedule with zero coaches has zero seats and is silently invisible to search (and
// unbookable) with no indication why — block creation instead of leaving a dead schedule.
const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } });
if (assignedCoachCount === 0) {
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } });
await this.prisma.trainSchedule.delete({ where: { id: schedule.id } });
throw new BadRequestException(
'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.',
);
}
const result = await this.getSchedule(schedule.id);
await this.auditService.log({ action: 'CREATE', entityType: 'Schedule', entityId: schedule.id, newData: { trainId: dto.trainId, routeId: dto.routeId, departureAt: dep } });
@@ -306,26 +340,7 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
plannedTimes = this.computePlannedTimes(route, dep, arr);
}
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
@@ -655,11 +670,14 @@ export class SchedulesService {
if (!schedule) throw new NotFoundException('Schedule not found');
const updateData: any = {};
let dep: Date | undefined;
let arr: Date | undefined;
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt);
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
updateData.departureAt = dep;
updateData.arrivalAt = arr;
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
@@ -672,6 +690,22 @@ export class SchedulesService {
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
}
// departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the
// OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left
// unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop
// arrival/departure estimates for every intermediate stop.
if (dep && arr && schedule.routeId) {
const route = await this.prisma.route.findUnique({
where: { id: schedule.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (route && route.stops.length >= 2) {
const plannedTimes = this.computePlannedTimes(route, dep, arr);
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
}
}
if (dto.coaches !== undefined) {
if (dto.coaches.length > 0) {
await this.assignCoaches(id, dto.coaches);

View File

@@ -10,7 +10,7 @@ import { CurrencyService } from "../currency/currency.service";
import { FareEngineService } from "../fare-engine/fare-engine.service";
import { SegmentsService } from "../segments/segments.service";
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
import { Currency } from "@prisma/client";
import { Currency, Prisma } from "@prisma/client";
const POINTS_TO_MINOR = 10;
@@ -220,12 +220,18 @@ export class SearchService {
const totalPassengers = adultCount + (childCount ?? 0);
const NEEDED = 3;
const baseWhere = {
status: "SCHEDULED",
// Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the
// schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) —
// it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable
// (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live
// check against each stop's estimated arrival/departure. Excluding BOARDING here would
// silently impose a hidden, non-configurable 30-minute cutoff on top of that.
const baseWhere: Prisma.TrainScheduleWhereInput = {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
} as const;
};
// Fetch candidates before and after in parallel; take more than needed to
// account for routes that don't serve the destination or have no availability.
@@ -300,23 +306,21 @@ export class SearchService {
const nextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
// Use now as the lower bound for today so we don't fetch schedules that have
// already fully departed. The per-segment cutoff check in buildScheduleResult
// handles the exact check using each stop's own plannedDepartureAt.
const isToday =
now.getFullYear() === y &&
now.getMonth() === m - 1 &&
now.getDate() === d;
const earliest = isToday ? now : date;
// Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here.
// A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g.
// Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would
// wrongly exclude the whole schedule for those still-bookable downstream segments. The
// per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS
// specific origin stop is still bookable, using each stop's own estimated arrival/departure.
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: "SCHEDULED",
// EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
departureAt: { gte: earliest, lt: nextDay },
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
@@ -368,7 +372,8 @@ export class SearchService {
const [leg1Schedules, allCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: {
status: "SCHEDULED",
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
@@ -378,7 +383,7 @@ export class SearchService {
}),
this.prisma.trainSchedule.findMany({
where: {
status: "SCHEDULED",
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
@@ -547,13 +552,14 @@ export class SearchService {
if (!originStop || !destStop || originStop.sequence >= destStop.sequence)
return null;
// Segment-level cutoff: use the origin stop's planned departure, not the
// Segment-level cutoff: use the origin stop's own estimated arrival time, not the
// schedule's overall departureAt (which is station A's time). This lets
// B→D remain bookable even after A→D closes.
// B→D remain bookable even after A→D closes. The first stop has no arrival (nothing
// to arrive at), so it falls back to its own departure.
// Cutoff resolution: stop-level override → route default → 30 min fallback.
const now = new Date();
const segmentDepartureAt =
originStop.plannedDepartureAt ?? schedule.departureAt;
originStop.plannedArrivalAt ?? originStop.plannedDepartureAt ?? schedule.departureAt;
const routeStop = schedule.route?.stops?.find(
(s) => s.stationId === originStationId,
);

View File

@@ -281,7 +281,7 @@ export class SeatsService {
}),
this.prisma.tripStopTime.findFirst({
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
select: { plannedDepartureAt: true },
select: { plannedArrivalAt: true, plannedDepartureAt: true },
}),
this.prisma.routeStop.findFirst({
where: {
@@ -295,7 +295,9 @@ export class SeatsService {
// Stop-level override wins; falls back to route-level; then to 30 min.
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt;
// Arrival basis: the origin stop's own estimated arrival, not its departure. The first
// stop of a route has no arrival (nothing to arrive at), so it falls back to its departure.
const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt;
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
throw new BadRequestException(

View File

@@ -81,17 +81,23 @@ export class TasksService {
byRoute.get(stop.routeId)!.push(stop.stationId);
}
// Arrival basis: each stop's own estimated arrival time, not its departure. The first
// stop of a route has no arrival (nothing to arrive at), so it falls back to its
// departure — expressed below as COALESCE(plannedArrivalAt, plannedDepartureAt).
let reopenedCount = 0;
let checkinClosedCount = 0;
for (const [mins, byRoute] of byMins) {
const cutoffAt = new Date(now.getTime() + mins * 60 * 1000);
for (const [routeId, stationIds] of byRoute) {
// Revert first: if the cutoff was reduced, stops that were prematurely closed
// should reopen (departure is still beyond the new cutoff window).
// should reopen (arrival is still beyond the new cutoff window).
const reverted = await this.prisma.tripStopTime.updateMany({
where: {
status: 'CHECKIN_CLOSED',
plannedDepartureAt: { gt: cutoffAt },
OR: [
{ plannedArrivalAt: { gt: cutoffAt } },
{ AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { gt: cutoffAt } }] },
],
stationId: { in: stationIds },
schedule: { routeId },
},
@@ -103,7 +109,10 @@ export class TasksService {
const closed = await this.prisma.tripStopTime.updateMany({
where: {
status: 'OPEN',
plannedDepartureAt: { lte: cutoffAt },
OR: [
{ plannedArrivalAt: { lte: cutoffAt } },
{ AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { lte: cutoffAt } }] },
],
stationId: { in: stationIds },
schedule: { routeId },
},
@@ -169,8 +178,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
},
@@ -179,12 +188,17 @@ export class TasksService {
for (const booking of bookings) {
try {
const createdAt = booking.createdAt as Date;
// Use the booking's origin-segment departure and the route's own check-in window.
// Use the booking's origin-segment estimated arrival (falling back to its departure
// for the first stop) and that stop's own check-in window (falling back to the route
// default), same resolution as holdSeats/search.
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const originRouteStop = (booking.schedule as any).route?.stops?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
@@ -231,12 +245,27 @@ export class TasksService {
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
// The departure pre-filter below is a query-scoping optimization only — the real
// deadline check happens per-row further down. It must be widened to the largest
// configured checkinMinutes across all routes/stops, or a booking on a route with a
// cutoff bigger than the CUTOFF_MINUTES default would never even be fetched here,
// silently never getting auto-cancelled.
const [maxRouteCutoff, maxStopCutoff] = await Promise.all([
this.prisma.route.aggregate({ _max: { checkinMinutesBefore: true } }),
this.prisma.routeStop.aggregate({ _max: { checkinMinutesBefore: true } }),
]);
const effectiveMaxCutoffMinutes = Math.max(
CUTOFF_MINUTES,
maxRouteCutoff._max.checkinMinutesBefore ?? 0,
maxStopCutoff._max.checkinMinutesBefore ?? 0,
);
const departureCutoff = new Date(now.getTime() + effectiveMaxCutoffMinutes * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, segment_arrival - checkinMinutes)
// Deadline is reached when either branch of the MIN is in the past:
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
// (b) departureAt ≤ now + 30min → departure within 30 min
// (b) departureAt ≤ now + effectiveMaxCutoff → within the widest possible cutoff window
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
@@ -250,8 +279,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
paymentIntent: { select: { method: true } },
@@ -264,14 +293,19 @@ export class TasksService {
for (const booking of expiredBookings) {
try {
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
// Use the booking's origin-segment departure for the deadline so that a B→C booking
// on an A→B→C→D schedule gets the correct payment window anchored to B, not A.
// Use the booking's origin-segment estimated arrival (falling back to its departure
// for the first stop) and that stop's own check-in window, so a B→C booking on an
// A→B→C→D schedule gets the correct payment window anchored to B, not A.
const createdAt = booking.createdAt as Date;
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const originRouteStop = (booking.schedule as any).route?.stops?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
if (now < paymentDeadline) continue;
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)

View File

@@ -23,6 +23,17 @@ export const IDS = {
/** Route stop distances (km from origin). A=0, B=100, C=250 → A→B is 100km, A→C is 250km. */
export const DISTANCE = { A: 0, B: 100, C: 250 } as const;
/**
* Optional per-stop check-in-cutoff/travel-time overrides, keyed by station label (A/B/C).
* Lets a spec seed a distinct `checkinMinutesBefore` override and/or `travelMinutesToStop`
* per stop without changing the zero-arg call sites the other specs rely on.
*/
export interface RouteStopOverrides {
A?: { checkinMinutesBefore?: number; travelMinutesToStop?: number };
B?: { checkinMinutesBefore?: number; travelMinutesToStop?: number };
C?: { checkinMinutesBefore?: number; travelMinutesToStop?: number };
}
/**
* FX rate chosen so the seat-class distance formula (which multiplies an ETB/km rate by the
* USD→ETB rate — see fare-engine.service.ts:157) yields whole ETB-minor amounts. 100 makes the
@@ -45,7 +56,7 @@ export async function truncateAllPassenger(prisma: PrismaClient): Promise<void>
}
/** Insert the deterministic core graph. Call after truncateAllPassenger. */
export async function seedCore(prisma: PrismaClient): Promise<void> {
export async function seedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise<void> {
const past = new Date("2020-01-01T00:00:00.000Z");
await prisma.coachType.create({
@@ -103,9 +114,9 @@ export async function seedCore(prisma: PrismaClient): Promise<void> {
active: true,
stops: {
create: [
{ stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A },
{ stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B },
{ stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C },
{ stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A, ...stopOverrides.A },
{ stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B, ...stopOverrides.B },
{ stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C, ...stopOverrides.C },
],
},
},
@@ -122,7 +133,7 @@ export async function seedCore(prisma: PrismaClient): Promise<void> {
}
/** Convenience: reset + seed in one call. */
export async function resetAndSeedCore(prisma: PrismaClient): Promise<void> {
export async function resetAndSeedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise<void> {
await truncateAllPassenger(prisma);
await seedCore(prisma);
await seedCore(prisma, stopOverrides);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { routesApi } from '@/lib/api/routes';
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
interface RouteStop {
stationId: string;
@@ -17,6 +18,7 @@ interface RouteStop {
distanceKm?: number;
distanceFromOrigin?: number;
checkinMinutesBefore?: number;
travelMinutesToStop?: number;
}
type Tab = 'routes' | 'coaches';
@@ -175,8 +177,18 @@ export default function RoutesPage() {
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
const [destinationTravelMinutes, setDestinationTravelMinutes] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const errorBannerRef = useRef<HTMLDivElement>(null);
// The form scrolls internally (long stop lists push the error banner above the fold), so a
// submit failure can land silently off-screen with no visible indication anything went wrong.
// Scroll the banner into view whenever a new error appears.
useEffect(() => {
if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [error]);
const queryClient = useQueryClient();
const { data: routes, isLoading: routesLoading } = useQuery({
@@ -198,6 +210,11 @@ export default function RoutesPage() {
queryClient.invalidateQueries({ queryKey: ['routes'] });
setShowModal(false);
setEditingRoute(null);
setError(null);
},
onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to create route';
setError(Array.isArray(msg) ? msg.join(' ') : msg);
},
});
@@ -207,6 +224,11 @@ export default function RoutesPage() {
queryClient.invalidateQueries({ queryKey: ['routes'] });
setShowModal(false);
setEditingRoute(null);
setError(null);
},
onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to update route';
setError(Array.isArray(msg) ? msg.join(' ') : msg);
},
});
@@ -244,6 +266,8 @@ export default function RoutesPage() {
const sortedMiddleStops = stops;
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
// travelMinutesToStop = minutes of travel from the PREVIOUS stop, used to estimate this
// stop's arrival time. The origin (sequence 1) has no predecessor, so it gets none.
const stopsArray = [
{ stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined },
...sortedMiddleStops.map((stop, idx) => ({
@@ -251,12 +275,14 @@ export default function RoutesPage() {
sequence: idx + 2,
distanceKm: stop.distanceFromOrigin || 0,
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
travelMinutesToStop: stop.travelMinutesToStop ?? undefined,
})),
{
stationId: destinationStationId,
sequence: sortedMiddleStops.length + 2,
distanceKm: destinationDistance || 0,
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
travelMinutesToStop: destinationTravelMinutes ?? undefined,
},
];
@@ -266,8 +292,10 @@ export default function RoutesPage() {
name: formData.get('name') as string,
description: formData.get('description') as string || undefined,
active: !editingRoute ? (formData.get('active') !== 'false') : undefined,
effectiveFrom: formData.get('effectiveFrom') as string,
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
effectiveFrom: eatLocalToISO(formData.get('effectiveFrom') as string),
// null (not undefined) so clearing the field on an edit explicitly clears effectiveUntil
// server-side, instead of being silently dropped as "no change".
effectiveUntil: formData.get('effectiveUntil') ? eatLocalToISO(formData.get('effectiveUntil') as string) : null,
checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined,
stops: stopsArray,
};
@@ -390,14 +418,17 @@ export default function RoutesPage() {
setDestinationStationId(destStop.stationId);
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
setDestinationDistance(destStop.distanceKm || 0);
setDestinationTravelMinutes(destStop.travelMinutesToStop ?? undefined);
setStops(routeStops.slice(1, -1).map((s: any) => ({
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm,
distanceFromOrigin: s.distanceKm || 0,
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
travelMinutesToStop: s.travelMinutesToStop ?? undefined,
})));
}
setError(null);
setShowModal(true);
} finally {
setEditLoading(false);
@@ -436,7 +467,9 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationCheckinMinutes(undefined);
setDestinationDistance(undefined);
setDestinationTravelMinutes(undefined);
setStops([]);
setError(null);
setShowModal(true);
}}
>
@@ -515,13 +548,20 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationCheckinMinutes(undefined);
setDestinationDistance(undefined);
setDestinationTravelMinutes(undefined);
setStops([]);
setSearch('');
setError(null);
}}
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
{error && (
<div ref={errorBannerRef} className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
{error}
</div>
)}
{editingRoute && (
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold"> Warning</p>
@@ -647,7 +687,7 @@ export default function RoutesPage() {
type="datetime-local"
name="effectiveFrom"
className="input"
defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)}
defaultValue={editingRoute?.effectiveFrom ? isoToEATLocal(editingRoute.effectiveFrom) : isoToEATLocal(new Date())}
required
/>
</div>
@@ -657,7 +697,7 @@ export default function RoutesPage() {
type="datetime-local"
name="effectiveUntil"
className="input"
defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''}
defaultValue={editingRoute?.effectiveUntil ? isoToEATLocal(editingRoute.effectiveUntil) : ''}
/>
</div>
</div>
@@ -665,7 +705,7 @@ export default function RoutesPage() {
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span>
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Travel min</span> estimates arrival from the previous stop (falls back to distance if blank) · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span>
</div>
<div className="space-y-2">
@@ -741,6 +781,17 @@ export default function RoutesPage() {
required
/>
</div>
<div className="w-28">
<input
type="number"
className="input input-sm"
placeholder="travel min"
value={stop.travelMinutesToStop ?? ''}
onChange={(e) => updateStop(index, 'travelMinutesToStop', e.target.value ? parseInt(e.target.value) : undefined)}
min={1}
title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time"
/>
</div>
<div className="w-28">
<input
type="number"
@@ -817,6 +868,19 @@ export default function RoutesPage() {
/>
)}
</div>
<div className="w-28">
{destinationStationId && (
<input
type="number"
className="input input-sm"
placeholder="travel min"
value={destinationTravelMinutes ?? ''}
onChange={(e) => setDestinationTravelMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
min={1}
title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time"
/>
)}
</div>
</div>
</div>
</div>
@@ -833,8 +897,10 @@ export default function RoutesPage() {
setDestinationStationId('');
setDestinationCheckinMinutes(undefined);
setDestinationDistance(undefined);
setDestinationTravelMinutes(undefined);
setStops([]);
setSearch('');
setError(null);
}}
>
Cancel

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
@@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api';
import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination';
import { formatDateTime } from '@/lib/utils';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
import DateTimePicker from '@/components/ui/DateTimePicker';
interface Schedule {
@@ -60,8 +61,15 @@ export default function SchedulesPage() {
{ isOpen: false, item: null }
);
const [error, setError] = useState<string | null>(null);
const errorBannerRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
// These modals can scroll internally — a submit failure can land silently off-screen with no
// visible indication anything went wrong. Scroll the banner into view when a new error appears.
useEffect(() => {
if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [error]);
const [bulkForm, setBulkForm] = useState({
trainId: '',
routeId: '',
@@ -167,7 +175,8 @@ export default function SchedulesPage() {
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to generate schedules');
const msg = err.response?.data?.message || 'Failed to generate schedules';
setError(Array.isArray(msg) ? msg.join(' ') : msg);
},
});
@@ -181,7 +190,8 @@ export default function SchedulesPage() {
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to create schedule');
const msg = err.response?.data?.message || 'Failed to create schedule';
setError(Array.isArray(msg) ? msg.join(' ') : msg);
},
});
@@ -195,7 +205,8 @@ export default function SchedulesPage() {
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update schedule');
const msg = err.response?.data?.message || 'Failed to update schedule';
setError(Array.isArray(msg) ? msg.join(' ') : msg);
},
});
@@ -229,20 +240,6 @@ export default function SchedulesPage() {
},
});
/** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */
const eatLocalToISO = (local: string): string => {
if (!local) return '';
return new Date(local + ':00+03:00').toISOString();
};
/** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */
const isoToEATLocal = (iso: string): string => {
if (!iso) return '';
const utcMs = new Date(iso).getTime();
const eatMs = utcMs + 3 * 60 * 60 * 1000;
return new Date(eatMs).toISOString().slice(0, 16);
};
const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
@@ -685,7 +682,7 @@ export default function SchedulesPage() {
size="xl"
>
<form onSubmit={handleAddSubmit} className="space-y-4">
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
{error && <div ref={errorBannerRef} className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div>
@@ -803,7 +800,7 @@ export default function SchedulesPage() {
>
<form onSubmit={handleBulkSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
<div ref={errorBannerRef} className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
{error}
</div>
)}
@@ -1022,7 +1019,7 @@ export default function SchedulesPage() {
{editingSchedule && (
<form onSubmit={handleEditSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
<div ref={errorBannerRef} className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
{error}
</div>
)}

View File

@@ -135,15 +135,18 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit {
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
// "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
// wrongly resolved off a "no info" response.
// "Failed to get transaction info") with no status — i.e. the payer hasn't done anything at
// the hosted page yet. That's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING:
// returning PROCESSING here would let the reconciliation sweep persist that guess and block
// the payer from switching providers on a session they never touched (see cac-bank.provider's
// queryStatus for the same convention). The intent still resolves correctly either way — via
// the webhook on a genuine payment, or via expiresAt once the 5-minute HPP session lapses.
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
this.logger.warn(
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as still awaiting the payer`,
);
return {
status: ProviderPaymentStatus.PROCESSING,
status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: response as unknown as Record<string, unknown>,
};
}