diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts index 87dee3ec8..093436aae 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts @@ -31,7 +31,7 @@ export class CurrenciesController { } @Delete(':id') - @PassengerAdmin() + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') deleteCurrency(@Param('id') id: string) { return this.currenciesService.deleteCurrency(id); diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index 99fee3cdf..388ed2cda 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -13,7 +13,7 @@ export class CurrenciesService { async getAllCurrencies() { const rates = await this.prisma.currencyExchangeRate.findMany({ distinct: ['toCurrency'], - orderBy: { toCurrency: 'asc' }, + orderBy: { createdAt: 'desc' }, }); const base = { @@ -83,12 +83,14 @@ export class CurrenciesService { throw new BadRequestException('Exchange rate must be positive'); } - const updated = await this.prisma.currencyExchangeRate.update({ - where: { id }, - data: { - rate: dto.exchangeRate, - }, - }); + // Upsert today's record so getRateOrThrow (orderBy effectiveDate desc) picks it up + const updated = await this.currencyService.upsertRate( + existing.fromCurrency, + existing.toCurrency, + dto.exchangeRate ?? Number(existing.rate), + undefined, + 'MANUAL', + ); return { id: updated.id, @@ -112,8 +114,9 @@ export class CurrenciesService { throw new NotFoundException('Currency not found'); } - await this.prisma.currencyExchangeRate.delete({ - where: { id }, + // Delete all records for this currency pair so no stale rates remain + await this.prisma.currencyExchangeRate.deleteMany({ + where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency }, }); return { message: 'Currency deleted successfully' }; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5e44038c4..4b0284935 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -99,6 +99,8 @@ export class PaymentsService { priceTierId: true, adultCount: true, childCount: true, + totalMinor: true, + currency: true, priceTier: { select: { priceMinor: true } }, }, }, @@ -129,7 +131,7 @@ export class PaymentsService { id: item.id, reference: item.id.substring(0, 8), bookingId: item.bookingId, - booking: { bookingRef: b?.bookingRef }, + booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency }, amountMinor, currency: item.currency, method: item.method, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index b26dcb763..3d485d22c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -837,13 +837,16 @@ export class SeatsService { async removeSeat(seatId: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - if (!seat.seatNumber) throw new BadRequestException('Seat already removed'); + if (!seat.seatNumber || seat.seatNumber.startsWith('-')) throw new BadRequestException('Seat already removed'); + // Mark as removed, then renumber all active seats in the coach await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: `-${seat.seatNumber}` }, }); + await this.renumberCoachSeats(seat.coachId); + return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; } @@ -854,10 +857,38 @@ export class SeatsService { throw new BadRequestException('Seat is not removed'); } - const originalNumber = seat.seatNumber.slice(1); - await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } }); + // Restore with a temporary placeholder number, then renumber + await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: `__restore__${seatId}` } }); + await this.renumberCoachSeats(seat.coachId); - return { restored: true, seatId, seatNumber: originalNumber }; + const restored = await this.prisma.seat.findUnique({ where: { id: seatId } }); + return { restored: true, seatId, seatNumber: restored?.seatNumber }; + } + + /** + * Renumbers all active (non-removed) seats in a coach sequentially starting from 1, + * ordered by row then col. Removed seats (prefixed with "-") keep their slot but + * are excluded from the numbering sequence so numbers remain continuous. + */ + private async renumberCoachSeats(coachId: string): Promise { + const allSeats = await this.prisma.seat.findMany({ + where: { coachId }, + orderBy: [{ row: 'asc' }, { col: 'asc' }], + select: { id: true, seatNumber: true }, + }); + + const activeSeats = allSeats.filter( + (s) => s.seatNumber && !s.seatNumber.startsWith('-') && !s.seatNumber.startsWith('__restore__'), + ); + + await Promise.all( + activeSeats.map((s, idx) => + this.prisma.seat.update({ + where: { id: s.id }, + data: { seatNumber: String(idx + 1) }, + }), + ), + ); } @Cron(CronExpression.EVERY_MINUTE) diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts index 1661d2027..3cfa1beb8 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma.service'; export const CONFIG_KEYS = { SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', + BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure', THROTTLE_AUTH_LIMIT: 'throttle_auth_limit', THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms', THROTTLE_STRICT_LIMIT: 'throttle_strict_limit', @@ -15,6 +16,7 @@ export const CONFIG_KEYS = { const DEFAULTS: Record = { [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', + [CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4', [CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5', [CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000', [CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20', diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts index 972668e18..1ccc2e392 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts @@ -3,9 +3,10 @@ import { TicketsController } from './tickets.controller'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; import { NotificationsModule } from '../notifications/notifications.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, SystemConfigModule], controllers: [TicketsController], providers: [TicketsService, JwtGuard], exports: [TicketsService, JwtGuard], diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 4a1f7b36e..1a9c88d4e 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import * as QRCode from 'qrcode'; interface OfflineValidation { @@ -20,6 +21,7 @@ export class TicketsService { constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, + private readonly systemConfig: SystemConfigService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -423,26 +425,20 @@ export class TicketsService { // Check if ticket date matches today const today = new Date(); - const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format if ((booking as any).schedule?.departureAt) { - const departureDate = new Date((booking as any).schedule.departureAt); - const departureDateStr = departureDate.toISOString().split('T')[0]; - - // Check if ticket is for today - if (departureDateStr !== todayDateStr) { - if (departureDateStr < todayDateStr) { - throw new BadRequestException('Ticket has expired - departure date has passed'); - } else { - throw new BadRequestException('Ticket is for a future date - cannot board early'); - } - } - - // Additional check: ticket expires 4 hours after departure time const departureTime = new Date((booking as any).schedule.departureAt); - const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure - if (today > expiryTime) { - throw new BadRequestException('Ticket has expired - boarding window closed'); + const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE); + const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000); + + if (today < boardingOpenTime) { + throw new BadRequestException( + `Boarding opens ${boardingWindowHours} hour(s) before departure at ${boardingOpenTime.toISOString()}`, + ); + } + + if (today >= departureTime) { + throw new BadRequestException('Boarding is closed — departure time has passed'); } } diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index ea45fe321..3133a87e4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -254,12 +254,16 @@ function BookingsPageContent() { }, { key: 'contact', label: 'Primary contact', - render: (booking: any) => ( -
-
{booking.contactPhone || booking.passenger?.phone}
-
{booking.contactEmail || booking.passenger?.email}
-
- ), + render: (booking: any) => { + const phone = booking.contactPhone || booking.passenger?.phone || '—'; + const email = booking.contactEmail || booking.passenger?.email || '—'; + return ( +
+
{phone}
+
{email}
+
+ ); + }, }, { key: 'paymentStatus', label: 'Payment', diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index cee6944f6..edf23692a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -146,7 +146,13 @@ export default function CurrenciesPage() { const actions = [ { label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit }, - { label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 }, + { + label: 'Delete', + onClick: (c: CurrencyRate) => setDeleteConfirm(c), + variant: 'danger' as const, + icon: Trash2, + show: (c: CurrencyRate) => c.id !== 'etb-base', + }, ]; return ( diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx index 30639e82d..17eb6d0e6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -93,7 +93,7 @@ export default function PaymentsPage() { switch (key) { case 'reference': return payment.reference || payment.id?.substring(0, 8) || ''; case 'booking': return payment.booking?.bookingRef || 'N/A'; - case 'amount': return formatCurrency(payment.amountMinor, payment.currency); + case 'amount': return formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB'); case 'method': return payment.method || ''; case 'status': return payment.status || ''; case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : ''; @@ -116,7 +116,7 @@ export default function PaymentsPage() { const columns = [ { key: 'reference', label: 'Reference', render: (payment: any) => {payment.reference || payment.id?.substring(0, 8)} }, { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, - { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) }, + { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB') }, { key: 'method', label: 'Method', render: (payment: any) => {payment.method} }, { key: 'status', label: 'Status', render: (payment: any) => {payment.status} }, { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, @@ -204,7 +204,7 @@ export default function PaymentsPage() {
{[ - { label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) }, + { label: 'Amount', value: formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB') }, { label: 'Method', value: p.method || '—' }, { label: 'Booking', value: p.booking?.bookingRef || '—' }, ].map(({ label, value }) => ( @@ -222,7 +222,7 @@ export default function PaymentsPage() {

Amount

-

{formatCurrency(p.amountMinor, p.currency || 'ETB')}

+

{formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB')}

diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 16f262b8e..3f0a266fd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -10,6 +10,7 @@ export default function SettingsPage() { const [activeTab, setActiveTab] = useState('general'); const [seatHoldMinutes, setSeatHoldMinutes] = useState('5'); const [holdCutoffHours, setHoldCutoffHours] = useState('2'); + const [boardingWindowHours, setBoardingWindowHours] = useState('4'); const [throttleAuthLimit, setThrottleAuthLimit] = useState('5'); const [throttleStrictLimit, setThrottleStrictLimit] = useState('20'); const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100'); @@ -24,6 +25,7 @@ export default function SettingsPage() { .then((data) => { if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes); if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure); + if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure); if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit); if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit); if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit); @@ -39,6 +41,7 @@ export default function SettingsPage() { await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes, hold_cutoff_hours_before_departure: holdCutoffHours, + boarding_window_hours_before_departure: boardingWindowHours, throttle_auth_limit: throttleAuthLimit, throttle_strict_limit: throttleStrictLimit, throttle_default_limit: throttleDefaultLimit, @@ -184,6 +187,23 @@ export default function SettingsPage() { Seat holds are rejected when this many hours or fewer remain before departure. Default: 2 hours.

+
+ + setBoardingWindowHours(e.target.value)} + /> +

+ Boarding opens this many hours before departure and closes exactly at departure time. Default: 4 hours. +

+
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index a18aab817..30e5393f9 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -848,7 +848,7 @@ export default function SearchPage() { etc.) are portaled to — see ModernDatePicker — so they aren't capped by this wrapper's own stacking context. ── */}