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