Merge pull request #516 from Tria-plc/alpha

Alpha
This commit is contained in:
Stephanos A.
2026-07-07 22:59:33 +03:00
committed by GitHub
8 changed files with 335 additions and 62 deletions

View File

@@ -33,6 +33,7 @@ import {
PaymentMethodTypeEnum,
PaymentPlatformDto,
BookingAmountResponseDto,
ForceConfirmDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -124,6 +125,19 @@ export class PaymentsController {
return this.service.refund(dto);
}
@Post(":bookingId/force-confirm")
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Force-confirm payment & generate ticket (back-office only)",
description:
"Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " +
"Use when a vendor payment completed but the webhook was never delivered. Idempotent.",
})
forceConfirm(@Param("bookingId") bookingId: string, @Body() dto: ForceConfirmDto) {
return this.service.forceConfirmPayment(bookingId, dto);
}
@Post("methods")
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")

View File

@@ -142,3 +142,14 @@ export class BookingAmountResponseDto {
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
}
export class ForceConfirmDto {
@ApiPropertyOptional({ description: 'External payment reference / transaction ID from the vendor', example: 'TXN-123456' })
@IsOptional() @IsString() paymentReference?: string;
@ApiPropertyOptional({ enum: PaymentMethodTypeEnum, description: 'Payment method used externally', example: 'TELEBIRR' })
@IsOptional() @IsEnum(PaymentMethodTypeEnum) paymentMethod?: PaymentMethodTypeEnum;
@ApiPropertyOptional({ description: 'Internal notes about why this was force-confirmed', example: 'Vendor confirmed via phone' })
@IsOptional() @IsString() notes?: string;
}

View File

@@ -21,6 +21,7 @@ import {
InitiateResponseDto,
IntentStatusDto,
PaymentRegionEnum,
ForceConfirmDto,
} from "./payments.dto";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentClientService } from "./payment-client.service";
@@ -773,6 +774,51 @@ export class PaymentsService {
return { processed: true, alreadyFinalized };
}
async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> {
const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } });
if (!booking) throw new NotFoundException('Booking not found');
const resolvedMethod = dto.paymentMethod
? (dto.paymentMethod as unknown as PaymentMethodType)
: PaymentMethodType.TELEBIRR;
let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } });
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
bookingId,
amountMinor: booking.totalMinor,
currency: booking.currency,
method: resolvedMethod,
status: PaymentIntentStatus.PROCESSING,
providerRef: `FORCE-${Date.now()}`,
providerTxnId: dto.paymentReference ?? null,
failureMessage: dto.notes ?? null,
},
});
} else {
// Update method/reference/notes regardless of current status
const updateData: any = {};
if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference;
if (dto.paymentMethod) updateData.method = resolvedMethod;
if (dto.notes) updateData.failureMessage = dto.notes;
if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) {
updateData.status = PaymentIntentStatus.PROCESSING;
}
if (Object.keys(updateData).length) {
intent = await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: updateData,
});
}
}
return this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
});
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;

View File

