setSelectedMethod(method.type)}
+ onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
disabled={isProcessing || !method.enabled}
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
isSelected
@@ -385,7 +409,10 @@ export default function PaymentPage() {
Total
- {displayCurrency} {(totalAmount / 100).toFixed(2)}
+
+ {loadingAmount && }
+ {confirmedCurrency} {(totalAmount / 100).toFixed(2)}
+
{paymentError && (
β οΈ {paymentError}
@@ -397,15 +424,19 @@ export default function PaymentPage() {
{isProcessing ? (
Processing...
+ ) : loadingAmount ? (
+
+ Calculating...
+
) : (
- `Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}`
+ `Pay ${confirmedCurrency} ${(totalAmount / 100).toFixed(2)}`
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
index 10eab03e0..2f053d98c 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
@@ -144,8 +144,8 @@ export default function ResultsPage() {
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0;
- const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => {
- setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } }));
+ const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
+ setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
};
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
@@ -161,10 +161,9 @@ export default function ResultsPage() {
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code);
// Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed.
const minFare = coachType?.classes.length
- ? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor))
+ ? Math.min(...coachType.classes.map(c => c.baseFareMinor))
: 0;
- const fareCurrency: string =
- coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB';
+ const fareCurrency = 'ETB';
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
const minutes = (schedule.durationMinutes || 0) % 60;
@@ -186,6 +185,7 @@ export default function ResultsPage() {
selectedCoachTypeId: selectedCoachType.id,
selectedCoachTypeCode: selectedCoachType.code,
selectedCoachTypeName: selectedCoachType.name,
+ seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name,
};
// For round trip, store outbound and wait for inbound selection
@@ -222,17 +222,13 @@ export default function ResultsPage() {
// Calculate lowest fare and display currency from coach types / faresByClass.
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
let lowestFare = null;
- let displayCurrency = schedule.displayCurrency || 'ETB';
+ const displayCurrency = 'ETB';
if (schedule.coachTypes?.length) {
const allClasses = schedule.coachTypes.flatMap(ct => ct.classes);
- const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0);
+ const allFares = allClasses.map(c => c.baseFareMinor).filter(f => f > 0);
lowestFare = allFares.length ? Math.min(...allFares) : null;
- const firstWithCurrency = allClasses.find(c => c.displayCurrency);
- if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
} else if (schedule.faresByClass?.length) {
- lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0));
- const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency);
- if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
+ lowestFare = Math.min(...schedule.faresByClass.map(f => f.baseFareMinor).filter(f => f > 0));
} else if (schedule.combinedMinFareDisplay) {
lowestFare = schedule.combinedMinFareDisplay;
}
@@ -551,14 +547,14 @@ export default function ResultsPage() {
{coachTypes.map((coachType: any, index: number) => {
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
- const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0;
- const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB';
+ const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
+ const coachCurrency = 'ETB';
const CoachIcon = getCoachIcon(coachType.coachTypeName);
return (
handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName)}
+ onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName, coachType.classes?.[0]?.name || coachType.coachTypeName)}
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
isSelected
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
@@ -631,10 +627,10 @@ export default function ResultsPage() {
- {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
+ {(cls.baseFareMinor / 100).toFixed(2)}
- {cls.displayCurrency ?? coachCurrency}
+ {coachCurrency}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
index 655f0a7c6..67422ad69 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
@@ -62,11 +62,7 @@ export default function ReviewPage() {
// Prefer the currency already stored on the selected schedule (set from search results).
// Fall back to deriving from nationality so the review page is never left with a stale value.
- const NATIONALITY_TO_CURRENCY: Record = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' };
- const displayCurrency: string =
- (isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ??
- NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ??
- 'USD';
+ const displayCurrency = 'ETB';
useEffect(() => {
if (!seatHold?.expiresAt) return;
@@ -201,19 +197,34 @@ export default function ReviewPage() {
return;
}
- // Get seat class ID
- let seatClassId = 'default-seat-class-id';
- let returnSeatClassId = 'default-seat-class-id';
+ // Get seat class ID by name-matching against the /seat-classes list
+ let seatClassId = '';
+ let returnSeatClassId = '';
try {
- const seatClasses: any = await apiClient.get('/seat-classes');
- console.log('Seat classes:', seatClasses);
+ const seatClasses: any[] = await apiClient.get('/seat-classes');
if (seatClasses && seatClasses.length > 0) {
- seatClassId = seatClasses[0].id;
- returnSeatClassId = seatClasses[0].id;
+ const outboundClassName = isRoundTrip
+ ? (outboundSchedule as any)?.seatClassName
+ : (selectedSchedule as any)?.seatClassName;
+ const returnClassName = isRoundTrip
+ ? (inboundSchedule as any)?.seatClassName
+ : outboundClassName;
+
+ const findByName = (name: string) =>
+ seatClasses.find((sc: any) => sc.name === name)?.id || seatClasses[0].id;
+
+ seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id;
+ returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id;
+ console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId });
}
} catch (err) {
console.error('Failed to fetch seat classes:', err);
}
+
+ if (!seatClassId) {
+ alert('Unable to determine seat class. Please go back and re-select your seats.');
+ return;
+ }
let bookingData: any;
if (isAuthenticated) {
diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts
index 4aaef69a1..93262f5b8 100644
--- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts
+++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts
@@ -54,6 +54,10 @@ export interface SelectedSchedule {
displayCurrency: string;
selectedSeatClass?: string;
selectedSeatClassName?: string;
+ seatClassName?: string;
+ selectedCoachTypeId?: string;
+ selectedCoachTypeCode?: string;
+ selectedCoachTypeName?: string;
}
export interface SeatHold {
diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
index f05db069b..37dfa5ce9 100644
--- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
+++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
@@ -1,392 +1,301 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
-interface VoucherData {
+interface ScheduleInfo {
+ trainNumber: string;
+ trainName?: string;
+ origin: { name: string; code: string; city: string };
+ destination: { name: string; code: string; city: string };
+ departureAt: string;
+ arrivalAt: string;
+ seatClass?: string;
+}
+
+interface PassengerVoucherData {
bookingRef: string;
+ ticketNumber: string;
+ passengerName: string;
+ dateOfBirth?: string;
+ nationality?: string;
+ seatNumber?: string;
+ outboundSeatNumber?: string;
+ inboundSeatNumber?: string;
status: string;
- passengers: Array<{
- fullName: string;
- category: string;
- seat?: {
- number: string;
- coach: string;
- seatClass: string;
- };
- }>;
- schedule: {
- trainNumber: string;
- trainName?: string;
- origin: {
- name: string;
- code: string;
- city: string;
- };
- destination: {
- name: string;
- code: string;
- city: string;
- };
- departureAt: string;
- arrivalAt: string;
- };
- totalMinor: number;
+ outboundSchedule: ScheduleInfo;
+ inboundSchedule?: ScheduleInfo;
+ isRoundTrip: boolean;
+ fareMinor: number;
currency: string;
- bookingType: string;
createdAt: string;
}
-export const generateVoucherPDF = async (booking: VoucherData) => {
- const doc = new jsPDF({
- orientation: 'portrait',
- unit: 'mm',
- format: 'a4',
- });
+// βββ shared drawing helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ
+const PRIMARY = [20, 113, 76] as const;
+const DARK = [51, 51, 51] as const;
+const MED = [102, 102, 102] as const;
+const LIGHT = [200, 200, 200] as const;
+
+async function drawHeader(doc: jsPDF, margin: number): Promise {
const pageWidth = doc.internal.pageSize.getWidth();
- const pageHeight = doc.internal.pageSize.getHeight();
- const margin = 15;
- let yPos = margin;
- // Colors
- const primaryColor = [20, 113, 76]; // EDR Green
- const darkGray = [51, 51, 51];
- const mediumGray = [102, 102, 102];
- const lightGray = [200, 200, 200];
-
- // ============ HEADER ============
- // Company branding strip
- doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
+ doc.setFillColor(...PRIMARY);
doc.rect(0, 0, pageWidth, 30, 'F');
- // Load and add logo
try {
- const logoImg = await fetch('/edr-logo.png');
+ const logoImg = await fetch('/edr-logo.png');
const logoBlob = await logoImg.blob();
const logoDataUrl = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(logoBlob);
});
-
- // Create image to get dimensions
const img = new Image();
- await new Promise((resolve) => {
- img.onload = resolve;
- img.src = logoDataUrl;
- });
-
- // Calculate aspect ratio and dimensions
- const logoHeight = 18;
- const logoWidth = (img.width / img.height) * logoHeight;
-
- // Add logo on left side with proper aspect ratio
- doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight);
-
- // Company name next to logo
+ await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
+ const logoH = 18;
+ const logoW = (img.width / img.height) * logoH;
+ doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH);
doc.setTextColor(255, 255, 255);
- doc.setFontSize(20);
- doc.setFont('helvetica', 'bold');
- doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14);
-
- doc.setFontSize(9);
- doc.setFont('helvetica', 'normal');
- doc.text('Premium Travel Experience', margin + logoWidth + 5, 20);
- } catch (error) {
- console.error('Failed to load logo:', error);
- // Fallback: just show text centered
+ doc.setFontSize(18); doc.setFont('helvetica', 'bold');
+ doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14);
+ doc.setFontSize(9); doc.setFont('helvetica', 'normal');
+ doc.text('Premium Travel Experience', margin + logoW + 5, 20);
+ } catch {
doc.setTextColor(255, 255, 255);
- doc.setFontSize(24);
- doc.setFont('helvetica', 'bold');
- doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' });
-
- doc.setFontSize(10);
- doc.setFont('helvetica', 'normal');
- doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' });
+ doc.setFontSize(22); doc.setFont('helvetica', 'bold');
+ doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' });
+ doc.setFontSize(9); doc.setFont('helvetica', 'normal');
+ doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' });
}
+ return 40;
+}
- yPos = 40;
-
- // ============ TITLE & STATUS ============
- doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
- doc.setFontSize(20);
- doc.setFont('helvetica', 'bold');
- doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' });
-
- yPos += 10;
-
- // Status badge (simplified)
- const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status;
- const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8];
-
- doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]);
- doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F');
+function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number {
+ const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status;
+ const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8];
+ doc.setFillColor(color[0], color[1], color[2]);
+ doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F');
doc.setTextColor(255, 255, 255);
- doc.setFontSize(9);
- doc.setFont('helvetica', 'bold');
- doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' });
+ doc.setFontSize(9); doc.setFont('helvetica', 'bold');
+ doc.text(label, pageWidth / 2, y + 1, { align: 'center' });
+ return y + 12;
+}
- yPos += 12;
-
- // ============ QR CODE ============
- // Generate QR code data URL
- const canvas = document.createElement('canvas');
- const QRCode = (await import('qrcode')).default;
-
- const qrSize = 35; // 35mm = 3.5cm
- await QRCode.toCanvas(canvas, booking.bookingRef, {
- width: 300,
- margin: 2,
- color: {
- dark: '#000000',
- light: '#FFFFFF',
- },
- });
-
- const qrDataUrl = canvas.toDataURL('image/png');
-
- // Place QR code at top-right
- const qrX = pageWidth - margin - qrSize;
- const qrY = yPos;
-
- doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize);
-
- doc.setFontSize(8);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFont('helvetica', 'normal');
- doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' });
-
- // ============ BOOKING REFERENCE ============
+function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number {
doc.setFillColor(245, 245, 245);
- doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F');
-
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFontSize(9);
- doc.setFont('helvetica', 'normal');
- doc.text('BOOKING REFERENCE', margin + 5, yPos + 6);
-
- doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
- doc.setFontSize(18);
- doc.setFont('helvetica', 'bold');
- doc.text(booking.bookingRef, margin + 5, yPos + 14);
+ doc.rect(margin, y, pageWidth - margin * 2, 22, 'F');
- yPos += 25;
+ doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
+ doc.text('BOOKING REFERENCE', margin + 5, y + 6);
+ doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
+ doc.text(bookingRef, margin + 5, y + 14);
- // ============ JOURNEY DETAILS ============
- doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
- doc.setFontSize(12);
- doc.setFont('helvetica', 'bold');
- doc.text('JOURNEY DETAILS', margin, yPos);
-
- yPos += 8;
+ const rightX = pageWidth - margin - 5;
+ doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
+ doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' });
+ doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
+ doc.text(ticketNumber, rightX, y + 14, { align: 'right' });
- // Route box
- doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
- doc.setLineWidth(0.5);
- doc.rect(margin, yPos, pageWidth - margin * 2, 40);
+ return y + 28;
+}
+
+function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number {
+ doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
+ doc.text(label ? `JOURNEY DETAILS β ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y);
+ y += 7;
+
+ doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5);
+ doc.rect(margin, y, pageWidth - margin * 2, 40);
// Origin
- doc.setFontSize(9);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFont('helvetica', 'normal');
- doc.text('FROM', margin + 5, yPos + 6);
-
- doc.setFontSize(16);
- doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
- doc.setFont('helvetica', 'bold');
- doc.text(booking.schedule.origin.code, margin + 5, yPos + 14);
-
- doc.setFontSize(10);
- doc.setFont('helvetica', 'normal');
- doc.text(booking.schedule.origin.name, margin + 5, yPos + 20);
-
- doc.setFontSize(8);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.text(booking.schedule.origin.city, margin + 5, yPos + 25);
+ doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
+ doc.text('FROM', margin + 5, y + 6);
+ doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
+ doc.text(schedule.origin.code, margin + 5, y + 14);
+ doc.setFontSize(9); doc.setFont('helvetica', 'normal');
+ doc.text(schedule.origin.name, margin + 5, y + 20);
+ doc.setFontSize(8); doc.setTextColor(...MED);
+ doc.text(schedule.origin.city, margin + 5, y + 25);
- // Departure time
- const departureDate = new Date(booking.schedule.departureAt);
- doc.setFontSize(14);
- doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
- doc.setFont('helvetica', 'bold');
- doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33);
-
- doc.setFontSize(8);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFont('helvetica', 'normal');
- doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38);
+ const dep = new Date(schedule.departureAt);
+ doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
+ doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33);
+ doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
+ doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
// Arrow
- doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]);
- doc.setLineWidth(1);
- const arrowStartX = pageWidth / 2 - 10;
- const arrowEndX = pageWidth / 2 + 10;
- const arrowY = yPos + 20;
-
- // Draw arrow line
- doc.line(arrowStartX, arrowY, arrowEndX, arrowY);
-
- // Draw arrow head manually with lines
- doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2);
- doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2);
+ doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8);
+ const ax = pageWidth / 2, ay = y + 20;
+ doc.line(ax - 10, ay, ax + 10, ay);
+ doc.line(ax + 10, ay, ax + 7, ay - 2);
+ doc.line(ax + 10, ay, ax + 7, ay + 2);
// Destination
- const destX = pageWidth - margin - 50;
- doc.setFontSize(9);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFont('helvetica', 'normal');
- doc.text('TO', destX, yPos + 6);
-
- doc.setFontSize(16);
- doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
- doc.setFont('helvetica', 'bold');
- doc.text(booking.schedule.destination.code, destX, yPos + 14);
-
- doc.setFontSize(10);
- doc.setFont('helvetica', 'normal');
- doc.text(booking.schedule.destination.name, destX, yPos + 20);
-
- doc.setFontSize(8);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.text(booking.schedule.destination.city, destX, yPos + 25);
+ const dx = pageWidth - margin - 50;
+ doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
+ doc.text('TO', dx, y + 6);
+ doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
+ doc.text(schedule.destination.code, dx, y + 14);
+ doc.setFontSize(9); doc.setFont('helvetica', 'normal');
+ doc.text(schedule.destination.name, dx, y + 20);
+ doc.setFontSize(8); doc.setTextColor(...MED);
+ doc.text(schedule.destination.city, dx, y + 25);
- // Arrival time
- const arrivalDate = new Date(booking.schedule.arrivalAt);
- doc.setFontSize(14);
- doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
- doc.setFont('helvetica', 'bold');
- doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33);
-
- doc.setFontSize(8);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFont('helvetica', 'normal');
- doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38);
+ const arr = new Date(schedule.arrivalAt);
+ doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
+ doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33);
+ doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
+ doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38);
- yPos += 48;
+ y += 47;
- // Train info
- doc.setFillColor(250, 250, 250);
- doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F');
-
- doc.setFontSize(9);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFont('helvetica', 'normal');
- doc.text('TRAIN', margin + 5, yPos + 5);
-
- doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
- doc.setFont('helvetica', 'bold');
- doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9);
-
- if (booking.schedule.trainName) {
- doc.setFont('helvetica', 'normal');
- doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9);
+ // Train info bar
+ doc.setFillColor(248, 248, 248);
+ doc.rect(margin, y, pageWidth - margin * 2, 12, 'F');
+ doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
+ doc.text('TRAIN', margin + 5, y + 5);
+ doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
+ doc.text(schedule.trainNumber + (schedule.trainName ? ` β ${schedule.trainName}` : ''), margin + 20, y + 9);
+ if (schedule.seatClass) {
+ doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED);
+ doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' });
}
- yPos += 18;
+ return y + 18;
+}
- // ============ PASSENGERS ============
- doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
- doc.setFontSize(12);
- doc.setFont('helvetica', 'bold');
- doc.text('PASSENGERS', margin, yPos);
-
- yPos += 8;
+function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number {
+ doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
+ doc.text('PASSENGER DETAILS', margin, y);
+ y += 7;
- // Passenger table
- const passengerData = booking.passengers.map((p, idx) => [
- (idx + 1).toString(),
- p.fullName,
- p.category,
- p.seat?.number || '-',
- p.seat?.coach || '-',
- p.seat?.seatClass || '-',
- ]);
+ const rows: [string, string][] = [
+ ['Full Name', data.passengerName || 'β'],
+ ['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : 'β'],
+ ['Nationality', data.nationality || 'β'],
+ ];
+
+ if (data.isRoundTrip) {
+ rows.push(['Outbound Seat', data.outboundSeatNumber || 'β']);
+ rows.push(['Return Seat', data.inboundSeatNumber || 'β']);
+ } else {
+ rows.push(['Seat', data.seatNumber || 'β']);
+ }
autoTable(doc, {
- startY: yPos,
- head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']],
- body: passengerData,
- theme: 'striped',
- headStyles: {
- fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]],
- textColor: [255, 255, 255],
- fontSize: 9,
- fontStyle: 'bold',
- },
- bodyStyles: {
- fontSize: 9,
- textColor: [darkGray[0], darkGray[1], darkGray[2]],
- },
- alternateRowStyles: {
- fillColor: [250, 250, 250],
+ startY: y,
+ body: rows,
+ theme: 'plain',
+ styles: { fontSize: 9, cellPadding: 3 },
+ columnStyles: {
+ 0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 },
+ 1: { textColor: [DARK[0], DARK[1], DARK[2]] },
},
+ alternateRowStyles: { fillColor: [248, 248, 248] },
margin: { left: margin, right: margin },
});
- yPos = (doc as any).lastAutoTable.finalY + 10;
+ return (doc as any).lastAutoTable.finalY + 8;
+}
- // ============ PAYMENT SUMMARY ============
- doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
- doc.setFontSize(12);
- doc.setFont('helvetica', 'bold');
- doc.text('PAYMENT SUMMARY', margin, yPos);
-
- yPos += 8;
+function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
+ doc.setFillColor(248, 248, 248);
+ doc.rect(margin, y, pageWidth - margin * 2, 20, 'F');
+ doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
+ doc.text('Fare', margin + 5, y + 7);
+ doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
+ doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' });
+ doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold');
+ doc.text('β PAID', margin + 5, y + 15);
+ return y + 26;
+}
- doc.setFillColor(250, 250, 250);
- doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F');
-
- doc.setFontSize(10);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFont('helvetica', 'normal');
- doc.text('Total Amount', margin + 5, yPos + 7);
-
- doc.setFontSize(16);
- doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
- doc.setFont('helvetica', 'bold');
- doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' });
-
- doc.setFontSize(9);
- doc.setTextColor(34, 197, 94);
- doc.setFont('helvetica', 'bold');
- doc.text('β PAID', margin + 5, yPos + 15);
-
- yPos += 28;
-
- // ============ INSTRUCTIONS ============
+function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number {
doc.setFillColor(252, 211, 77);
- doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F');
-
- doc.setFontSize(9);
- doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
- doc.setFont('helvetica', 'bold');
- doc.text('β IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6);
-
- doc.setFont('helvetica', 'normal');
- doc.setFontSize(8);
- doc.text('β’ Present this voucher at the terminal for boarding', margin + 5, yPos + 11);
- doc.text('β’ Arrive at least 30 minutes before departure', margin + 5, yPos + 15);
+ doc.rect(margin, y, pageWidth - margin * 2, 18, 'F');
+ doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
+ doc.text('β IMPORTANT INSTRUCTIONS', margin + 5, y + 6);
+ doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
+ doc.text('β’ Present this voucher at the terminal for boarding', margin + 5, y + 11);
+ doc.text('β’ Arrive at least 30 minutes before departure', margin + 5, y + 15);
+ return y + 24;
+}
- // ============ FOOTER ============
- const footerY = pageHeight - 25;
-
- doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
- doc.line(margin, footerY, pageWidth - margin, footerY);
-
- doc.setFontSize(8);
- doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
- doc.setFont('helvetica', 'normal');
+function drawFooter(doc: jsPDF, createdAt: string): void {
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const pageHeight = doc.internal.pageSize.getHeight();
+ const footerY = pageHeight - 22;
+
+ doc.setDrawColor(...LIGHT);
+ doc.line(15, footerY, pageWidth - 15, footerY);
+ doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' });
doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
-
doc.setFontSize(7);
- doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
+ doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
+}
- // Watermark (removed rotation as it may cause issues)
- doc.setTextColor(240, 240, 240);
- doc.setFontSize(50);
- doc.setFont('helvetica', 'bold');
- doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' });
+// βββ public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- // Save PDF
- doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`);
+/** Generates and downloads one PDF voucher for a single passenger. */
+export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => {
+ const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
+ const pageW = doc.internal.pageSize.getWidth();
+ const margin = 15;
+
+ let y = await drawHeader(doc, margin);
+
+ // Title
+ doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
+ doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' });
+ y += 10;
+
+ y = drawStatusBadge(doc, data.status, y, pageW);
+ y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, y, margin, pageW);
+ y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
+
+ if (data.isRoundTrip && data.inboundSchedule) {
+ y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW);
+ }
+
+ y = drawPassengerDetails(doc, data, y, margin);
+ y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
+ drawInstructions(doc, y, margin, pageW);
+ drawFooter(doc, data.createdAt);
+
+ const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, '');
+ doc.save(`Voucher_${safeName}.pdf`);
+};
+
+// βββ legacy combined voucher (kept for backward compat) ββββββββββββββββββββββ
+
+interface VoucherData {
+ bookingRef: string;
+ status: string;
+ passengers: Array<{ fullName: string; category: string; seat?: { number: string; coach: string; seatClass: string } }>;
+ schedule: { trainNumber: string; trainName?: string; origin: { name: string; code: string; city: string }; destination: { name: string; code: string; city: string }; departureAt: string; arrivalAt: string };
+ totalMinor: number;
+ currency: string;
+ bookingType: string;
+ createdAt: string;
+}
+
+export const generateVoucherPDF = async (booking: VoucherData): Promise => {
+ for (let i = 0; i < booking.passengers.length; i++) {
+ const p = booking.passengers[i];
+ await generatePassengerVoucherPDF({
+ bookingRef: booking.bookingRef,
+ ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`,
+ passengerName: p.fullName,
+ seatNumber: p.seat?.number,
+ status: booking.status,
+ outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
+ isRoundTrip: false,
+ fareMinor: Math.round(booking.totalMinor / booking.passengers.length),
+ currency: booking.currency,
+ createdAt: booking.createdAt,
+ });
+ // small delay so browsers don't block multiple sequential downloads
+ if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400));
+ }
};
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index c3e16374a..dbfcf655d 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -24,7 +24,7 @@ export const GOVERNMENT_PRIORITY_BONUS = 50_000;
export interface GovernmentBookingFields {
isGovernment: boolean;
-governmentInstitution?: string | null;
+ governmentInstitution?: string | null;
}
export enum ExceededAction {
@@ -129,6 +129,30 @@ export enum PaymentStatus {
Refunded = "REFUNDED",
}
+export enum InvoiceStatus {
+ Draft = "DRAFT",
+ Pending = "PENDING",
+ Paid = "PAID",
+ Overdue = "OVERDUE",
+ Cancelled = "CANCELLED",
+ Refunded = "REFUNDED",
+}
+
+/** Originating subsystem an invoice bills for; namespaces invoice events. */
+export enum InvoiceSource {
+ Booking = "booking",
+ Warehouse = "warehouse",
+ Demurrage = "demurrage",
+}
+
+/**
+ * What an invoice bills for within its source β the discriminator when one
+ * entity carries several invoices (e.g. a booking's up-front vs final charge).
+ */
+export enum InvoiceType {
+ Prepaid = "PREPAID",
+}
+
export enum SchedulingStatus {
NotScheduled = "NOT_SCHEDULED",
Holding = "HOLDING",
@@ -500,14 +524,57 @@ export interface ClearanceView {
allApproved: boolean;
}
-export interface IInvoice extends BaseEntity {
- bookingId: string;
- invoiceNumber: string;
+/** Company an invoice is billed to (minimal projection). */
+export interface IInvoiceCompany {
+ id: string;
+ name: string;
+}
+
+/** Company profile (importer/exporter/forwarder/β¦) an invoice is billed to. */
+export interface IInvoiceCompanyProfile {
+ id: string;
+ type: string;
+ reference: string | null;
+}
+
+/** A single billed line on an invoice. */
+export interface IInvoiceLine extends BaseEntity {
+ invoiceId: string;
+ chargeType: string;
+ description?: string | null;
+ /** Units billed (container count, wagon count, tons, β¦); defaults to 1. */
+ quantity: number;
+ /** Price per unit; `amount` is normally `quantity * unitRate`. */
+ unitRate: number;
amount: number;
currency: string;
- status: PaymentStatus;
- issuedAt: string;
+ metadata?: Record | null;
+}
+
+export interface IInvoice extends BaseEntity {
+ invoiceNumber: string;
+ /** Customer (company) the invoice is billed to. */
+ companyId: string;
+ company?: IInvoiceCompany;
+ /** Specific company profile billed. */
+ companyProfileId: string;
+ companyProfile?: IInvoiceCompanyProfile;
+ totalAmount: number;
+ currency: string;
+ status: InvoiceStatus;
+ /** Originating subsystem: booking / warehouse / demurrage. */
+ source: InvoiceSource;
+ /** Identifier of the source record (e.g. booking id). */
+ sourceId: string;
+ /** What the invoice bills for (e.g. PREPAID). */
+ type: string;
+ /** Set when issued; null while DRAFT. */
+ issuedAt?: string | null;
+ /** Gateway payment that settled the invoice, once paid. */
+ paymentId?: string | null;
dueAt: string;
+ /** Present on invoice-detail reads. */
+ lines?: IInvoiceLine[];
}
// Γ’ββ¬Γ’ββ¬ Reference Data (booking form catalog) Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