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, SeatsModule,
TicketsModule, TicketsModule,
CurrencyModule, 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(), ...rabbitMQImport(),
], ],
controllers: [PaymentsController, InternalPaymentsController], controllers: [PaymentsController, InternalPaymentsController],

View File

@@ -37,8 +37,10 @@ export default function PaymentPage() {
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null); const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null); 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 [payerMobile, setPayerMobile] = useState("");
const [phoneModalOpen, setPhoneModalOpen] = useState(false);
const [phoneError, setPhoneError] = useState<string | null>(null);
const [otpModalOpen, setOtpModalOpen] = useState(false); const [otpModalOpen, setOtpModalOpen] = useState(false);
const [otpCode, setOtpCode] = useState(""); const [otpCode, setOtpCode] = useState("");
const [otpMessage, setOtpMessage] = useState<string | null>(null); const [otpMessage, setOtpMessage] = useState<string | null>(null);
@@ -192,37 +194,51 @@ export default function PaymentPage() {
const handlePayment = async () => { // Fire the actual initiate. `mobile` is only used for CAC (OTP debit).
if (!selectedMethod || !bookingId) { const startPayment = (mobile?: string) => {
alert("Please select a payment method"); if (!selectedMethod || !bookingId || !selectedPaymentMethod) return;
return;
}
setIsProcessing(true); setIsProcessing(true);
setPaymentError(null); 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({ paymentMutation.mutate({
bookingId, bookingId,
method: selectedMethod, method: selectedMethod,
paymentMethodId: selectedPaymentMethod.id, paymentMethodId: selectedPaymentMethod.id,
currency: displayCurrency, currency: displayCurrency,
amountMinor: totalAmount, 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) // Redirect if no booking data (but not during navigation)
useEffect(() => { useEffect(() => {
// Add a small delay to allow state to be set from previous page // Add a small delay to allow state to be set from previous page
@@ -463,6 +479,49 @@ export default function PaymentPage() {
</div> </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 */} {/* CAC Bank OTP entry */}
{otpModalOpen && ( {otpModalOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"> <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> </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> </div>
{/* Order summary inline — mobile only */} {/* Order summary inline — mobile only */}

View File

@@ -10,4 +10,7 @@ export default registerAs("cac", () => ({
currency: process.env.CAC_CURRENCY || "DJF", currency: process.env.CAC_CURRENCY || "DJF",
tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000), tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000),
otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 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( throw new BadRequestException(
`Intent is not awaiting confirmation (status=${intent.status})`, `Intent is not awaiting confirmation (status=${intent.status})`,
); );

View File

@@ -14,6 +14,7 @@ export interface CacAuthConfig {
username: string; username: string;
password: string; password: string;
tokenTtlMs: number; tokenTtlMs: number;
httpTimeoutMs: number;
} }
/** /**
@@ -63,7 +64,7 @@ export class CacBankAuth {
const res = await firstValueFrom( const res = await firstValueFrom(
this.http.post<CacSigninResponse>(url, body, { this.http.post<CacSigninResponse>(url, body, {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
timeout: 10_000, timeout: this.config.httpTimeoutMs,
}), }),
); );
const token = res.data.accessToken; 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 * 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 * 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. * 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> { async queryStatus(paymentRequestId: string): Promise<ProviderStatus> {
const requestBody: CacGetPaymentByReferenceRequest = { const requestBody: CacGetPaymentByReferenceRequest = {
@@ -218,13 +221,13 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
} }
return { return {
status: ProviderPaymentStatus.PROCESSING, status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: response as unknown as Record<string, unknown>, rawResponse: response as unknown as Record<string, unknown>,
}; };
} catch (err) { } catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) { if (err instanceof AxiosError && err.response?.status === 404) {
return { return {
status: ProviderPaymentStatus.PROCESSING, status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: { notFound: true, reference: paymentRequestId }, rawResponse: { notFound: true, reference: paymentRequestId },
}; };
} }
@@ -242,7 +245,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
timeout: 10_000, timeout: this.httpTimeoutMs,
// Keep the raw response text — 17-digit ids would lose precision under axios's // Keep the raw response text — 17-digit ids would lose precision under axios's
// default JSON.parse. We parse losslessly with parseCacResponse. // default JSON.parse. We parse losslessly with parseCacResponse.
transformResponse: [(data) => data], transformResponse: [(data) => data],
@@ -294,6 +297,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
username: this.username, username: this.username,
password: this.password, password: this.password,
tokenTtlMs: this.tokenTtlMs, tokenTtlMs: this.tokenTtlMs,
httpTimeoutMs: this.httpTimeoutMs,
}); });
} }
return this.auth; return this.auth;
@@ -341,4 +345,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
private get otpExpiryMs(): number { private get otpExpiryMs(): number {
return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000; return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
} }
private get httpTimeoutMs(): number {
return this.config.get<number>("cac.httpTimeoutMs") ?? 60_000;
}
} }