@@ -258,8 +258,8 @@ export class TicketsService {
type: booking.bookingType,
passenger: passengerName,
seats: passengerSeats.map(ps => ({
seat: ps.seat.seatNumber,
coach: ps.seat.coach.number,
seat: ps.seat?.seatNumber,
coach: ps.seat?.coach?.number,
leg: ps.leg || 1,
scheduleId: ps.scheduleId || booking.scheduleId,
})),

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import { Download, Eye, Trash2, Ticket } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
@@ -35,6 +35,8 @@ function BookingsPageContent() {
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [generateTicketBooking, setGenerateTicketBooking] = useState<any>(null);
const [generateTicketForm, setGenerateTicketForm] = useState({ paymentReference: '', paymentMethod: '', notes: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -63,6 +65,19 @@ function BookingsPageContent() {
}),
});
const forceConfirmMutation = useMutation({
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
bookingsApi.forceConfirm(bookingId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setSuccessMessage('Payment confirmed and ticket generated successfully');
setTimeout(() => setSuccessMessage(''), 4000);
setSelectedBooking(null);
setGenerateTicketBooking(null);
setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' });
},
});
const deleteMutation = useMutation({
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`),
onSuccess: () => {
@@ -258,6 +273,7 @@ function BookingsPageContent() {
const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
];
@@ -448,6 +464,25 @@ function BookingsPageContent() {
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
</div>
{b.paymentIntent?.status !== 'SUCCEEDED' && canManage && (
<div className="mt-3 p-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20">
<p className="text-xs text-amber-700 dark:text-amber-400 mb-2">
Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket.
</p>
<ActionButton
variant="secondary"
onClick={() => forceConfirmMutation.mutate({ bookingId: b.id, data: {} })}
disabled={forceConfirmMutation.isPending}
>
{forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'}
</ActionButton>
{forceConfirmMutation.isError && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
</p>
)}
</div>
)}
</section>
{/* Seats / Passengers */}
@@ -517,6 +552,93 @@ function BookingsPageContent() {
onCascadeChange={(checked) => setDeleteCascadeChecked(checked)}
/>
{/* Generate Ticket Modal */}
<Modal
isOpen={!!generateTicketBooking}
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); forceConfirmMutation.reset(); }}
title="Generate Ticket"
size="md"
>
{generateTicketBooking && (
<div className="space-y-4">
<div className="-mx-6 -mt-4 mb-4 px-6 py-4 bg-muted/40 border-b border-muted">
<p className="text-xs text-muted-foreground">Booking</p>
<p className="font-mono font-bold text-lg">{generateTicketBooking.bookingRef}</p>
<p className="text-sm text-muted-foreground">
{generateTicketBooking.schedule?.originStation?.name} {generateTicketBooking.schedule?.destinationStation?.name}
</p>
</div>
<div>
<label className="label">Payment Reference / Transaction ID</label>
<input
type="text"
className="input"
placeholder="e.g. TXN-123456 or vendor receipt number"
value={generateTicketForm.paymentReference}
onChange={(e) => setGenerateTicketForm(f => ({ ...f, paymentReference: e.target.value }))}
/>
</div>
<div>
<label className="label">Payment Method</label>
<select
className="input"
value={generateTicketForm.paymentMethod}
onChange={(e) => setGenerateTicketForm(f => ({ ...f, paymentMethod: e.target.value }))}
>
<option value="">Select method</option>
<option value="TELEBIRR">Telebirr</option>
<option value="CBE_BIRR">CBE Birr</option>
<option value="EBIRR">eBirr</option>
<option value="WAAFI">Waafi</option>
<option value="DMONEY">dMoney</option>
<option value="CARD">Card</option>
<option value="WALLET">Wallet</option>
</select>
</div>
<div>
<label className="label">Notes <span className="text-muted-foreground font-normal">(optional)</span></label>
<textarea
className="input min-h-[72px] resize-none"
placeholder="e.g. Customer paid at counter, receipt #123"
value={generateTicketForm.notes}
onChange={(e) => setGenerateTicketForm(f => ({ ...f, notes: e.target.value }))}
/>
</div>
{forceConfirmMutation.isError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton
variant="secondary"
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); forceConfirmMutation.reset(); }}
>
Cancel
</ActionButton>
<ActionButton
onClick={() => forceConfirmMutation.mutate({
bookingId: generateTicketBooking.id,
data: {
paymentReference: generateTicketForm.paymentReference || undefined,
paymentMethod: generateTicketForm.paymentMethod || undefined,
notes: generateTicketForm.notes || undefined,
},
})}
disabled={forceConfirmMutation.isPending}
>
{forceConfirmMutation.isPending ? 'Generating…' : 'Confirm & Generate Ticket'}
</ActionButton>
</div>
</div>
)}
</Modal>
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">

View File

@@ -2,13 +2,16 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
import { routesApi } from '@/lib/api/routes';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
export default function SeatsPage() {
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
const [selectedSchedule, setSelectedSchedule] = useState('');
const [selectedRoute, setSelectedRoute] = useState('');
const [expandedCoaches, setExpandedCoaches] = useState<Set<string>>(new Set());
const [showBlockModal, setShowBlockModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
@@ -39,6 +42,30 @@ export default function SeatsPage() {
queryFn: () => fleetApi.getCoaches(),
});
const { data: routesData } = useQuery({
queryKey: ['routes'],
queryFn: () => routesApi.getAll(),
});
const { data: routeCoachesData, isLoading: routeCoachesLoading } = useQuery({
queryKey: ['routeCoaches', selectedRoute],
queryFn: async () => {
if (!selectedRoute) return null;
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
if (!template?.length) return [];
const fullCoaches = await Promise.all(
template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
);
return fullCoaches.map((coach: any, i: number) => ({
...coach,
coachNumber: coach.number,
positionNumber: template[i].positionNumber,
seatArrangement: coach.arrangement,
}));
},
enabled: !!selectedRoute,
});
const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
onSuccess: () => {
@@ -89,7 +116,8 @@ export default function SeatsPage() {
});
const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || [];
const routes = routesData?.items || routesData?.data || [];
const coaches = activeTab === 'schedule' ? (seatMapData?.coaches || []) : (Array.isArray(routeCoachesData) ? routeCoachesData : []);
const blockCoachMutation = useMutation({
mutationFn: async ({ coachId, reason }: any) => {
@@ -464,6 +492,9 @@ export default function SeatsPage() {
return seqA - seqB;
});
const activeSelection = activeTab === 'schedule' ? selectedSchedule : selectedRoute;
const isLoadingData = activeTab === 'schedule' ? isLoading : routeCoachesLoading;
return (
<div className="space-y-6">
<div>
@@ -471,31 +502,62 @@ export default function SeatsPage() {
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
</div>
{!selectedSchedule ? (
{/* Tab switcher */}
<div className="flex gap-1 p-1 bg-muted rounded-lg w-fit">
<button
onClick={() => setActiveTab('route')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'route'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
By Route
</button>
<button
onClick={() => setActiveTab('schedule')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'schedule'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
By Schedule
</button>
</div>
{!activeSelection ? (
<div className="card">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{date} - {routeName}
</option>
);
})}
</select>
{activeTab === 'schedule' ? (
<>
<label className="label">Select Schedule</label>
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return <option key={schedule.id} value={schedule.id}>{date} - {routeName}</option>;
})}
</select>
</>
) : (
<>
<label className="label">Select Route</label>
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
<option value="">Select a route...</option>
{routes.map((route: any) => (
<option key={route.id} value={route.id}>{route.name}</option>
))}
</select>
</>
)}
<div className="text-center py-12 text-muted-foreground mt-8">
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Select a schedule to view seat map</p>
<p>Select a {activeTab === 'schedule' ? 'schedule' : 'route'} to view seat map</p>
</div>
</div>
) : isLoading ? (
) : isLoadingData ? (
<div className="card text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
@@ -503,51 +565,63 @@ export default function SeatsPage() {
) : coachesWithSeats.length === 0 ? (
<div className="space-y-6">
<div className="card">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
{activeTab === 'schedule' ? (
<>
<label className="label">Select Schedule</label>
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return <option key={schedule.id} value={schedule.id}>{trainNumber} - {routeName} - {date}</option>;
})}
</select>
</>
) : (
<>
<label className="label">Select Route</label>
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
<option value="">Select a route...</option>
{routes.map((route: any) => (
<option key={route.id} value={route.id}>{route.name}</option>
))}
</select>
</>
)}
</div>
<div className="card text-center py-12 text-muted-foreground">
<p>No coaches with seats found for this schedule</p>
<p>No coaches with seats found for this {activeTab === 'schedule' ? 'schedule' : 'route'}</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card h-fit sticky top-6 space-y-6">
<div>
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
{activeTab === 'schedule' ? (
<>
<label className="label">Select Schedule</label>
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return <option key={schedule.id} value={schedule.id}>{trainNumber} - {routeName} - {date}</option>;
})}
</select>
</>
) : (
<>
<label className="label">Select Route</label>
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
<option value="">Select a route...</option>
{routes.map((route: any) => (
<option key={route.id} value={route.id}>{route.name}</option>
))}
</select>
</>
)}
</div>
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">

View File

@@ -28,4 +28,7 @@ export const bookingsApi = {
cancel: (id: string, reason?: string) => {
return apiClient.post<Booking>(`/bookings/${id}/cancel`, { reason });
},
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
};

View File

@@ -44,6 +44,8 @@ export const bookingsApi = {
cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
};
// Passengers API
@@ -115,6 +117,7 @@ export const fleetApi = {
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
deleteCoach: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/coaches/${id}${cascade ? '?cascade=true' : ''}`),
generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data),
getCoach: (id: string) => apiClient.get<any>(`/fleet/coaches/${id}`),
};
// Schedules API