This commit is contained in:
Roba Boru
2026-07-11 00:45:37 +03:00
12 changed files with 110 additions and 45 deletions

View File

@@ -31,7 +31,7 @@ export class CurrenciesController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerStaff(PASSENGER_PERMS.currencies.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
deleteCurrency(@Param('id') id: string) { deleteCurrency(@Param('id') id: string) {
return this.currenciesService.deleteCurrency(id); return this.currenciesService.deleteCurrency(id);

View File

@@ -13,7 +13,7 @@ export class CurrenciesService {
async getAllCurrencies() { async getAllCurrencies() {
const rates = await this.prisma.currencyExchangeRate.findMany({ const rates = await this.prisma.currencyExchangeRate.findMany({
distinct: ['toCurrency'], distinct: ['toCurrency'],
orderBy: { toCurrency: 'asc' }, orderBy: { createdAt: 'desc' },
}); });
const base = { const base = {
@@ -83,12 +83,14 @@ export class CurrenciesService {
throw new BadRequestException('Exchange rate must be positive'); throw new BadRequestException('Exchange rate must be positive');
} }
const updated = await this.prisma.currencyExchangeRate.update({ // Upsert today's record so getRateOrThrow (orderBy effectiveDate desc) picks it up
where: { id }, const updated = await this.currencyService.upsertRate(
data: { existing.fromCurrency,
rate: dto.exchangeRate, existing.toCurrency,
}, dto.exchangeRate ?? Number(existing.rate),
}); undefined,
'MANUAL',
);
return { return {
id: updated.id, id: updated.id,
@@ -112,8 +114,9 @@ export class CurrenciesService {
throw new NotFoundException('Currency not found'); throw new NotFoundException('Currency not found');
} }
await this.prisma.currencyExchangeRate.delete({ // Delete all records for this currency pair so no stale rates remain
where: { id }, await this.prisma.currencyExchangeRate.deleteMany({
where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency },
}); });
return { message: 'Currency deleted successfully' }; return { message: 'Currency deleted successfully' };

View File

@@ -99,6 +99,8 @@ export class PaymentsService {
priceTierId: true, priceTierId: true,
adultCount: true, adultCount: true,
childCount: true, childCount: true,
totalMinor: true,
currency: true,
priceTier: { select: { priceMinor: true } }, priceTier: { select: { priceMinor: true } },
}, },
}, },
@@ -129,7 +131,7 @@ export class PaymentsService {
id: item.id, id: item.id,
reference: item.id.substring(0, 8), reference: item.id.substring(0, 8),
bookingId: item.bookingId, bookingId: item.bookingId,
booking: { bookingRef: b?.bookingRef }, booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
amountMinor, amountMinor,
currency: item.currency, currency: item.currency,
method: item.method, method: item.method,

View File

@@ -837,13 +837,16 @@ export class SeatsService {
async removeSeat(seatId: string) { async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found'); 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({ await this.prisma.seat.update({
where: { id: seatId }, where: { id: seatId },
data: { seatNumber: `-${seat.seatNumber}` }, data: { seatNumber: `-${seat.seatNumber}` },
}); });
await this.renumberCoachSeats(seat.coachId);
return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
} }
@@ -854,10 +857,38 @@ export class SeatsService {
throw new BadRequestException('Seat is not removed'); throw new BadRequestException('Seat is not removed');
} }
const originalNumber = seat.seatNumber.slice(1); // Restore with a temporary placeholder number, then renumber
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } }); 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<void> {
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) @Cron(CronExpression.EVERY_MINUTE)

View File

