From d2a942ffbfddafe7e6f9082b1588caafab4025be Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 21 Aug 2026 18:26:38 +0300 Subject: [PATCH] feat: add contact phone overrides for payment links in excess baggage and supplementary charges --- .../excess-baggage/excess-baggage.dto.ts | 4 ++ .../excess-baggage/excess-baggage.service.ts | 4 +- .../modules/payments/payments.controller.ts | 2 + .../payments/supplementary-charges.service.ts | 6 ++- .../src/app/excess-baggage/page.tsx | 46 ++++++++++++++++--- .../payments/SupplementaryChargesModal.tsx | 45 ++++++++++++++++-- .../app/payments/useSupplementaryCharges.ts | 10 +++- .../backoffice/src/app/tickets/page.tsx | 38 ++++++++++++++- .../backoffice/src/lib/api/index.ts | 10 +++- 9 files changed, 145 insertions(+), 20 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index 4b06e906e..763f4a4d2 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -8,6 +8,10 @@ export class LogExcessBaggageDto { @IsOptional() @IsString() bookingReference?: string; @ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' }) @IsOptional() @IsString() agentId?: string; + @ApiPropertyOptional({ example: '+251911223344', description: 'Override the phone the payment link SMS should go to. Defaults to the booking contact phone.' }) + @IsOptional() @IsString() contactPhone?: string; + @ApiPropertyOptional({ example: 'passenger@example.com', description: 'Override the email the payment link should also be sent to. Defaults to the booking contact email.' }) + @IsOptional() @IsString() contactEmail?: string; @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) @IsInt() @IsPositive() excessWeightKg: number; @ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' }) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 7ccc0af06..a8eee0028 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -115,8 +115,8 @@ export class ExcessBaggageService { const totalMinor = feePerKgMinor * dto.excessWeightKg; const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); - const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; - const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + const contactPhone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null); + const contactEmail = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null); const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING'; const paidAt = dto.collectCash ? new Date() : null; 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 04ace216f..862856863 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -49,6 +49,8 @@ class CreateSupplementaryChargeDto { @ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string; @ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number; @ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string; + @ApiPropertyOptional({ description: 'Override the phone the payment link SMS should go to. Falls back to the booking contact phone.', example: '+251911223344' }) @IsOptional() @IsString() contactPhone?: string; + @ApiPropertyOptional({ description: 'Override the email the payment link should also be sent to. Falls back to the booking contact email.', example: 'passenger@example.com' }) @IsOptional() @IsString() contactEmail?: string; @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; } diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index 714b02170..184ff69ad 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -52,6 +52,8 @@ export class SupplementaryChargesService { amountMinor: number; reason: string; notes?: string; + contactPhone?: string; + contactEmail?: string; createdBy: string; }) { const booking = await this.prisma.booking.findUnique({ @@ -76,8 +78,8 @@ export class SupplementaryChargesService { }, }); - const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; - const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + const phone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null); + const email = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null); await this.sendLink(charge, booking.bookingRef, phone, email); await this.auditService.log({ diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index f571499a4..6e3c98340 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -1,13 +1,13 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; -import { excessBaggageApi, apiClient } from '@/lib/api'; +import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -28,7 +28,7 @@ export default function ExcessBaggagePage() { const [waiveReason, setWaiveReason] = useState(''); const [waiveError, setWaiveError] = useState(null); const [logModal, setLogModal] = useState(false); - const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false }); + const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); const [logError, setLogError] = useState(null); const [resendModal, setResendModal] = useState(null); const [resendSuccess, setResendSuccess] = useState(false); @@ -54,12 +54,36 @@ export default function ExcessBaggagePage() { }), }); + useEffect(() => { + if (!logModal) return; + const bookingRef = logForm.bookingReference.trim(); + if (!bookingRef) { + setLogForm((prev) => ({ ...prev, paymentPhone: '' })); + return; + } + + const timeout = setTimeout(async () => { + try { + const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 }); + const items = response?.items ?? []; + const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0]; + if (!match) return; + const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? ''; + setLogForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone })); + } catch { + // Ignore lookup failures: the agent can still override the number manually. + } + }, 250); + + return () => clearTimeout(timeout); + }, [logForm.bookingReference, logModal]); + const logMutation = useMutation({ mutationFn: (data: any) => excessBaggageApi.logCharge(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); setLogModal(false); - setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); + setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); setLogError(null); }, onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'), @@ -178,7 +202,7 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

- { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}> + { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}> Log Excess Luggage @@ -256,6 +280,15 @@ export default function ExcessBaggagePage() { onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })} /> +
+ + setLogForm({ ...logForm, paymentPhone: e.target.value })} + /> +
{!logForm.collectCash && (

- A payment link will be sent to the passenger's email and phone on file. + The payment link will be sent to the phone above and the booking's saved email when present.

)} @@ -302,6 +335,7 @@ export default function ExcessBaggagePage() { bookingReference: logForm.bookingReference.trim(), excessWeightKg: parseInt(logForm.excessWeightKg), collectCash: logForm.collectCash, + contactPhone: logForm.paymentPhone.trim() || undefined, }); }} > diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx index 45941cea4..f62f7c402 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { PlusCircle } from 'lucide-react'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; +import { bookingsApi } from '@/lib/api'; import { useCreateSupplementaryCharge } from './useSupplementaryCharges'; const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER']; @@ -14,13 +15,37 @@ interface Props { } export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { - const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' }); const [formError, setFormError] = useState(null); const [createSuccess, setCreateSuccess] = useState(null); + useEffect(() => { + if (!isOpen) return; + const bookingRef = form.bookingRef.trim(); + if (!bookingRef) { + setForm((prev) => ({ ...prev, paymentPhone: '' })); + return; + } + + const timeout = setTimeout(async () => { + try { + const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 }); + const items = response?.items ?? []; + const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0]; + if (!match) return; + const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? ''; + setForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone })); + } catch { + // Ignore lookup failures here; the staff member can still type a phone override manually. + } + }, 300); + + return () => clearTimeout(timeout); + }, [form.bookingRef, isOpen]); + const createMutation = useCreateSupplementaryCharge(() => { setCreateSuccess('Charge created and payment link sent.'); - setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' }); setFormError(null); setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000); }); @@ -31,7 +56,13 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { if (!form.bookingRef.trim()) return setFormError('Booking reference is required'); if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount'); try { - await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined }); + await createMutation.mutateAsync({ + bookingRef: form.bookingRef.trim(), + amountMinor, + reason: form.reason, + notes: form.notes || undefined, + contactPhone: form.paymentPhone.trim() || undefined, + }); } catch (e: any) { setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge'); } @@ -52,6 +83,10 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { setForm({ ...form, bookingRef: e.target.value })} />
+
+ + setForm({ ...form, paymentPhone: e.target.value })} /> +
setForm({ ...form, amountEtb: e.target.value })} /> @@ -69,7 +104,7 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {

- A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours. + The payment link sends to the phone above, falling back to the booking's saved contact details if left empty. The link expires in 72 hours.

diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts b/apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts index 32bfb4998..18d41fd3c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts +++ b/apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts @@ -13,8 +13,14 @@ export function useSupplementaryCharges(filters: { bookingRef?: string; status?: export function useCreateSupplementaryCharge(onSuccess: () => void) { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) => - paymentsApi.supplementary.create(data), + mutationFn: (data: { + bookingRef: string; + amountMinor: number; + reason: string; + notes?: string; + contactPhone?: string; + contactEmail?: string; + }) => paymentsApi.supplementary.create(data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['supplementary-charges'] }); onSuccess(); diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 8731a1eb4..74f1ec9b8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { LogIn, ListCollapse, Trash2, Printer, Package } from 'lucide-react'; import { Download } from 'lucide-react'; @@ -39,6 +39,7 @@ export default function TicketsPage() { const [excessTicket, setExcessTicket] = useState(null); const [excessKg, setExcessKg] = useState(''); const [excessCollectCash, setExcessCollectCash] = useState(false); + const [excessPaymentPhone, setExcessPaymentPhone] = useState(''); const [excessError, setExcessError] = useState(null); const [excessResult, setExcessResult] = useState(null); @@ -163,10 +164,35 @@ export default function TicketsPage() { onError: (e: any) => setExcessError(e?.response?.data?.message || e?.message || 'Failed to log charge'), }); + useEffect(() => { + if (!excessModalOpen || !excessTicket) return; + const bookingRef = excessTicket?.booking?.bookingRef ?? ''; + if (!bookingRef) { + setExcessPaymentPhone(''); + return; + } + + const timeout = setTimeout(async () => { + try { + const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 }); + const items = response?.items ?? []; + const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0]; + if (!match) return; + const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? ''; + setExcessPaymentPhone((prev) => prev || nextPhone); + } catch { + // Ignore lookup failures here; the staff member can still type a phone override manually. + } + }, 250); + + return () => clearTimeout(timeout); + }, [excessModalOpen, excessTicket]); + const openExcessModal = (ticket: any) => { setExcessTicket(ticket); setExcessKg(''); setExcessCollectCash(false); + setExcessPaymentPhone(''); setExcessError(null); setExcessResult(null); setExcessModalOpen(true); @@ -179,6 +205,7 @@ export default function TicketsPage() { bookingId: excessTicket.booking?.id ?? excessTicket.bookingId, excessWeightKg: parseInt(excessKg), collectCash: excessCollectCash, + contactPhone: excessPaymentPhone.trim() || undefined, }); }; @@ -979,6 +1006,15 @@ export default function TicketsPage() { required />
+
+ + setExcessPaymentPhone(e.target.value)} + /> +