From 8cc2d9445d06db2d1a3e0a74e51cbcbf77cbb57e Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 7 Jul 2026 22:58:01 +0300 Subject: [PATCH] Manuel ticket generation, seat management by route added --- .../modules/payments/payments.controller.ts | 14 ++ .../src/modules/payments/payments.dto.ts | 11 + .../src/modules/payments/payments.service.ts | 46 +++++ .../src/modules/tickets/tickets.service.ts | 4 +- .../backoffice/src/app/bookings/page.tsx | 124 ++++++++++- .../backoffice/src/app/seats/page.tsx | 192 ++++++++++++------ .../backoffice/src/lib/api/bookings.ts | 3 + .../backoffice/src/lib/api/index.ts | 3 + 8 files changed, 335 insertions(+), 62 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 5be11434a..2cdbc86e4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -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") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 410716b85..8d24091a1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -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; +} 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 e9b512c5e..9e43139c4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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; 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 e482189d2..4a1f7b36e 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -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, })), 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 be2785d43..1642f0a14 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -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(null); + const [generateTicketBooking, setGenerateTicketBooking] = useState(null); + const [generateTicketForm, setGenerateTicketForm] = useState({ paymentReference: '', paymentMethod: '', notes: '' }); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [deleteError, setDeleteError] = useState(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() { + {b.paymentIntent?.status !== 'SUCCEEDED' && canManage && ( +
+

+ Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket. +

+ forceConfirmMutation.mutate({ bookingId: b.id, data: {} })} + disabled={forceConfirmMutation.isPending} + > + {forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'} + + {forceConfirmMutation.isError && ( +

+ {(() => { 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'; })()} +

+ )} +
+ )} {/* Seats / Passengers */} @@ -517,6 +552,93 @@ function BookingsPageContent() { onCascadeChange={(checked) => setDeleteCascadeChecked(checked)} /> + {/* Generate Ticket Modal */} + { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); forceConfirmMutation.reset(); }} + title="Generate Ticket" + size="md" + > + {generateTicketBooking && ( +
+
+

Booking

+

{generateTicketBooking.bookingRef}

+

+ {generateTicketBooking.schedule?.originStation?.name} → {generateTicketBooking.schedule?.destinationStation?.name} +

+
+ +
+ + setGenerateTicketForm(f => ({ ...f, paymentReference: e.target.value }))} + /> +
+ +
+ + +
+ +
+ +