mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
train
This commit is contained in:
@@ -16,6 +16,67 @@ interface ErrorResponseBody {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PgDriverError {
|
||||
code?: string;
|
||||
detail?: string;
|
||||
column?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const toSentenceCase = (snake: string): string =>
|
||||
snake.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
/**
|
||||
* Translate common Postgres constraint failures (surfaced by TypeORM as
|
||||
* QueryFailedError, which is NOT an HttpException and would otherwise fall
|
||||
* through as an opaque 500) into actionable 400s. Clients rely on the
|
||||
* "still referenced" wording to show friendly cannot-delete messages.
|
||||
*/
|
||||
const translatePgError = (
|
||||
driver: PgDriverError,
|
||||
): { status: number; message: string } | null => {
|
||||
const detail = driver.detail ?? "";
|
||||
switch (driver.code) {
|
||||
case "23503": {
|
||||
const stillReferenced = detail.match(
|
||||
/is still referenced from table "(.*?)"/,
|
||||
);
|
||||
if (stillReferenced) {
|
||||
const table = toSentenceCase(stillReferenced[1]);
|
||||
return {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: `Cannot delete: this record is still referenced by ${table}.`,
|
||||
};
|
||||
}
|
||||
const notPresent = detail.match(/is not present in table "(.*?)"/);
|
||||
const entity = toSentenceCase(notPresent?.[1] ?? "referenced entity");
|
||||
return {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: `The specified ${entity} does not exist.`,
|
||||
};
|
||||
}
|
||||
case "23505": {
|
||||
const pair = detail.match(/\((.*?)\)=\((.*?)\)/);
|
||||
return {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: `Duplicate entry: '${pair?.[2] ?? "value"}' already exists for '${toSentenceCase(pair?.[1] ?? "field")}'.`,
|
||||
};
|
||||
}
|
||||
case "23502":
|
||||
return {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: `Missing required field: ${toSentenceCase(driver.column ?? "field")}.`,
|
||||
};
|
||||
case "22P02":
|
||||
return {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: "Invalid input format (e.g., wrong UUID or number).",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
@@ -25,13 +86,23 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
const pgTranslated =
|
||||
(exception as Error)?.name === "QueryFailedError"
|
||||
? translatePgError(
|
||||
((exception as { driverError?: PgDriverError }).driverError ??
|
||||
{}) as PgDriverError,
|
||||
)
|
||||
: null;
|
||||
|
||||
const status = pgTranslated
|
||||
? pgTranslated.status
|
||||
: exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const messageRaw =
|
||||
exception instanceof HttpException
|
||||
const messageRaw = pgTranslated
|
||||
? pgTranslated.message
|
||||
: exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: "Internal server error";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Lossless JSON handling for CAC Bank.
|
||||
*
|
||||
* CAC returns 17-digit identifiers — `paymentRequestId`, `confirmReference`,
|
||||
* `transactionNo` (spec: numeric, <=18) — that exceed `Number.MAX_SAFE_INTEGER`
|
||||
* (9,007,199,254,740,991). A plain `JSON.parse` silently rounds them
|
||||
* (…240611 → …240610), corrupting the id we send back to CONFIRM the payment and use
|
||||
* to VERIFY it via GetPaymentByReferenceRequest. So we quote those fields to strings
|
||||
* before parsing and carry them as strings end-to-end, then re-emit id fields as raw
|
||||
* JSON numbers when building requests. This avoids a bigint-JSON dependency for the
|
||||
* three fields that need it.
|
||||
*/
|
||||
|
||||
/** Response id fields that must survive as exact strings, not JS numbers. */
|
||||
const RESPONSE_ID_FIELDS = [
|
||||
"paymentRequestId",
|
||||
"confirmReference",
|
||||
"transactionNo",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Request id fields we carry as strings but the bank types as `numeric`, so they must
|
||||
* go on the wire unquoted. Only PaymentConfirmationRequest.payment_request_id qualifies;
|
||||
* GetPaymentByReferenceRequest.reference is a string field and stays quoted.
|
||||
*/
|
||||
const REQUEST_NUMERIC_ID_FIELDS = ["payment_request_id"] as const;
|
||||
|
||||
/**
|
||||
* Parse a CAC JSON response body, keeping oversized integer ids as exact strings.
|
||||
* `raw` is the untouched response text (axios response transform is disabled for CAC).
|
||||
*/
|
||||
export function parseCacResponse<T>(raw: string): T {
|
||||
const pattern = new RegExp(
|
||||
`"(${RESPONSE_ID_FIELDS.join("|")})"\\s*:\\s*(-?\\d+)`,
|
||||
"g",
|
||||
);
|
||||
const quoted = raw.replace(pattern, '"$1":"$2"');
|
||||
return JSON.parse(quoted) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a CAC request body. Numeric id fields we hold as strings are emitted as raw
|
||||
* JSON numbers (unquoted) so their full precision reaches the bank, matching the spec's
|
||||
* `numeric` type. All other fields serialize normally.
|
||||
*/
|
||||
export function serializeCacRequest(body: unknown): string {
|
||||
let json = JSON.stringify(body);
|
||||
for (const field of REQUEST_NUMERIC_ID_FIELDS) {
|
||||
json = json.replace(
|
||||
new RegExp(`("${field}"\\s*:\\s*)"(-?\\d+)"`, "g"),
|
||||
"$1$2",
|
||||
);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Djibouti mobile number to the bare 8-digit national form the bank expects
|
||||
* (spec example `77112233`). Strips a leading `+253` / `00253` / `253` country code and any
|
||||
* spaces or dashes. Returns the input trimmed if it doesn't match the expected shape.
|
||||
*/
|
||||
export function normalizeCacMobile(mobile: string): string {
|
||||
const digits = mobile.replace(/[\s-]/g, "").replace(/^\+/, "");
|
||||
const national = digits.replace(/^(?:00)?253/, "");
|
||||
return national || digits;
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { CacBankAuth } from "./cac-bank.auth";
|
||||
import {
|
||||
normalizeCacMobile,
|
||||
parseCacResponse,
|
||||
serializeCacRequest,
|
||||
} from "./cac-bank.json";
|
||||
import type {
|
||||
CacConfirmResult,
|
||||
CacGetPaymentByReferenceRequest,
|
||||
@@ -22,6 +27,10 @@ import type {
|
||||
CacPaymentInitiateResponse,
|
||||
} from "./cac-bank.types";
|
||||
|
||||
/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). */
|
||||
const CAC_MIN_AMOUNT = 10;
|
||||
const CAC_MAX_AMOUNT = 100_000;
|
||||
|
||||
@Injectable()
|
||||
export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
readonly method = ProviderMethod.CAC_BANK;
|
||||
@@ -63,19 +72,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
throw new Error("CAC Bank requires payerAccount (customer mobile number)");
|
||||
}
|
||||
|
||||
const customerMobile = normalizeCacMobile(input.payerAccount);
|
||||
const amount = this.toMajorAmount(input.amountMinor, input.currency);
|
||||
if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) {
|
||||
throw new Error(
|
||||
`CAC Bank amount ${amount} ${input.currency} is outside the accepted range ` +
|
||||
`(${CAC_MIN_AMOUNT}–${CAC_MAX_AMOUNT} DJF)`,
|
||||
);
|
||||
}
|
||||
|
||||
const requestBody: CacPaymentInitiateRequest = {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
customer_mobile: input.payerAccount,
|
||||
customer_mobile: customerMobile,
|
||||
currency: input.currency || this.defaultCurrency,
|
||||
desc: `${input.orderRef}`.slice(0, 500),
|
||||
vender_ref: input.merchantOrderId,
|
||||
amount: this.toMajorAmount(input.amountMinor, input.currency),
|
||||
amount,
|
||||
company_services_id: this.companyServicesId,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${input.payerAccount} ref=${input.merchantOrderId}`,
|
||||
`CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${customerMobile} ref=${input.merchantOrderId}`,
|
||||
);
|
||||
this.logger.debug(
|
||||
`CAC Bank initiate request body: ${JSON.stringify(this.sanitizeKeys(requestBody))}`,
|
||||
@@ -98,7 +116,8 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
const providerOrderId = String(response.paymentRequestId);
|
||||
// Already an exact string (parseCacResponse keeps the 17-digit id lossless).
|
||||
const providerOrderId = response.paymentRequestId;
|
||||
const expiresAt = new Date(Date.now() + this.otpExpiryMs);
|
||||
|
||||
return {
|
||||
@@ -124,7 +143,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
const requestBody: CacPaymentConfirmRequest = {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
payment_request_id: Number(paymentRequestId),
|
||||
// Kept as a string here; serializeCacRequest emits it as a raw JSON number so the
|
||||
// full 17-digit precision reaches the bank.
|
||||
payment_request_id: paymentRequestId,
|
||||
otp,
|
||||
};
|
||||
|
||||
@@ -169,15 +190,20 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
async queryStatus(
|
||||
merchantOrderId: string,
|
||||
reference?: string,
|
||||
): Promise<ProviderStatus> {
|
||||
const lookupRef = reference ?? merchantOrderId;
|
||||
/**
|
||||
* 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 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 = {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
reference: lookupRef,
|
||||
reference: paymentRequestId,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -195,14 +221,14 @@ 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,
|
||||
rawResponse: { notFound: true, reference: lookupRef },
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
rawResponse: { notFound: true, reference: paymentRequestId },
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
@@ -212,21 +238,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const token = await this.getAuth().getAccessToken();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
// Serialize ourselves so numeric ids we carry as strings go on the wire unquoted.
|
||||
const payload = serializeCacRequest(body);
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
"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],
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<string>(url, payload, config),
|
||||
);
|
||||
this.logger.debug(
|
||||
`CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
return parseCacResponse<T>(res.data);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 401) {
|
||||
this.getAuth().invalidate();
|
||||
@@ -239,9 +272,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
},
|
||||
};
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<T>(url, body, retryConfig),
|
||||
this.http.post<string>(url, payload, retryConfig),
|
||||
);
|
||||
return res.data;
|
||||
return parseCacResponse<T>(res.data);
|
||||
}
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
@@ -264,6 +297,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
tokenTtlMs: this.tokenTtlMs,
|
||||
httpTimeoutMs: this.httpTimeoutMs,
|
||||
});
|
||||
}
|
||||
return this.auth;
|
||||
@@ -311,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,19 +24,25 @@ export interface CacPaymentInitiateRequest {
|
||||
|
||||
export interface CacPaymentInitiateResponse {
|
||||
description: string;
|
||||
paymentRequestId: number;
|
||||
/** Numeric id (<=18 digits) kept as a string — it exceeds JS's safe integer range. */
|
||||
paymentRequestId: string;
|
||||
}
|
||||
|
||||
export interface CacPaymentConfirmRequest {
|
||||
app_key: string;
|
||||
api_key: string;
|
||||
payment_request_id: number;
|
||||
/**
|
||||
* Carried as a string for precision; emitted as a raw JSON number on the wire by
|
||||
* `serializeCacRequest` (the bank types this field as `numeric`).
|
||||
*/
|
||||
payment_request_id: string;
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export interface CacPaymentConfirmResponse {
|
||||
description: string;
|
||||
confirmReference: number;
|
||||
/** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
|
||||
confirmReference: string;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
@@ -52,7 +58,8 @@ export interface CacPaymentByReferenceResponse {
|
||||
reference: string;
|
||||
amount: number;
|
||||
transactionDate: string;
|
||||
transactionNo: number;
|
||||
/** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
|
||||
transactionNo: string;
|
||||
}
|
||||
|
||||
export interface CacConfirmResult {
|
||||
|
||||
@@ -60,6 +60,12 @@ export interface ETradeCompanyInfo {
|
||||
}
|
||||
|
||||
export interface CompanyRegistrationData {
|
||||
/**
|
||||
* The registered organization name — `ETradeCompanyInfo.BusinessName`, falling
|
||||
* back to the licence's `TradeName`. Never the manager/owner's personal name;
|
||||
* that is {@link managerName}.
|
||||
*/
|
||||
companyName: string;
|
||||
licenceNumber: string;
|
||||
statusDescription: string;
|
||||
dateRegistered: string;
|
||||
|
||||
@@ -30,6 +30,7 @@ export enum NotificationPriority {
|
||||
export enum NotificationType {
|
||||
GENERIC = "GENERIC",
|
||||
// Portal-facing (customer)
|
||||
ACCOUNT_STATUS = "ACCOUNT_STATUS",
|
||||
CLEARANCE_DECISION = "CLEARANCE_DECISION",
|
||||
DOCUMENT_ACTION = "DOCUMENT_ACTION",
|
||||
BOOKING_STATUS = "BOOKING_STATUS",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./theme.css";
|
||||
@custom-variant dark (&: where(.dark, .dark *));
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
Reference in New Issue
Block a user