feat: add contact phone overrides for payment links in excess baggage and supplementary charges

This commit is contained in:
Stephanos A
2026-08-21 18:26:38 +03:00
parent 5d6a21b6be
commit d2a942ffbf
9 changed files with 145 additions and 20 deletions

View File

@@ -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' })

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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<string | null>(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<string | null>(null);
const [resendModal, setResendModal] = useState<any>(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() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}>
Log Excess Luggage
</ActionButton>
</div>
@@ -256,6 +280,15 @@ export default function ExcessBaggagePage() {
onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })}
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={logForm.paymentPhone}
onChange={(e) => setLogForm({ ...logForm, paymentPhone: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
@@ -282,7 +315,7 @@ export default function ExcessBaggagePage() {
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
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.
</p>
)}
</>
@@ -302,6 +335,7 @@ export default function ExcessBaggagePage() {
bookingReference: logForm.bookingReference.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
contactPhone: logForm.paymentPhone.trim() || undefined,
});
}}
>

View File

@@ -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<string | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(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) {
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
<input className="input" placeholder="e.g. EDR-20240001" value={form.bookingRef} onChange={(e) => setForm({ ...form, bookingRef: e.target.value })} />
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input className="input" placeholder="e.g. +251911223344" value={form.paymentPhone} onChange={(e) => setForm({ ...form, paymentPhone: e.target.value })} />
</div>
<div>
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
<input className="input" type="number" min="0.01" step="0.01" placeholder="e.g. 50.00" value={form.amountEtb} onChange={(e) => setForm({ ...form, amountEtb: e.target.value })} />
@@ -69,7 +104,7 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">

View File

@@ -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();

View File

@@ -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<any>(null);
const [excessKg, setExcessKg] = useState('');
const [excessCollectCash, setExcessCollectCash] = useState(false);
const [excessPaymentPhone, setExcessPaymentPhone] = useState('');
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(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
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={excessPaymentPhone}
onChange={(e) => setExcessPaymentPhone(e.target.value)}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"

View File

@@ -200,8 +200,14 @@ export const paymentsApi = {
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
supplementary: {
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
apiClient.post<any>('/payments/supplementary', data),
create: (data: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
}) => apiClient.post<any>('/payments/supplementary', data),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)