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/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index f5d71229d..e2e90be05 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -119,10 +119,13 @@ export default function ConfirmationPage() { const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; // The server-confirmed settled amount/currency (what was actually charged) is - // authoritative — prefer it over the ETB booking fare once it's available. + // authoritative — prefer it over the ETB booking fare once available. Shown exactly + // as returned by the API (no /100, no per-passenger split) on every passenger's + // voucher — see fareIsMajorUnits below. const settledAmountMinor = _booking?.payment?.amountMinor; const settledCurrency = _booking?.payment?.currency; - const voucherCurrency = settledCurrency || "ETB"; + const hasSettledAmount = settledAmountMinor != null && !!settledCurrency; + const voucherCurrency = hasSettledAmount ? settledCurrency! : "ETB"; const createdAt = _booking?.createdAt || new Date().toISOString(); const status = _booking?.status || "CONFIRMED"; @@ -154,21 +157,6 @@ export default function ConfirmationPage() { return Math.round(totalFare / passengers.length); }; - // Real conversion happened (payment settled in something other than ETB) — scale each - // passenger's ETB fare proportionally into the settled currency, rather than showing - // ETB-denominated numbers next to a foreign currency label. - const etbFares = passengers.map((_, idx) => getEtbFare(idx)); - const etbTotal = etbFares.reduce((sum, f) => sum + f, 0); - const needsConversion = - settledAmountMinor != null && - settledCurrency && - settledCurrency !== "ETB" && - etbTotal > 0; - const getVoucherFare = (idx: number): number => { - if (!needsConversion) return etbFares[idx]; - return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal)); - }; - const outbound = { trainNumber: activeSchedule?.trainNumber || "N/A", trainName: "EDR Express", @@ -234,8 +222,9 @@ export default function ConfirmationPage() { outboundSchedule: outbound, inboundSchedule: inbound, isRoundTrip, - fareMinor: getVoucherFare(i), + fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i), currency: voucherCurrency, + fareIsMajorUnits: hasSettledAmount, createdAt, }); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 677fa2497..7aef411e6 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -18,6 +18,7 @@ import { Smartphone, Loader2, ChevronLeft, + X, } from "lucide-react"; import { format } from "date-fns"; import { formatTime, getTimePeriod } from "@/utils/format"; @@ -53,6 +54,11 @@ function BookingDetailContent() { string | null >(null); const [paymentError, setPaymentError] = useState(null); + // The mobile Pay trigger opens this modal instead of living in a `fixed bottom-0` + // bar — that bar kept getting covered by the phone's own home-indicator/gesture + // nav bar. The modal's footer button is a normal in-flow flex item instead, so it + // can't end up pinned underneath system chrome. + const [paymentModalOpen, setPaymentModalOpen] = useState(false); const [copiedPNR, setCopiedPNR] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); @@ -486,7 +492,7 @@ function BookingDetailContent() { if (isPendingPayment && !isExpired) { return ( -
+

@@ -676,6 +682,11 @@ function BookingDetailContent() { setSelectedMethodCurrency( method.currency ?? null, ); + // Mobile only — desktop's Pay button lives inline in the + // sidebar OrderSummary, not behind a modal. + if (window.innerWidth < 1024) { + setPaymentModalOpen(true); + } }} disabled={ paymentMutation.isPending || @@ -735,57 +746,93 @@ function BookingDetailContent() {

- {/* Mobile sticky bottom bar */} -
-
- - Total - - - {awaitingAmount ? ( - - ) : ( - <> - {confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} - - )} - + {/* Mobile payment dialog — the amount + Back/Pay buttons live here now, + opened by picking a payment method above. Centered dialog-box style + (not a full-screen sheet) so it can't end up pinned under system chrome. */} + {paymentModalOpen && ( +
+ {/* Backdrop */} +
setPaymentModalOpen(false)} + /> + + {/* Dialog */} +
+
+

+ Confirm payment +

+ +
+ +
+
+ + Total + + + {awaitingAmount ? ( + + ) : ( + <> + {confirmedCurrency}{" "} + {(totalAmountDisplay ?? 0).toFixed(2)} + + )} + +
+

+ {selectedPaymentMethod + ? `via ${selectedPaymentMethod.displayName}` + : "Select a payment method to continue."} +

+ {paymentError && ( +

+ ⚠️ {paymentError} +

+ )} +
+ +
+ +
+
- {paymentError && ( -

- ⚠️ {paymentError} -

- )} -
- - -
-
+ )}
); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 6ce8b5f66..3992e140f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -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(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 @@ -372,7 +437,7 @@ export default function PaymentPage() { ); return ( -
+

Complete payment

@@ -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 */}
@@ -487,7 +646,7 @@ export default function PaymentPage() {
{/* Mobile sticky bottom bar */} -
+
Total diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 30e5393f9..371905e11 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -25,14 +25,16 @@ import { useEffect, useRef, useState, useCallback } from "react"; import ModernDatePicker from "@/components/ModernDatePicker"; function useDarkMode() { - const [dark, setDark] = useState(() => - typeof window !== 'undefined' && document.documentElement.classList.contains('dark') + const [dark, setDark] = useState( + () => + typeof window !== "undefined" && + document.documentElement.classList.contains("dark"), ); useEffect(() => { const obs = new MutationObserver(() => - setDark(document.documentElement.classList.contains('dark')) + setDark(document.documentElement.classList.contains("dark")), ); - obs.observe(document.documentElement, { attributeFilter: ['class'] }); + obs.observe(document.documentElement, { attributeFilter: ["class"] }); return () => obs.disconnect(); }, []); return dark; @@ -344,7 +346,9 @@ function PassengerModal({ {nationalityError && (

{nationalityError}

)} -
+
{natOptions.map((opt) => ( + +
+
+ ); + + // From/To fields — rendered both in the mobile collapsed view (so a station can be + // picked without opening the full modal) and inside the modal itself (prefilled with + // whatever was already picked). Tapping either still opens StationModal directly. + // stacked: single column for the collapsed first view; the modal keeps the 2-column grid. + const renderStationFields = (stacked = false) => ( +
+
+
+ +
+ + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )} +
+
+
+ + +
+ + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )} +
+
+ ); return (
@@ -765,7 +938,9 @@ export default function SearchPage() { clearErrors("nationality"); }} onClose={() => setPassengerModalOpen(false)} - nationalityError={showNationalityError ? errors.nationality?.message : undefined} + nationalityError={ + showNationalityError ? errors.nationality?.message : undefined + } /> )} @@ -870,241 +1045,182 @@ export default function SearchPage() { )}
- {/* Trip Type Tabs */} -
-
- - -
+ {/* Trip Type Tabs — always visible on desktop; on mobile only inside the + search modal (hidden in the collapsed view). */} +
+ {renderTripTypeTabs()}
- {/* Mobile: stacked, but From/To and Date/Return Date pair up into two - columns each to save vertical space (station names/dates truncate - rather than wrap) — same fields, same behavior, just denser. */} -
-
-
-
- -
- - {hasInteracted && errors.originStationId && ( -

- {errors.originStationId.message} -

- )} -
-
-
- - -
- - {hasInteracted && errors.destinationStationId && ( -

- {errors.destinationStationId.message} -

- )} -
-
-
-
- -
- { - setValue( - "departureDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("departureDate"); - }} - minDate={new Date()} - placeholder="Departure date" - error={!!errors.departureDate} - /> -
- {errors.departureDate && ( -

- {errors.departureDate.message} -

- )} -
- {tripType === "ROUND_TRIP" && ( -
- -
- { - setValue( - "returnDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("returnDate"); - }} - minDate={ - departureDate - ? new Date(departureDate + "T00:00:00") - : new Date() - } - placeholder="Return date" - error={!!errors.returnDate} - /> -
- {errors.returnDate && ( -

- {errors.returnDate.message} -

- )} -
- )} -
- {/* Pax + Nationality combined trigger */} + {/* Mobile collapsed view — just From/To + Search. Tapping a station still + opens StationModal directly; tapping Search opens the full modal below + instead of submitting (validation happens inside that modal). */} +
+ {renderStationFields(true)} - {showNationalityError && ( -

{errors.nationality?.message}

- )} -
+ {/* Mobile search modal — full field set (trip type, From/To, dates, + passengers/nationality). Its Search button is the real form submit — + same onSubmit/onInvalid/validation as before, just relocated here. */} + {mobileSearchModalOpen && ( +
+
+ +

+ Plan and Book Your Trip +

+
+ +
+ {renderTripTypeTabs()} + {renderStationFields()} +
+
+ +
+ { + setValue( + "departureDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("departureDate"); + }} + minDate={new Date()} + placeholder="Departure date" + error={!!errors.departureDate} + /> +
+ {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )} +
+ {tripType === "ROUND_TRIP" && ( +
+ +
+ { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } + placeholder="Return date" + error={!!errors.returnDate} + /> +
+ {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )} +
+ )} +
+ {/* Pax + Nationality combined trigger */} + + {showNationalityError && ( +

+ {errors.nationality?.message} +

+ )} + +
+
+ )} + {/* Desktop: dynamic layout based on trip type */}
{tripType === "ONE_WAY" ? ( @@ -1219,12 +1335,17 @@ export default function SearchPage() { type="button" onClick={() => setPassengerModalOpen(true)} className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${ - showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700" + showNationalityError + ? "border-red-400" + : "border-gray-200 dark:border-gray-700" }`} > - {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"} + {totalPassengers}{" "} + {totalPassengers === 1 + ? "Passenger" + : "Passengers"} {nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` : " · Nationality"} @@ -1232,7 +1353,9 @@ export default function SearchPage() { {showNationalityError && ( -

{errors.nationality?.message}

+

+ {errors.nationality?.message} +

)}
{/* Search */} @@ -1259,7 +1382,9 @@ export default function SearchPage() {
{/* From */}
- + {hasInteracted && errors.originStationId && ( -

{errors.originStationId.message}

+

+ {errors.originStationId.message} +

)}
{/* Swap */} @@ -1291,7 +1422,9 @@ export default function SearchPage() { {/* To */}
- + {hasInteracted && errors.destinationStationId && ( -

{errors.destinationStationId.message}

+

+ {errors.destinationStationId.message} +

)}
{/* Departure Date */}
- + { - setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`); + setValue( + "departureDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); trigger("departureDate"); trigger("returnDate"); }} @@ -1325,44 +1473,72 @@ export default function SearchPage() { placeholder="Departure date" /> {errors.departureDate && ( -

{errors.departureDate.message}

+

+ {errors.departureDate.message} +

)}
{/* Return Date */}
- + { - setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`); + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); trigger("returnDate"); }} - minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } placeholder="Return date" /> {errors.returnDate && ( -

{errors.returnDate.message}

+

+ {errors.returnDate.message} +

)}
{/* Passengers */}
- + {showNationalityError && ( -

{errors.nationality?.message}

+

+ {errors.nationality?.message} +

)}
{/* Search */} @@ -1386,7 +1562,6 @@ export default function SearchPage() {
)}
-
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-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx index 1128124e3..7e6049956 100644 --- a/apps/edr-passenger-web/portal/src/app/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import { headers } from 'next/headers'; import './globals.css'; import { Providers } from './providers'; @@ -13,6 +13,15 @@ export const metadata: Metadata = { description: 'Book train tickets on the Ethio-Djibouti Railway', }; +// viewportFit: 'cover' lets fixed bottom bars (e.g. the payment page's Pay +// button) read env(safe-area-inset-bottom) so they pad above the home +// indicator / gesture nav bar instead of being covered by it. +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', +}; + export default function RootLayout({ children, }: { diff --git a/apps/edr-passenger-web/portal/src/app/page.tsx b/apps/edr-passenger-web/portal/src/app/page.tsx index b9475fc81..f77e7291d 100644 --- a/apps/edr-passenger-web/portal/src/app/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/page.tsx @@ -38,10 +38,21 @@ async function StationsPrefetch({ children }: { children: React.ReactNode }) { await queryClient.prefetchQuery({ queryKey: ['stations'], queryFn: async () => { - const res = await fetch(`${apiUrl}/stations`, { next: { revalidate: 3600 } }); + // Caps how long the home page can be blocked on a slow/unresponsive API — + // without this, a hung request leaves the Suspense boundary (and the + // skeleton) stuck indefinitely instead of falling through to the + // client-side fetch, which has its own timeout and a visible error state. + const res = await fetch(`${apiUrl}/stations`, { + next: { revalidate: 3600 }, + signal: AbortSignal.timeout(8000), + }); const json = await res.json(); return json?.data ?? json; }, + // prefetchQuery defaults to 3 retries on failure — uncapped, that's up to + // ~24s of retries on top of the 8s timeout above before this ever resolves. + // Match the client's global default (providers.tsx) instead. + retry: 1, }); return ( @@ -53,11 +64,16 @@ async function StationsPrefetch({ children }: { children: React.ReactNode }) { export default function Home() { return ( - }> - - - - - + <> + }> + + + + + {/* Has its own independent data fetch (no dependency on stations) — + rendered outside the StationsPrefetch boundary so it isn't stuck + waiting on that fetch to resolve before it can start its own. */} + + ); } diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 7fd0e71f2..4c8f25eec 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -27,6 +27,10 @@ interface PassengerVoucherData { fareMinor: number; currency: string; createdAt: string; + // True when fareMinor is already a display-ready amount (e.g. the settled + // payment.amountMinor straight from the API) and must NOT be divided by 100 — as + // opposed to the normal case where fareMinor is genuine minor units (cents). + fareIsMajorUnits?: boolean; } // ─── palette ─────────────────────────────────────────────────────────────── @@ -327,7 +331,7 @@ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, // ─── fare summary ────────────────────────────────────────────────────────── -function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number { +function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number, fareIsMajorUnits = false): number { const cardH = 20; doc.setFillColor(...BRAND_SOFT); doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F'); @@ -338,7 +342,8 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num doc.text('✓ PAID', margin + padX, y + 15); doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); - doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' }); + const displayAmount = fareIsMajorUnits ? fareMinor : fareMinor / 100; + doc.text(`${currency} ${displayAmount.toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' }); return y + cardH + 8; } @@ -398,7 +403,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): } y = drawPassengerDetails(doc, data, y, margin, pageW); - y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); + y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits); drawInstructions(doc, y, margin, pageW); drawFooter(doc, data.createdAt); } @@ -432,11 +437,12 @@ interface VoucherData { } export const generateVoucherPDF = async (booking: VoucherData): Promise => { + // The settled payment amount, when available, is shown exactly as returned by the API + // (no /100, no per-passenger split) on every passenger's voucher — see fareIsMajorUnits. const settledAmountMinor = booking.payment?.amountMinor; const settledCurrency = booking.payment?.currency; const useSettledAmount = settledAmountMinor != null && !!settledCurrency; const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency; - const totalForSplit = useSettledAmount ? settledAmountMinor! : booking.totalMinor; // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between // them — a setTimeout delay here would push later saves outside the click's synchronous @@ -459,8 +465,9 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => status: booking.status, outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, isRoundTrip: false, - fareMinor: Math.round(totalForSplit / booking.passengers.length), + fareMinor: useSettledAmount ? settledAmountMinor! : Math.round(booking.totalMinor / booking.passengers.length), currency: voucherCurrency, + fareIsMajorUnits: useSettledAmount, createdAt: booking.createdAt, }); } 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/local-packages/tria-plc-iamapi-common-0.7.12.tgz b/local-packages/tria-plc-iamapi-common-0.7.12.tgz index 3a1b26e45..58997ee36 100644 Binary files a/local-packages/tria-plc-iamapi-common-0.7.12.tgz and b/local-packages/tria-plc-iamapi-common-0.7.12.tgz differ 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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9cbdcae8..97702902c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4416,7 +4416,7 @@ packages: typeorm: ^0.3.0 '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.12.tgz': - resolution: {integrity: sha512-9h87gfCQBqnUY8TOCK6jbnONwIV4pFKn4MF2Hte3p6K/kxsppgOR3KyOI9oBg5j5wre/RXbZLi1U1meEKBrWJw==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.12.tgz} + resolution: {integrity: sha512-9lZ5t3WzjcRrIEJu0rywzWXlmV+YteYqaYjiYP4T6ETZ9K7LIOXKM1gd2vK5hfrlIh6BPPcsIIhairmJgmpwEQ==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.12.tgz} version: 0.7.12 engines: {node: '>=20'} peerDependencies: