mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
refactor: ( payment ) increase timeout
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user