diff --git a/apps/edr-passenger-api/prisma/migrations/20260713103724_add_cac_bank_in_payment_method_type/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260713103724_add_cac_bank_in_payment_method_type/migration.sql new file mode 100644 index 000000000..3d0b0b022 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260713103724_add_cac_bank_in_payment_method_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "PaymentMethodType" ADD VALUE 'CAC_BANK'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 73387fb18..00c5a8194 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -146,6 +146,7 @@ enum PaymentMethodType { WALLET WAAFI DMONEY + CAC_BANK @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts index 7b1789ae9..c9264eeda 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -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 { + const url = `${this.baseUrl}/payments/intents/${intentId}/confirm`; + try { + const response = await firstValueFrom( + this.http.post( + 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( method: "GET" | "POST", path: string, 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 2cdbc86e4..58be16c05 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -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({ 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 8d24091a1..825fa114e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 0db784838..1b9af89f3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -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], 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 4b0284935..4a02941aa 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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 { + 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; diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx index 3e8b8c849..8937497c6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx @@ -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' }, ]; diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index 73ce87383..5de572542 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -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(''); const [selectedRoute, setSelectedRoute] = useState(''); @@ -67,17 +67,6 @@ export default function PricingPage() { validUntil: '', }); - const [baggageForm, setBaggageForm] = useState({ - seatClassId: '', - maxWeightKg: '', - maxPiecesCount: '', - excessFeePerKg: '', - }); - const [editingAllowance, setEditingAllowance] = useState(null); - const [baggageError, setBaggageError] = useState(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('/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 @@ -604,13 +545,6 @@ export default function PricingPage() { > Schedule Fares -
@@ -720,52 +654,9 @@ export default function PricingPage() { )} - {tab === 'baggage' && ( - <> - {allowancesLoading ? ( -
- ) : allowancesArray.length === 0 ? ( -
- No baggage allowance rules defined. Click "Add Allowance Rule" to create one. -
- ) : ( - {a.seatClass?.name ?? a.seatClassId} }, - { key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs }, - { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => {(a.excessFeePerKg / 100).toFixed(2)} }, - ]} - 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." - /> - )} - - )}
- {/* Delete Confirmation */} setDeleteConfirm({ isOpen: false, id: null })} @@ -1095,54 +986,6 @@ export default function PricingPage() { - {/* Luggage Allowance Modal */} - { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md"> -
- {baggageError &&
{baggageError}
} -
- - - {editingAllowance &&

Seat class cannot be changed. Delete and recreate to change.

} -
-
-
- - setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} /> -
-
- - setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} /> -
-
-
- - setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} /> -

Amount charged per kg above the free allowance

-
-
- { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel - - {editingAllowance ? 'Update' : 'Save'} - -
-
-
- - {/* Delete Allowance Confirm */} - 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." - /> ); } diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 590c30848..530b335e7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -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 = { 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(null); @@ -50,8 +53,57 @@ export default function TariffRatesPage() { const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const [selectedBedPosition, setSelectedBedPosition] = useState(''); const [selectedNationalityType, setSelectedNationalityType] = useState('LOCAL'); + + const [baggageForm, setBaggageForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' }); + const [editingAllowance, setEditingAllowance] = useState(null); + const [baggageError, setBaggageError] = useState(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('/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('/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() {

Tariff Rates

- 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

- { setEditingClass(null); setFormError(null); setShowModal(true); }}> - Add Rate + { + 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'}
-
- - setSearch(e.target.value)} - /> +
+ +
- + + {tab === 'tariff' && ( + <> +
+ + setSearch(e.target.value)} + /> +
+ + + )} + + {tab === 'baggage' && ( + allowancesLoading ? ( +
+
+
+ ) : allowancesArray.length === 0 ? ( +
+ No baggage allowance rules defined. Click "Add Allowance Rule" to create one. +
+ ) : ( + {a.seatClass?.name ?? a.seatClassId} }, + { key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs }, + { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => {(a.excessFeePerKg / 100).toFixed(2)} ETB }, + ]} + 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." + /> + ) + )}
+ 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." + /> + + { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} + title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} + size="md" + > +
+ {baggageError && ( +
{baggageError}
+ )} +
+ + + {editingAllowance &&

Seat class cannot be changed. Delete and recreate to change.

} +
+
+
+ + setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} /> +
+
+ + setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} /> +
+
+
+ + setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} /> +

Amount charged per kg above the free allowance

+
+
+ { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel + + {editingAllowance ? 'Update' : 'Save'} + +
+
+
+ { 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(null); const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(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(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpMessage, setOtpMessage] = useState(null); + const [otpError, setOtpError] = useState(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() {
)} + {/* CAC Bank — collect payer mobile before initiating */} + {phoneModalOpen && ( +
+
+
+ +

Your mobile number

+
+

+ CAC Bank will send a one-time password to this number to authorize the payment. +

+ { 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 && ( +

⚠️ {phoneError}

+ )} +
+ + +
+
+
+ )} + + {/* CAC Bank OTP entry */} + {otpModalOpen && ( +
+
+
+ +

Enter OTP

+
+

+ {otpMessage} +

+ { 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 && ( +

⚠️ {otpError}

+ )} +
+ + +
+
+
+ )} + {/* Two-column grid */}
diff --git a/apps/edr-passenger-web/portal/src/app/go/page.tsx b/apps/edr-passenger-web/portal/src/app/go/page.tsx index feed05990..7d2063022 100644 --- a/apps/edr-passenger-web/portal/src/app/go/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/go/page.tsx @@ -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()) diff --git a/apps/edr-payment-api/src/config/cac.config.ts b/apps/edr-payment-api/src/config/cac.config.ts index 360e64299..3b07e6906 100644 --- a/apps/edr-payment-api/src/config/cac.config.ts +++ b/apps/edr-payment-api/src/config/cac.config.ts @@ -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), })); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index ccd10f508..0ebaaad05 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -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 { + 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); diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts index 98595f8f2..7c4805ddb 100644 --- a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts @@ -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); diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts index ee20b0aa5..0f6ac2063 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts @@ -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(url, body, { headers: { "Content-Type": "application/json" }, - timeout: 10_000, + timeout: this.config.httpTimeoutMs, }), ); const token = res.data.accessToken; diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.json.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.json.ts new file mode 100644 index 000000000..f58aa9e64 --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.json.ts @@ -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(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; +} diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts index 8e5a94bbb..df69c4b1f 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts @@ -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 { - 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 { 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, }; } 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(path: string, body: unknown): Promise { 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(url, body, config)); + const res = await firstValueFrom( + this.http.post(url, payload, config), + ); this.logger.debug( `CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`, ); - return res.data; + return parseCacResponse(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(url, body, retryConfig), + this.http.post(url, payload, retryConfig), ); - return res.data; + return parseCacResponse(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("cac.otpExpiryMs") ?? 10 * 60 * 1000; } + private get httpTimeoutMs(): number { + return this.config.get("cac.httpTimeoutMs") ?? 60_000; + } } diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts index 6e2b7ba5b..522994c4f 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts @@ -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 {