mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
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>
This commit is contained in:
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal file
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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"}`);
|
||||
}
|
||||
Reference in New Issue
Block a user