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.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 772a8c1e9..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 @@ -204,6 +204,7 @@ export default function PaymentMethodsPage() { { 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/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 6694e4377..39832d787 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,12 @@ export default function PaymentPage() { const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); + // CAC Bank OTP debit: capture the payer's mobile up front, then collect the SMS'd OTP. + const [payerMobile, setPayerMobile] = useState(""); + 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,12 +127,25 @@ export default function PaymentPage() { bookingId: data.bookingId, method: data.method, paymentMethodId: data.paymentMethodId, + payerAccount: data.payerAccount, platform: 'web', }); }, onSuccess: async (data: any) => { setPaymentError(null); + // 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"); @@ -148,6 +170,26 @@ 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 () => { @@ -165,12 +207,19 @@ export default function PaymentPage() { return; } + if (selectedMethod === 'CAC_BANK' && !payerMobile.trim()) { + setPaymentError("Please enter the mobile number to receive the OTP"); + setIsProcessing(false); + return; + } + paymentMutation.mutate({ bookingId, method: selectedMethod, paymentMethodId: selectedPaymentMethod.id, currency: displayCurrency, amountMinor: totalAmount, + payerAccount: selectedMethod === 'CAC_BANK' ? payerMobile.trim() : undefined, }); }; @@ -414,6 +463,57 @@ export default function PaymentPage() { )} + {/* 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 */}
@@ -467,6 +567,27 @@ export default function PaymentPage() { })}
)} + + {selectedMethod === 'CAC_BANK' && ( +
+ +

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

+ setPayerMobile(e.target.value)} + placeholder="77 XX XX XX" + disabled={isProcessing} + className="w-full px-3 py-2.5 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" + /> +
+ )} {/* Order summary inline — mobile only */}