mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( payment ) wire CAC Bank OTP flow through passenger-api, portal, and backoffice
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PaymentMethodType" ADD VALUE 'CAC_BANK';
|
||||
@@ -146,6 +146,7 @@ enum PaymentMethodType {
|
||||
WALLET
|
||||
WAAFI
|
||||
DMONEY
|
||||
CAC_BANK
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
@@ -50,6 +55,52 @@ export class PaymentClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
|
||||
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a
|
||||
* BadRequest (retryable) rather than a 502, so the payer can re-enter the code.
|
||||
*/
|
||||
async confirmOtp(
|
||||
intentId: string,
|
||||
otp: string,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
const url = `${this.baseUrl}/payments/intents/${intentId}/confirm`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<PaymentIntentSnapshot>(
|
||||
url,
|
||||
{ otp },
|
||||
{
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
},
|
||||
),
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
// 400 = wrong/expired OTP, 404 = unknown intent → both are client-fixable.
|
||||
if (err.response.status === 400 || err.response.status === 404) {
|
||||
throw new BadRequestException(detail);
|
||||
}
|
||||
this.logger.error(
|
||||
`payment service confirm ${intentId} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||
}
|
||||
this.logger.error(
|
||||
`payment service unreachable (confirm ${intentId}): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
throw new BadGatewayException("Payment service unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -183,6 +183,14 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
const method = dto.method as PaymentMethodType;
|
||||
|
||||
// CAC Bank is an OTP debit — the bank SMSes the OTP to this number, so it's required.
|
||||
if (method === PaymentMethodType.CAC_BANK && !dto.payerAccount?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"payerAccount (mobile number) is required for CAC Bank",
|
||||
);
|
||||
}
|
||||
|
||||
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||
|
||||
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
|
||||
@@ -230,6 +238,7 @@ export class PaymentsService {
|
||||
currency: chargeCurrency,
|
||||
provider: method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl,
|
||||
failureUrl,
|
||||
});
|
||||
@@ -248,6 +257,43 @@ export class PaymentsService {
|
||||
}
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by bookingId: the active
|
||||
* remote intent is looked up by reference, the OTP is forwarded to the payment service,
|
||||
* and the projection is refreshed. On success the booking is converged immediately
|
||||
* (idempotent — the outbox → mark-paid path also converges it). A wrong/expired OTP
|
||||
* bubbles up as a 400 so the payer can retry; the intent stays REQUIRES_ACTION.
|
||||
*/
|
||||
async confirmOtpPayment(
|
||||
bookingId: string,
|
||||
otp: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
);
|
||||
if (!snapshot) {
|
||||
throw new NotFoundException("No active payment to confirm for this booking");
|
||||
}
|
||||
|
||||
const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
|
||||
let intent = await this.syncIntentProjection(bookingId, confirmed);
|
||||
|
||||
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: confirmed.providerTxnId,
|
||||
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
|
||||
});
|
||||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
}
|
||||
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
private resolveReturnUrls(method: PaymentMethodType): {
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(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<string | null>(null);
|
||||
const [otpError, setOtpError] = useState<string | null>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CAC Bank OTP entry */}
|
||||
{otpModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Enter OTP</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{otpMessage}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={otpCode}
|
||||
onChange={(e) => { setOtpCode(e.target.value.replace(/\D/g, '')); setOtpError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }}
|
||||
placeholder="Enter code"
|
||||
maxLength={10}
|
||||
className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none"
|
||||
/>
|
||||
{otpError && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {otpError}</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => { setOtpModalOpen(false); updateStatus("REQUIRES_ACTION"); }}
|
||||
disabled={otpMutation.isPending}
|
||||
className="btn-secondary flex-1 py-2.5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => otpCode.trim() && otpMutation.mutate(otpCode.trim())}
|
||||
disabled={otpMutation.isPending || !otpCode.trim()}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{otpMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Verifying...
|
||||
</span>
|
||||
) : (
|
||||
"Confirm payment"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-column grid */}
|
||||
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
||||
|
||||
@@ -467,6 +567,27 @@ export default function PaymentPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedMethod === 'CAC_BANK' && (
|
||||
<div className="mt-4 p-4 rounded-xl border-2 border-primary/30 bg-primary/5">
|
||||
<label htmlFor="cac-mobile" className="block text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Mobile number for OTP
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
CAC Bank will send a one-time password to this number to authorize the debit.
|
||||
</p>
|
||||
<input
|
||||
id="cac-mobile"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={payerMobile}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order summary inline — mobile only */}
|
||||
|
||||
Reference in New Issue
Block a user