Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims.errors.ts
Hagernesh 2644d5e52d feat(eims): add invoice mapper and signed EIMS transport
Map EDR invoices onto the MoR EIMS /v1/register document and add the
cryptographic transport needed to talk to core.mor.gov.et.

Mapper: DTOs mirror the supplied Postman collection section by section.
Tax is resolved per line via a caller-supplied resolver and throws when
unresolved -- the app models no tax at all (invoice.taxAmount is always 0,
invoice_lines and the rate catalogue carry no fiscal columns), so a
zero-rated default would assert a tax position the codebase cannot support.
Seller identity, document number, counters and previous IRN are passed in
explicitly; the mapper stays pure.

Transport: config, credential loading, RSA-SHA512 signing and /auth/login
with an in-memory token cache. Signing reproduces the process that produced
a working live token -- compact JSON of the inner request only, exact UTF-8
bytes, base64 signature, and base64 of the certificate file's exact bytes
with no parsing or re-encoding. Concurrent callers share one login via an
in-flight promise. Refresh is deliberately unimplemented: the collection
shows an unsigned refresh body but also ships unsigned examples of calls
that do require signing, so an expired token re-logs in instead.

Errors normalise to EimsApiException carrying only the gateway's own error
fields; secrets, signature, certificate and tokens never reach logs.
Key and certificate file patterns are gitignored.

Nothing calls EIMS automatically and no invoice entity, migration or UI is
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:08:40 +00:00

90 lines
3.3 KiB
TypeScript

import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common";
import { AxiosError } from "axios";
import { EimsErrorResponse } from "./eims.types";
export type EimsFailureKind =
| "NETWORK"
| "TIMEOUT"
| "SCHEMA_VALIDATION"
| "AUTH"
| "FORBIDDEN"
| "RULE_VALIDATION"
| "SERVER"
| "UNKNOWN";
/** Raised when EIMS is disabled or its credential files are unusable. */
export class EimsConfigException extends ServiceUnavailableException {
constructor(message: string) {
super({ code: "EIMS_NOT_CONFIGURED", message });
}
}
/**
* A failed EIMS call. Carries only the gateway's own error reporting — never the request body,
* signature, certificate, bearer token or any configured secret.
*/
export class EimsApiException extends BadGatewayException {
constructor(
readonly kind: EimsFailureKind,
message: string,
readonly httpStatus?: number,
readonly details?: EimsErrorResponse,
) {
super({ code: `EIMS_${kind}`, message });
}
}
const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const;
/**
* Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed
* request, a token, a signature — is dropped before it can reach a log or an exception payload.
*/
export function redactEimsBody(data: unknown): EimsErrorResponse | undefined {
if (!data || typeof data !== "object") return undefined;
const source = data as Record<string, unknown>;
const safe: Record<string, unknown> = {};
for (const key of SAFE_KEYS) {
if (source[key] !== undefined) safe[key] = source[key];
}
return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined;
}
const kindFor = (status: number): EimsFailureKind => {
if (status === 400) return "SCHEMA_VALIDATION";
if (status === 401) return "AUTH";
if (status === 403) return "FORBIDDEN";
if (status === 406) return "RULE_VALIDATION";
if (status >= 500) return "SERVER";
return "UNKNOWN";
};
/** First error line the gateway gives us, whichever shape it used. */
const describe = (body: EimsErrorResponse | undefined): string => {
if (!body) return "no error body";
const detail = body.details?.find((d) => d.errorMessage)?.errorMessage;
return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body";
};
/**
* Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a
* short label such as `"login"` or `"POST /v1/register"` — never a payload.
*/
export function toEimsApiException(err: unknown, operation: string): EimsApiException {
if (err instanceof EimsApiException) return err;
if (err instanceof AxiosError) {
if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") {
return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`);
}
if (!err.response) {
return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`);
}
const status = err.response.status;
const body = redactEimsBody(err.response.data);
return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body);
}
return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`);
}