@@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = { export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', 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_LIMIT: 'throttle_auth_limit',
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms', THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit', THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
@@ -15,6 +16,7 @@ export const CONFIG_KEYS = {
const DEFAULTS: Record<string, string> = { const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', [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_LIMIT]: '5',
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000', [CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20', [CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',

View File

@@ -3,9 +3,10 @@ import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule, SystemConfigModule],
controllers: [TicketsController], controllers: [TicketsController],
providers: [TicketsService, JwtGuard], providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard], exports: [TicketsService, JwtGuard],

View File

@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
interface OfflineValidation { interface OfflineValidation {
@@ -20,6 +21,7 @@ export class TicketsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly systemConfig: SystemConfigService,
@InjectDataSource() private readonly dataSource: DataSource, @InjectDataSource() private readonly dataSource: DataSource,
) {} ) {}
@@ -423,26 +425,20 @@ export class TicketsService {
// Check if ticket date matches today // Check if ticket date matches today
const today = new Date(); const today = new Date();
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
if ((booking as any).schedule?.departureAt) { if ((booking as any).schedule?.departureAt) {
const departureDate = new Date((booking as any).schedule.departureAt); const departureTime = new Date((booking as any).schedule.departureAt);
const departureDateStr = departureDate.toISOString().split('T')[0]; const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
// Check if ticket is for today if (today < boardingOpenTime) {
if (departureDateStr !== todayDateStr) { throw new BadRequestException(
if (departureDateStr < todayDateStr) { `Boarding opens ${boardingWindowHours} hour(s) before departure at ${boardingOpenTime.toISOString()}`,
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 if (today >= departureTime) {
const departureTime = new Date((booking as any).schedule.departureAt); throw new BadRequestException('Boarding is closed — departure time has passed');
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');
} }
} }

View File

@@ -254,12 +254,16 @@ function BookingsPageContent() {
}, },
{ {
key: 'contact', label: 'Primary contact', key: 'contact', label: 'Primary contact',
render: (booking: any) => ( render: (booking: any) => {
<div> const phone = booking.contactPhone || booking.passenger?.phone || '—';
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div> const email = booking.contactEmail || booking.passenger?.email || '—';
<div className="text-sm text-muted-foreground">{booking.contactEmail || booking.passenger?.email}</div> return (
</div> <div>
), <div className="font-medium">{phone}</div>
<div className="text-sm text-muted-foreground truncate" title={email}>{email}</div>
</div>
);
},
}, },
{ {
key: 'paymentStatus', label: 'Payment', key: 'paymentStatus', label: 'Payment',

View File

@@ -146,7 +146,13 @@ export default function CurrenciesPage() {
const actions = [ const actions = [
{ label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit }, { 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 ( return (

View File

@@ -93,7 +93,7 @@ export default function PaymentsPage() {
switch (key) { switch (key) {
case 'reference': return payment.reference || payment.id?.substring(0, 8) || ''; case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
case 'booking': return payment.booking?.bookingRef || 'N/A'; 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 'method': return payment.method || '';
case 'status': return payment.status || ''; case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : ''; case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : '';
@@ -116,7 +116,7 @@ export default function PaymentsPage() {
const columns = [ const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> }, { key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, { 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) => <Badge>{payment.method}</Badge> }, { key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> }, { key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
@@ -204,7 +204,7 @@ export default function PaymentsPage() {
</div> </div>
<div className="mt-4 grid grid-cols-3 gap-3"> <div className="mt-4 grid grid-cols-3 gap-3">
{[ {[
{ label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) }, { label: 'Amount', value: formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB') },
{ label: 'Method', value: p.method || '—' }, { label: 'Method', value: p.method || '—' },
{ label: 'Booking', value: p.booking?.bookingRef || '—' }, { label: 'Booking', value: p.booking?.bookingRef || '—' },
].map(({ label, value }) => ( ].map(({ label, value }) => (
@@ -222,7 +222,7 @@ export default function PaymentsPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3 col-span-2"> <div className="bg-muted/40 rounded-lg p-3 col-span-2">
<p className="text-xs text-muted-foreground mb-1">Amount</p> <p className="text-xs text-muted-foreground mb-1">Amount</p>
<p className="text-xl font-bold">{formatCurrency(p.amountMinor, p.currency || 'ETB')}</p> <p className="text-xl font-bold">{formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB')}</p>
</div> </div>
<Field label="Method" value={p.method} /> <Field label="Method" value={p.method} />
<Field label="Status" value={p.status} /> <Field label="Status" value={p.status} />

View File

@@ -10,6 +10,7 @@ export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general'); const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5'); const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [holdCutoffHours, setHoldCutoffHours] = useState('2'); const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5'); const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
const [throttleStrictLimit, setThrottleStrictLimit] = useState('20'); const [throttleStrictLimit, setThrottleStrictLimit] = useState('20');
const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100'); const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100');
@@ -24,6 +25,7 @@ export default function SettingsPage() {
.then((data) => { .then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes); 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?.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_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit); if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit);
if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit); if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit);
@@ -39,6 +41,7 @@ export default function SettingsPage() {
await systemConfigApi.update({ await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes, seat_hold_duration_minutes: seatHoldMinutes,
hold_cutoff_hours_before_departure: holdCutoffHours, hold_cutoff_hours_before_departure: holdCutoffHours,
boarding_window_hours_before_departure: boardingWindowHours,
throttle_auth_limit: throttleAuthLimit, throttle_auth_limit: throttleAuthLimit,
throttle_strict_limit: throttleStrictLimit, throttle_strict_limit: throttleStrictLimit,
throttle_default_limit: throttleDefaultLimit, 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. Seat holds are rejected when this many hours or fewer remain before departure. Default: 2 hours.
</p> </p>
</div> </div>
<div className="max-w-sm space-y-2">
<label className="label" htmlFor="boarding-window">
Boarding Window Before Departure (hours)
</label>
<input
id="boarding-window"
type="number"
min="1"
max="24"
className="input"
value={boardingWindowHours}
onChange={(e) => setBoardingWindowHours(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Boarding opens this many hours before departure and closes exactly at departure time. Default: 4 hours.
</p>
</div>
</> </>
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -848,7 +848,7 @@ export default function SearchPage() {
etc.) are portaled to <body> — see ModernDatePicker — so they aren't etc.) are portaled to <body> — see ModernDatePicker — so they aren't
capped by this wrapper's own stacking context. ── */} capped by this wrapper's own stacking context. ── */}
<div <div
className="relative pt-4 pb-4 md:pt-0 md:pb-0 md:absolute md:bottom-8 md:left-0 md:right-0 z-[35] px-4 md:px-6" className="relative pt-4 pb-4 md:pt-0 md:pb-0 md:absolute md:bottom-8 md:left-0 md:right-0 z-[30] px-4 md:px-6"
ref={widgetRef} ref={widgetRef}
> >
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">