Merge pull request #649 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-07-13 20:45:39 +03:00
committed by GitHub
19 changed files with 712 additions and 235 deletions

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'CAC_BANK';

View File

@@ -146,6 +146,7 @@ enum PaymentMethodType {
WALLET
WAAFI
DMONEY
CAC_BANK
@@schema("passenger")
}

View File

@@ -1,4 +1,9 @@
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
import {
BadGatewayException,
BadRequestException,
Injectable,
Logger,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
@@ -50,6 +55,52 @@ export class PaymentClientService {
}
}
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a
* BadRequest (retryable) rather than a 502, so the payer can re-enter the code.
*/
async confirmOtp(
intentId: string,
otp: string,
): Promise<PaymentIntentSnapshot> {
const url = `${this.baseUrl}/payments/intents/${intentId}/confirm`;
try {
const response = await firstValueFrom(
this.http.post<PaymentIntentSnapshot>(
url,
{ otp },
{
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
},
),
);
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
const detail =
(err.response.data as { message?: string | string[] })?.message ??
err.message;
// 400 = wrong/expired OTP, 404 = unknown intent → both are client-fixable.
if (err.response.status === 400 || err.response.status === 404) {
throw new BadRequestException(detail);
}
this.logger.error(
`payment service confirm ${intentId}${err.response.status}: ${detail}`,
);
throw new BadGatewayException(`Payment service error: ${detail}`);
}
this.logger.error(
`payment service unreachable (confirm ${intentId}): ${
err instanceof Error ? err.message : String(err)
}`,
);
throw new BadGatewayException("Payment service unreachable");
}
}
private async call<T>(
method: "GET" | "POST",
path: string,

View File

@@ -34,6 +34,7 @@ import {
PaymentPlatformDto,
BookingAmountResponseDto,
ForceConfirmDto,
ConfirmOtpDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -94,6 +95,21 @@ export class PaymentsController {
return this.service.getIntentByBookingId(bookingId);
}
@Post(":bookingId/confirm")
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Confirm an OTP-debit payment (CAC Bank)",
description:
"Submits the OTP the payer received by SMS. Returns the updated intent status. " +
"A wrong or expired OTP returns 400 and the payment stays open for retry.",
})
confirmOtp(
@Param("bookingId") bookingId: string,
@Body() dto: ConfirmOtpDto,
) {
return this.service.confirmOtpPayment(bookingId, dto.otp);
}
@Get("waafi/return")
@SetMetadata('isPublic', true)
@ApiOperation({

View File

@@ -22,6 +22,7 @@ export enum PaymentMethodTypeEnum {
EBIRR = "EBIRR", // Ethiopia
WAAFI = "WAAFI",
DMONEY= "DMONEY",// Djibouti
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
CARD = "CARD", // International
WALLET = "WALLET", // Internal
}
@@ -50,6 +51,24 @@ export class InitiatePaymentDto {
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
@ApiPropertyOptional({
description:
"Payer account / mobile number. Required for OTP-debit methods (CAC_BANK) — " +
"the bank sends the OTP to this number.",
example: "77112233",
})
@IsOptional()
@IsString()
payerAccount?: string;
}
export class ConfirmOtpDto {
@ApiProperty({
description: "One-time password the payer received by SMS (e.g. CAC Bank).",
example: "4530",
})
@IsString()
otp: string;
}
export class RefundDto {

View File

@@ -53,7 +53,11 @@ function rabbitMQImport(): DynamicModule[] {
SeatsModule,
TicketsModule,
CurrencyModule,
HttpModule.register({ timeout: 10_000 }),
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
HttpModule.register({
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
...rabbitMQImport(),
],
controllers: [PaymentsController, InternalPaymentsController],

View File

@@ -183,6 +183,14 @@ export class PaymentsService {
}
const method = dto.method as PaymentMethodType;
// CAC Bank is an OTP debit — the bank SMSes the OTP to this number, so it's required.
if (method === PaymentMethodType.CAC_BANK && !dto.payerAccount?.trim()) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
@@ -230,6 +238,7 @@ export class PaymentsService {
currency: chargeCurrency,
provider: method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl,
failureUrl,
});
@@ -248,6 +257,43 @@ export class PaymentsService {
}
return this.formatIntentResponse(intent);
}
/**
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by bookingId: the active
* remote intent is looked up by reference, the OTP is forwarded to the payment service,
* and the projection is refreshed. On success the booking is converged immediately
* (idempotent — the outbox → mark-paid path also converges it). A wrong/expired OTP
* bubbles up as a 400 so the payer can retry; the intent stays REQUIRES_ACTION.
*/
async confirmOtpPayment(
bookingId: string,
otp: string,
): Promise<IntentStatusDto> {
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
bookingId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm for this booking");
}
const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
let intent = await this.syncIntentProjection(bookingId, confirmed);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: confirmed.providerTxnId,
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
}
return this.formatIntentStatus(intent);
}
private resolveReturnUrls(method: PaymentMethodType): {
returnUrl?: string;
failureUrl?: string;

View File

@@ -203,6 +203,8 @@ export default function PaymentMethodsPage() {
{ value: 'CBE_BIRR', label: 'CBE Birr' },
{ value: 'EBIRR', label: 'eBirr' },
{ value: 'WAAFI', label: 'Waafi' },
{ value: 'DMONEY', label: 'dMoney' },
{ value: 'CAC_BANK', label: 'CAC Bank' },
{ value: 'CARD', label: 'Card Payment' },
{ value: 'WALLET', label: 'Internal Wallet' },
];

View File

@@ -34,7 +34,7 @@ interface SeatClass {
}
export default function PricingPage() {
const [tab, setTab] = useState<'segment' | 'schedule' | 'baggage'>('segment');
const [tab, setTab] = useState<'segment' | 'schedule'>('segment');
const [showModal, setShowModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<string>('');
const [selectedRoute, setSelectedRoute] = useState<string>('');
@@ -67,17 +67,6 @@ export default function PricingPage() {
validUntil: '',
});
const [baggageForm, setBaggageForm] = useState({
seatClassId: '',
maxWeightKg: '',
maxPiecesCount: '',
excessFeePerKg: '',
});
const [editingAllowance, setEditingAllowance] = useState<any>(null);
const [baggageError, setBaggageError] = useState<string | null>(null);
const [baggageModal, setBaggageModal] = useState(false);
const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null });
const { data: schedules = [] } = useQuery({
queryKey: ['schedules'],
queryFn: () => apiClient.get('/schedules'),
@@ -120,48 +109,6 @@ export default function PricingPage() {
enabled: !!selectedRoute && tab === 'segment',
});
const { data: allowances = [], isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({
queryKey: ['baggage-allowances'],
queryFn: () => apiClient.get<any[]>('/agents/excess-baggage/allowances'),
enabled: tab === 'baggage',
});
const createAllowanceMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data),
onSuccess: () => { refetchAllowances(); setBaggageModal(false); setBaggageError(null); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to save'),
});
const updateAllowanceMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data),
onSuccess: () => { refetchAllowances(); setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to update'),
});
const deleteAllowanceMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`),
onSuccess: () => { refetchAllowances(); setDeleteAllowanceConfirm({ isOpen: false, id: null }); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to delete'),
});
const handleSaveAllowance = async () => {
setBaggageError(null);
if (!baggageForm.seatClassId || !baggageForm.maxWeightKg || !baggageForm.maxPiecesCount || !baggageForm.excessFeePerKg) {
setBaggageError('All fields are required'); return;
}
const payload = {
seatClassId: baggageForm.seatClassId,
maxWeightKg: parseInt(baggageForm.maxWeightKg),
maxPiecesCount: parseInt(baggageForm.maxPiecesCount),
excessFeePerKg: Math.round(parseFloat(baggageForm.excessFeePerKg) * 100),
};
if (editingAllowance) {
await updateAllowanceMutation.mutateAsync({ id: editingAllowance.id, ...payload });
} else {
await createAllowanceMutation.mutateAsync(payload);
}
};
const createFareMutation = useMutation({
mutationFn: (data: any) => apiClient.post(`/schedules/fares`, data),
onSuccess: () => {
@@ -401,7 +348,6 @@ export default function PricingPage() {
const stationsArray = Array.isArray(stations) ? stations : (stations as any)?.items || [];
const faresArray = Array.isArray(fares) ? fares : (fares as any)?.items || [];
const segmentFaresArray = Array.isArray(segmentFares) ? segmentFares : (segmentFares as any)?.items || [];
const allowancesArray = Array.isArray(allowances) ? allowances : (allowances as any)?.items || [];
const currentRoute = routesArray.find((r: Route) => r.id === selectedRoute);
const fareColumns = [
@@ -554,12 +500,7 @@ export default function PricingPage() {
onClick={() => {
setError(null);
setEditingFare(null);
if (tab === 'baggage') {
setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
setEditingAllowance(null);
setBaggageError(null);
setBaggageModal(true);
} else if (tab === 'schedule') {
if (tab === 'schedule') {
setFareForm({
seatClassId: '',
baseFare: '',
@@ -584,7 +525,7 @@ export default function PricingPage() {
setShowModal(true);
}}
>
{tab === 'baggage' ? 'Add Allowance Rule' : 'Add Fare Rule'}
Add Fare Rule
</ActionButton>
</div>
@@ -604,13 +545,6 @@ export default function PricingPage() {
>
Schedule Fares
</button>
<button
onClick={() => { setTab('baggage'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Excess Luggage Rates
</button>
</div>
<div className="space-y-6">
@@ -720,52 +654,9 @@ export default function PricingPage() {
</>
)}
{tab === 'baggage' && (
<>
{allowancesLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="h-6 w-6 animate-spin" /></div>
) : allowancesArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No baggage allowance rules defined. Click "Add Allowance Rule" to create one.
</div>
) : (
<DataTable
data={allowancesArray}
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} </span> },
]}
actions={[
{
label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (a: any) => {
setEditingAllowance(a);
setBaggageForm({
seatClassId: a.seatClassId,
maxWeightKg: String(a.maxWeightKg),
maxPiecesCount: String(a.maxPiecesCount),
excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2),
});
setBaggageError(null);
setBaggageModal(true);
},
},
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (a: any) => setDeleteAllowanceConfirm({ isOpen: true, id: a.id }),
},
]}
loading={false}
emptyMessage="No allowance rules found."
/>
)}
</>
)}
</div>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
@@ -1095,54 +986,6 @@ export default function PricingPage() {
</div>
</div>
</Modal>
{/* Luggage Allowance Modal */}
<Modal isOpen={baggageModal} onClose={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md">
<div className="space-y-4">
{baggageError && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{baggageError}</div>}
<div>
<label className="label">Seat Class *</label>
<select value={baggageForm.seatClassId} onChange={(e) => setBaggageForm({ ...baggageForm, seatClassId: e.target.value })} className="input w-full" disabled={!!editingAllowance}>
<option value="">Select seat class...</option>
{seatClassesArray.map((sc: SeatClass) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
{editingAllowance && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Free Allowance (kg) *</label>
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={baggageForm.maxWeightKg} onChange={(e) => setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} />
</div>
<div>
<label className="label">Max Pieces *</label>
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={baggageForm.maxPiecesCount} onChange={(e) => setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} />
</div>
</div>
<div>
<label className="label">Excess Fee per kg (ETB) *</label>
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={baggageForm.excessFeePerKg} onChange={(e) => setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} />
<p className="text-xs text-muted-foreground mt-1">Amount charged per kg above the free allowance</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel</ActionButton>
<ActionButton onClick={handleSaveAllowance} loading={createAllowanceMutation.isPending || updateAllowanceMutation.isPending}>
{editingAllowance ? 'Update' : 'Save'}
</ActionButton>
</div>
</div>
</Modal>
{/* Delete Allowance Confirm */}
<ConfirmDialog
isOpen={deleteAllowanceConfirm.isOpen}
onClose={() => setDeleteAllowanceConfirm({ isOpen: false, id: null })}
onConfirm={() => deleteAllowanceMutation.mutateAsync(deleteAllowanceConfirm.id!)}
title="Delete Allowance Rule"
message="Are you sure you want to delete this baggage allowance rule?"
confirmText="Delete"
isDanger
isLoading={deleteAllowanceMutation.isPending}
warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created."
/>
</div>
);
}

View File

@@ -10,6 +10,8 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
interface SeatClass { id: string; name: string; }
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
const COACH_TYPE_LABELS: Record<string, string> = {
HSC: 'Regular Seat (Hard Seat)',
@@ -42,6 +44,7 @@ function getTariffRef(nationalityType: string, coachCode: string, bedPosition: s
}
export default function TariffRatesPage() {
const [tab, setTab] = useState<'tariff' | 'baggage'>('tariff');
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null);
@@ -50,8 +53,57 @@ export default function TariffRatesPage() {
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
const [selectedBedPosition, setSelectedBedPosition] = useState<string>('');
const [selectedNationalityType, setSelectedNationalityType] = useState<string>('LOCAL');
const [baggageForm, setBaggageForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
const [editingAllowance, setEditingAllowance] = useState<any>(null);
const [baggageError, setBaggageError] = useState<string | null>(null);
const [baggageModal, setBaggageModal] = useState(false);
const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null });
const queryClient = useQueryClient();
const { data: allowances, isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({
queryKey: ['baggage-allowances'],
queryFn: () => apiClient.get<any[]>('/agents/excess-baggage/allowances'),
enabled: tab === 'baggage',
});
const createAllowanceMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data),
onSuccess: () => { refetchAllowances(); setBaggageModal(false); setBaggageError(null); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to save'),
});
const updateAllowanceMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data),
onSuccess: () => { refetchAllowances(); setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to update'),
});
const deleteAllowanceMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`),
onSuccess: () => { refetchAllowances(); setDeleteAllowanceConfirm({ isOpen: false, id: null }); },
onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to delete'),
});
const handleSaveAllowance = async () => {
setBaggageError(null);
if (!baggageForm.seatClassId || !baggageForm.maxWeightKg || !baggageForm.maxPiecesCount || !baggageForm.excessFeePerKg) {
setBaggageError('All fields are required'); return;
}
const payload = {
seatClassId: baggageForm.seatClassId,
maxWeightKg: parseInt(baggageForm.maxWeightKg),
maxPiecesCount: parseInt(baggageForm.maxPiecesCount),
excessFeePerKg: Math.round(parseFloat(baggageForm.excessFeePerKg) * 100),
};
if (editingAllowance) {
await updateAllowanceMutation.mutateAsync({ id: editingAllowance.id, ...payload });
} else {
await createAllowanceMutation.mutateAsync(payload);
}
};
const { data: classesData, isLoading } = useQuery({
queryKey: ['seat-classes'],
queryFn: () => apiClient.get<any>('/seat-classes'),
@@ -126,6 +178,8 @@ export default function TariffRatesPage() {
? classesData
: (classesData as any)?.items || (classesData as any)?.data || [];
const allowancesArray: any[] = Array.isArray(allowances) ? allowances : (allowances as any)?.items || [];
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
const displayed = tariffClasses.filter((c: any) => {
@@ -232,32 +286,108 @@ export default function TariffRatesPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
<p className="text-muted-foreground">
Manage per-km fare rates by nationality, coach type, and bed position per the official EDR tariff policy
Manage per-km fare rates and excess luggage allowances per the official EDR tariff policy
</p>
</div>
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
Add Rate
<ActionButton
icon={Plus}
onClick={() => {
if (tab === 'baggage') {
setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
setEditingAllowance(null);
setBaggageError(null);
setBaggageModal(true);
} else {
setEditingClass(null);
setFormError(null);
setShowModal(true);
}
}}
>
{tab === 'baggage' ? 'Add Allowance Rule' : 'Add Rate'}
</ActionButton>
</div>
<div className="card">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, nationality, etc."
className="input pl-10 w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex gap-4 border-b mb-6">
<button
onClick={() => setTab('tariff')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'tariff' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
>
Seat Class Tariffs
</button>
<button
onClick={() => setTab('baggage')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
>
Excess Luggage Rates
</button>
</div>
<DataTable
data={displayed}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
/>
{tab === 'tariff' && (
<>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, nationality, etc."
className="input pl-10 w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<DataTable
data={displayed}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
/>
</>
)}
{tab === 'baggage' && (
allowancesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : allowancesArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No baggage allowance rules defined. Click "Add Allowance Rule" to create one.
</div>
) : (
<DataTable
data={allowancesArray}
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
]}
actions={[
{
label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (a: any) => {
setEditingAllowance(a);
setBaggageForm({
seatClassId: a.seatClassId,
maxWeightKg: String(a.maxWeightKg),
maxPiecesCount: String(a.maxPiecesCount),
excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2),
});
setBaggageError(null);
setBaggageModal(true);
},
},
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (a: any) => setDeleteAllowanceConfirm({ isOpen: true, id: a.id }),
},
]}
loading={false}
emptyMessage="No allowance rules found."
/>
)
)}
</div>
<ConfirmDialog
@@ -273,6 +403,60 @@ export default function TariffRatesPage() {
warning="Bookings in progress may be affected. Ensure a replacement rate exists."
/>
<ConfirmDialog
isOpen={deleteAllowanceConfirm.isOpen}
onClose={() => setDeleteAllowanceConfirm({ isOpen: false, id: null })}
onConfirm={() => deleteAllowanceMutation.mutateAsync(deleteAllowanceConfirm.id!)}
title="Delete Allowance Rule"
message="Are you sure you want to delete this baggage allowance rule?"
confirmText="Delete"
isDanger
isLoading={deleteAllowanceMutation.isPending}
warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created."
/>
<Modal
isOpen={baggageModal}
onClose={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}
title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
size="md"
>
<div className="space-y-4">
{baggageError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{baggageError}</div>
)}
<div>
<label className="label">Seat Class *</label>
<select value={baggageForm.seatClassId} onChange={(e) => setBaggageForm({ ...baggageForm, seatClassId: e.target.value })} className="input w-full" disabled={!!editingAllowance}>
<option value="">Select seat class...</option>
{allClasses.map((sc: SeatClass) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
{editingAllowance && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Free Allowance (kg) *</label>
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={baggageForm.maxWeightKg} onChange={(e) => setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} />
</div>
<div>
<label className="label">Max Pieces *</label>
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={baggageForm.maxPiecesCount} onChange={(e) => setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} />
</div>
</div>
<div>
<label className="label">Excess Fee per kg (ETB) *</label>
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={baggageForm.excessFeePerKg} onChange={(e) => setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} />
<p className="text-xs text-muted-foreground mt-1">Amount charged per kg above the free allowance</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel</ActionButton>
<ActionButton onClick={handleSaveAllowance} loading={createAllowanceMutation.isPending || updateAllowanceMutation.isPending}>
{editingAllowance ? 'Update' : 'Save'}
</ActionButton>
</div>
</div>
</Modal>
<Modal
isOpen={showModal}
onClose={closeModal}

View File

@@ -17,11 +17,14 @@ import {
Loader2,
CheckCircle,
ChevronLeft,
KeyRound,
Landmark,
} from "lucide-react";
const getIconForMethod = (methodId: string) => {
if (methodId.includes('CARD')) return CreditCard;
if (methodId.includes('WALLET')) return Wallet;
if (methodId.includes('CAC')) return Landmark;
return Smartphone;
};
@@ -34,6 +37,14 @@ export default function PaymentPage() {
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
// CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP.
const [payerMobile, setPayerMobile] = useState("");
const [phoneModalOpen, setPhoneModalOpen] = useState(false);
const [phoneError, setPhoneError] = useState<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpMessage, setOtpMessage] = useState<string | null>(null);
const [otpError, setOtpError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const isPackage = !!packageName;
@@ -118,13 +129,26 @@ export default function PaymentPage() {
bookingId: data.bookingId,
method: data.method,
paymentMethodId: data.paymentMethodId,
payerAccount: data.payerAccount,
platform: 'web',
});
},
onSuccess: async (data: any) => {
setPaymentError(null);
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
// CAC Bank: no redirect — the bank SMS'd an OTP. Collect it in-app and confirm.
if (data?.clientAction?.type === 'COLLECT_OTP') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
setOtpMessage(data.clientAction.message ?? "Enter the OTP sent to your phone");
setOtpCode("");
setOtpError(null);
setOtpModalOpen(true);
setIsProcessing(false);
return;
}
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') {
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
window.location.href = data.clientAction.url;
@@ -148,32 +172,73 @@ export default function PaymentPage() {
},
});
// CAC Bank OTP confirmation. A 200 means the payment settled; a 400 is a wrong/expired
// OTP — keep the modal open so the payer can re-enter it (the intent stays open).
const otpMutation = useMutation({
mutationFn: async (otp: string) => {
return await apiClient.post(`/payments/${bookingId}/confirm`, { otp });
},
onSuccess: () => {
setOtpModalOpen(false);
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
},
onError: (error: any) => {
setOtpError(
error?.response?.data?.message ||
error?.message ||
"Invalid or expired OTP. Please try again.",
);
},
});
const handlePayment = async () => {
if (!selectedMethod || !bookingId) {
alert("Please select a payment method");
return;
}
// Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
const startPayment = (mobile?: string) => {
if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
setIsProcessing(true);
setPaymentError(null);
if (!selectedPaymentMethod) {
alert("Invalid payment method selected");
setIsProcessing(false);
return;
}
paymentMutation.mutate({
bookingId,
method: selectedMethod,
paymentMethodId: selectedPaymentMethod.id,
currency: displayCurrency,
amountMinor: totalAmount,
payerAccount: selectedMethod === 'CAC_BANK' ? mobile?.trim() : undefined,
});
};
const handlePayment = () => {
if (!selectedMethod || !bookingId) {
alert("Please select a payment method");
return;
}
if (!selectedPaymentMethod) {
alert("Invalid payment method selected");
return;
}
setPaymentError(null);
// CAC Bank needs the payer's mobile for the OTP — collect it in a modal before initiating.
if (selectedMethod === 'CAC_BANK') {
setPhoneError(null);
setPhoneModalOpen(true);
return;
}
startPayment();
};
const submitPhone = () => {
if (!payerMobile.trim()) {
setPhoneError("Please enter your mobile number");
return;
}
setPhoneModalOpen(false);
startPayment(payerMobile);
};
// Redirect if no booking data (but not during navigation)
useEffect(() => {
// Add a small delay to allow state to be set from previous page
@@ -414,6 +479,100 @@ export default function PaymentPage() {
</div>
)}
{/* CAC Bank — collect payer mobile before initiating */}
{phoneModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
<div className="flex items-center gap-2 mb-1">
<Smartphone className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
CAC Bank will send a one-time password to this number to authorize the payment.
</p>
<input
type="tel"
inputMode="numeric"
autoFocus
value={payerMobile}
onChange={(e) => { setPayerMobile(e.target.value); setPhoneError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') submitPhone(); }}
placeholder="77 XX XX XX"
className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
/>
{phoneError && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2"> {phoneError}</p>
)}
<div className="flex gap-2 mt-4">
<button
onClick={() => setPhoneModalOpen(false)}
className="btn-secondary flex-1 py-2.5"
>
Cancel
</button>
<button
onClick={submitPhone}
disabled={!payerMobile.trim()}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
</button>
</div>
</div>
</div>
)}
{/* CAC Bank OTP entry */}
{otpModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
<div className="flex items-center gap-2 mb-1">
<KeyRound className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Enter OTP</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{otpMessage}
</p>
<input
type="text"
inputMode="numeric"
autoFocus
value={otpCode}
onChange={(e) => { setOtpCode(e.target.value.replace(/\D/g, '')); setOtpError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter' && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }}
placeholder="Enter code"
maxLength={10}
className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
/>
{otpError && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2"> {otpError}</p>
)}
<div className="flex gap-2 mt-4">
<button
onClick={() => { setOtpModalOpen(false); updateStatus("REQUIRES_ACTION"); }}
disabled={otpMutation.isPending}
className="btn-secondary flex-1 py-2.5"
>
Cancel
</button>
<button
onClick={() => otpCode.trim() && otpMutation.mutate(otpCode.trim())}
disabled={otpMutation.isPending || !otpCode.trim()}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{otpMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Verifying...
</span>
) : (
"Confirm payment"
)}
</button>
</div>
</div>
</div>
)}
{/* Two-column grid */}
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">

View File

@@ -23,7 +23,10 @@ import { useSearchParams } from "next/navigation";
*/
const ALLOWED_HOSTS = (
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj"
// Default allows both D-Money environments:
// test/sandbox → pgtest.d-money.dj (base domain d-money.dj)
// production → pg.d-moneyservice.dj (base domain d-moneyservice.dj)
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj"
)
.split(",")
.map((h) => h.trim().toLowerCase())

View File

@@ -10,4 +10,7 @@ export default registerAs("cac", () => ({
currency: process.env.CAC_CURRENCY || "DJF",
tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000),
otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 60 * 1000),
// The bank's PaymentInitiateRequest sends an OTP by SMS and can be slow; the bank asked us
// to raise the client timeout. Generous default, overridable via env.
httpTimeoutMs: Number(process.env.CAC_HTTP_TIMEOUT_MS || 60_000),
}));

View File

@@ -180,7 +180,12 @@ export class IntentsService {
);
}
if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) {
// REQUIRES_ACTION is the normal awaiting-OTP state; PROCESSING is tolerated so an intent
// that a poll/sweep nudged forward can still be confirmed. Terminal states are rejected.
if (
intent.status !== ProviderPaymentStatus.REQUIRES_ACTION &&
intent.status !== ProviderPaymentStatus.PROCESSING
) {
throw new BadRequestException(
`Intent is not awaiting confirmation (status=${intent.status})`,
);
@@ -211,15 +216,44 @@ export class IntentsService {
providerTxnId: confirmResult.providerTxnId,
paidAt: new Date(),
});
} else {
await this.applyProviderResult(intent.id, {
status: ProviderPaymentStatus.FAILED,
failureCode: confirmResult.failureCode,
failureMessage: confirmResult.failureMessage,
});
return this.snapshotOf(intent.id);
}
const updated = await this.intentsRepository.findById(intent.id);
// Confirm did not clearly succeed. CAC has no callback and the confirm response can be
// lost after the customer was charged, so before failing anything verify the source of
// truth by paymentRequestId (GetPaymentByReferenceRequest keys on it).
const verified = await this.cacBankProvider
.queryStatus(intent.providerOrderId)
.catch((err: unknown) => {
this.logger.warn(
`CAC verify after failed confirm errored for intent ${intent.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
});
if (verified?.status === ProviderPaymentStatus.SUCCEEDED) {
await this.applyProviderResult(intent.id, {
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: verified.providerTxnId,
paidAt: new Date(),
});
return this.snapshotOf(intent.id);
}
// Genuinely not paid — almost always a wrong or expired OTP. Leave the intent in
// REQUIRES_ACTION so the payer can re-enter the code, and do NOT emit payment.failed:
// a mistyped OTP must not cancel the booking. The reconciliation sweep CANCELs the
// intent once its OTP window (expiresAt) passes.
throw new BadRequestException(
confirmResult.failureMessage ??
"OTP confirmation failed — please re-enter the code sent to your phone",
);
}
private async snapshotOf(intentId: string): Promise<PaymentIntentSnapshot> {
const updated = await this.intentsRepository.findById(intentId);
if (!updated) throw new NotFoundException("PaymentIntent not found");
return this.toSnapshot(updated);
}
@@ -306,12 +340,12 @@ export class IntentsService {
}
if (intent.provider === ProviderMethod.CAC_BANK) {
const reference = (intent.rawInitiation as { reference?: string })
?.reference;
return this.cacBankProvider.queryStatus(
intent.merchantOrderId,
reference,
);
if (!intent.providerOrderId) {
throw new Error(
`CAC intent ${intent.id} has no providerOrderId to verify`,
);
}
return this.cacBankProvider.queryStatus(intent.providerOrderId);
}
return provider.queryStatus(intent.merchantOrderId);

View File

@@ -87,8 +87,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
const status =
intent.provider === ProviderMethod.CAC_BANK
? await this.cacBankProvider.queryStatus(
intent.merchantOrderId,
(intent.rawInitiation as { reference?: string })?.reference,
intent.providerOrderId ?? intent.merchantOrderId,
)
: await provider.queryStatus(intent.merchantOrderId);
const result = this.intentsService.fromProviderStatus(status);

View File

@@ -14,6 +14,7 @@ export interface CacAuthConfig {
username: string;
password: string;
tokenTtlMs: number;
httpTimeoutMs: number;
}
/**
@@ -63,7 +64,7 @@ export class CacBankAuth {
const res = await firstValueFrom(
this.http.post<CacSigninResponse>(url, body, {
headers: { "Content-Type": "application/json" },
timeout: 10_000,
timeout: this.config.httpTimeoutMs,
}),
);
const token = res.data.accessToken;

View File

@@ -0,0 +1,66 @@
/**
* Lossless JSON handling for CAC Bank.
*
* CAC returns 17-digit identifiers — `paymentRequestId`, `confirmReference`,
* `transactionNo` (spec: numeric, <=18) — that exceed `Number.MAX_SAFE_INTEGER`
* (9,007,199,254,740,991). A plain `JSON.parse` silently rounds them
* (…240611 → …240610), corrupting the id we send back to CONFIRM the payment and use
* to VERIFY it via GetPaymentByReferenceRequest. So we quote those fields to strings
* before parsing and carry them as strings end-to-end, then re-emit id fields as raw
* JSON numbers when building requests. This avoids a bigint-JSON dependency for the
* three fields that need it.
*/
/** Response id fields that must survive as exact strings, not JS numbers. */
const RESPONSE_ID_FIELDS = [
"paymentRequestId",
"confirmReference",
"transactionNo",
] as const;
/**
* Request id fields we carry as strings but the bank types as `numeric`, so they must
* go on the wire unquoted. Only PaymentConfirmationRequest.payment_request_id qualifies;
* GetPaymentByReferenceRequest.reference is a string field and stays quoted.
*/
const REQUEST_NUMERIC_ID_FIELDS = ["payment_request_id"] as const;
/**
* Parse a CAC JSON response body, keeping oversized integer ids as exact strings.
* `raw` is the untouched response text (axios response transform is disabled for CAC).
*/
export function parseCacResponse<T>(raw: string): T {
const pattern = new RegExp(
`"(${RESPONSE_ID_FIELDS.join("|")})"\\s*:\\s*(-?\\d+)`,
"g",
);
const quoted = raw.replace(pattern, '"$1":"$2"');
return JSON.parse(quoted) as T;
}
/**
* Serialize a CAC request body. Numeric id fields we hold as strings are emitted as raw
* JSON numbers (unquoted) so their full precision reaches the bank, matching the spec's
* `numeric` type. All other fields serialize normally.
*/
export function serializeCacRequest(body: unknown): string {
let json = JSON.stringify(body);
for (const field of REQUEST_NUMERIC_ID_FIELDS) {
json = json.replace(
new RegExp(`("${field}"\\s*:\\s*)"(-?\\d+)"`, "g"),
"$1$2",
);
}
return json;
}
/**
* Normalize a Djibouti mobile number to the bare 8-digit national form the bank expects
* (spec example `77112233`). Strips a leading `+253` / `00253` / `253` country code and any
* spaces or dashes. Returns the input trimmed if it doesn't match the expected shape.
*/
export function normalizeCacMobile(mobile: string): string {
const digits = mobile.replace(/[\s-]/g, "").replace(/^\+/, "");
const national = digits.replace(/^(?:00)?253/, "");
return national || digits;
}

View File

@@ -12,6 +12,11 @@ import {
import { AxiosError, AxiosRequestConfig } from "axios";
import { firstValueFrom } from "rxjs";
import { CacBankAuth } from "./cac-bank.auth";
import {
normalizeCacMobile,
parseCacResponse,
serializeCacRequest,
} from "./cac-bank.json";
import type {
CacConfirmResult,
CacGetPaymentByReferenceRequest,
@@ -22,6 +27,10 @@ import type {
CacPaymentInitiateResponse,
} from "./cac-bank.types";
/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). */
const CAC_MIN_AMOUNT = 10;
const CAC_MAX_AMOUNT = 100_000;
@Injectable()
export class CacBankProvider implements PaymentProvider, OnModuleInit {
readonly method = ProviderMethod.CAC_BANK;
@@ -63,19 +72,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
throw new Error("CAC Bank requires payerAccount (customer mobile number)");
}
const customerMobile = normalizeCacMobile(input.payerAccount);
const amount = this.toMajorAmount(input.amountMinor, input.currency);
if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) {
throw new Error(
`CAC Bank amount ${amount} ${input.currency} is outside the accepted range ` +
`(${CAC_MIN_AMOUNT}${CAC_MAX_AMOUNT} DJF)`,
);
}
const requestBody: CacPaymentInitiateRequest = {
app_key: this.appKey,
api_key: this.apiKey,
customer_mobile: input.payerAccount,
customer_mobile: customerMobile,
currency: input.currency || this.defaultCurrency,
desc: `${input.orderRef}`.slice(0, 500),
vender_ref: input.merchantOrderId,
amount: this.toMajorAmount(input.amountMinor, input.currency),
amount,
company_services_id: this.companyServicesId,
};
this.logger.log(
`CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${input.payerAccount} ref=${input.merchantOrderId}`,
`CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${customerMobile} ref=${input.merchantOrderId}`,
);
this.logger.debug(
`CAC Bank initiate request body: ${JSON.stringify(this.sanitizeKeys(requestBody))}`,
@@ -98,7 +116,8 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
);
}
const providerOrderId = String(response.paymentRequestId);
// Already an exact string (parseCacResponse keeps the 17-digit id lossless).
const providerOrderId = response.paymentRequestId;
const expiresAt = new Date(Date.now() + this.otpExpiryMs);
return {
@@ -124,7 +143,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
const requestBody: CacPaymentConfirmRequest = {
app_key: this.appKey,
api_key: this.apiKey,
payment_request_id: Number(paymentRequestId),
// Kept as a string here; serializeCacRequest emits it as a raw JSON number so the
// full 17-digit precision reaches the bank.
payment_request_id: paymentRequestId,
otp,
};
@@ -169,15 +190,20 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
}
}
async queryStatus(
merchantOrderId: string,
reference?: string,
): Promise<ProviderStatus> {
const lookupRef = reference ?? merchantOrderId;
/**
* Verify a payment via GetPaymentByReferenceRequest, keyed on the paymentRequestId. This
* is CAC's callback replacement: the bank sends no webhook, but the id is known from
* initiate and the lookup accepts it, so a lost/failed confirm can still be reconciled.
* A settled payment carries a transactionNo; anything else means the OTP hasn't been
* confirmed yet — that's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING.
* Returning PROCESSING would let a poll/sweep advance the intent out of REQUIRES_ACTION
* and block the confirm() call.
*/
async queryStatus(paymentRequestId: string): Promise<ProviderStatus> {
const requestBody: CacGetPaymentByReferenceRequest = {
app_key: this.appKey,
api_key: this.apiKey,
reference: lookupRef,
reference: paymentRequestId,
};
try {
@@ -195,14 +221,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
}
return {
status: ProviderPaymentStatus.PROCESSING,
status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: response as unknown as Record<string, unknown>,
};
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) {
return {
status: ProviderPaymentStatus.PROCESSING,
rawResponse: { notFound: true, reference: lookupRef },
status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: { notFound: true, reference: paymentRequestId },
};
}
throw err;
@@ -212,21 +238,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
private async postJson<T>(path: string, body: unknown): Promise<T> {
const token = await this.getAuth().getAccessToken();
const url = `${this.baseUrl}${path}`;
// Serialize ourselves so numeric ids we carry as strings go on the wire unquoted.
const payload = serializeCacRequest(body);
const config: AxiosRequestConfig = {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
timeout: 10_000,
timeout: this.httpTimeoutMs,
// Keep the raw response text — 17-digit ids would lose precision under axios's
// default JSON.parse. We parse losslessly with parseCacResponse.
transformResponse: [(data) => data],
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
const res = await firstValueFrom(
this.http.post<string>(url, payload, config),
);
this.logger.debug(
`CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`,
);
return res.data;
return parseCacResponse<T>(res.data);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 401) {
this.getAuth().invalidate();
@@ -239,9 +272,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
},
};
const res = await firstValueFrom(
this.http.post<T>(url, body, retryConfig),
this.http.post<string>(url, payload, retryConfig),
);
return res.data;
return parseCacResponse<T>(res.data);
}
if (err instanceof AxiosError) {
@@ -264,6 +297,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
username: this.username,
password: this.password,
tokenTtlMs: this.tokenTtlMs,
httpTimeoutMs: this.httpTimeoutMs,
});
}
return this.auth;
@@ -311,4 +345,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
private get otpExpiryMs(): number {
return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
}
private get httpTimeoutMs(): number {
return this.config.get<number>("cac.httpTimeoutMs") ?? 60_000;
}
}

View File

@@ -24,19 +24,25 @@ export interface CacPaymentInitiateRequest {
export interface CacPaymentInitiateResponse {
description: string;
paymentRequestId: number;
/** Numeric id (<=18 digits) kept as a string — it exceeds JS's safe integer range. */
paymentRequestId: string;
}
export interface CacPaymentConfirmRequest {
app_key: string;
api_key: string;
payment_request_id: number;
/**
* Carried as a string for precision; emitted as a raw JSON number on the wire by
* `serializeCacRequest` (the bank types this field as `numeric`).
*/
payment_request_id: string;
otp: string;
}
export interface CacPaymentConfirmResponse {
description: string;
confirmReference: number;
/** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
confirmReference: string;
reference: string;
}
@@ -52,7 +58,8 @@ export interface CacPaymentByReferenceResponse {
reference: string;
amount: number;
transactionDate: string;
transactionNo: number;
/** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
transactionNo: string;
}
export interface CacConfirmResult {