This commit is contained in:
Marshal
2026-07-14 13:11:38 +00:00
1915 changed files with 241099 additions and 165123 deletions

View File

@@ -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";

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

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

View File

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

View File

@@ -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 {

View File

@@ -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;

View File

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

View File

@@ -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 *));