feat: ( payment ) wire CAC Bank OTP flow through passenger-api, portal, and backoffice

This commit is contained in:
Abubeker Yasin
2026-07-13 14:13:31 +03:00
parent bc3973cf0d
commit 8424d3bc7f
8 changed files with 258 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@@ -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;