refactor: ( payment ) increase timeout

This commit is contained in:
Abubeker Yasin
2026-07-13 15:20:56 +03:00
parent 8424d3bc7f
commit d39bf43e45
6 changed files with 107 additions and 49 deletions

View File

@@ -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],

View File

@@ -37,8 +37,10 @@ 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.
// 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<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpMessage, setOtpMessage] = useState<string | null>(null);
@@ -192,37 +194,51 @@ export default function PaymentPage() {
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;
}
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,
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
@@ -463,6 +479,49 @@ export default function PaymentPage() {
</div>
)}
{/* CAC Bank — collect payer mobile before initiating */}
{phoneModalOpen && (
<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">
<Smartphone className="w-5 h-5 text-primary" />
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
CAC Bank will send a one-time password to this number to authorize the payment.
</p>
<input
type="tel"
inputMode="numeric"
autoFocus
value={payerMobile}
onChange={(e) => { 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 && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2"> {phoneError}</p>
)}
<div className="flex gap-2 mt-4">
<button
onClick={() => setPhoneModalOpen(false)}
className="btn-secondary flex-1 py-2.5"
>
Cancel
</button>
<button
onClick={submitPhone}
disabled={!payerMobile.trim()}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
</button>
</div>
</div>
</div>
)}
{/* CAC Bank OTP entry */}
{otpModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
@@ -567,27 +626,6 @@ 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 */}

View File

@@ -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),
}));

View File

@@ -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})`,
);

View File

@@ -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<CacSigninResponse>(url, body, {
headers: { "Content-Type": "application/json" },
timeout: 10_000,
timeout: this.config.httpTimeoutMs,
}),
);
const token = res.data.accessToken;

View File

@@ -194,7 +194,10 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
* 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 is still pending.
* 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<ProviderStatus> {
const requestBody: CacGetPaymentByReferenceRequest = {
@@ -218,13 +221,13 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
}
return {
status: ProviderPaymentStatus.PROCESSING,
status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: response as unknown as Record<string, unknown>,
};
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) {
return {
status: ProviderPaymentStatus.PROCESSING,
status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: { notFound: true, reference: paymentRequestId },
};
}
@@ -242,7 +245,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
"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],
@@ -294,6 +297,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
username: this.username,
password: this.password,
tokenTtlMs: this.tokenTtlMs,
httpTimeoutMs: this.httpTimeoutMs,
});
}
return this.auth;
@@ -341,4 +345,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
private get otpExpiryMs(): number {
return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
}
private get httpTimeoutMs(): number {
return this.config.get<number>("cac.httpTimeoutMs") ?? 60_000;
}
}