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:
Hagernesh
2026-08-07 11:42:29 +00:00
parent 22e6e550bc
commit 2644d5e52d
17 changed files with 1523 additions and 1 deletions

View File

@@ -0,0 +1,40 @@
import "dotenv/config";
import axios from "axios";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import eimsConfig, { EimsConfig } from "../config/eims.config";
import { EimsAuthService } from "../modules/eims/eims-auth.service";
import { EimsCredentialsProvider } from "../modules/eims/eims-credentials.provider";
import { EimsSignerService } from "../modules/eims/eims-signer.service";
/**
* Manual, developer-run live check of EIMS authentication.
*
* Run explicitly: pnpm --filter @edr/freight-api eims:login
*
* Reads credentials from the local .env only. Never runs at boot, never runs in the test suite,
* and prints no token, secret, signature or certificate — only whether login succeeded.
*/
async function main(): Promise<void> {
const config = eimsConfig() as EimsConfig;
if (!config.enabled) {
throw new Error("EIMS_ENABLED is not true — set it in .env before running this check");
}
const configService = { get: () => config } as unknown as ConfigService;
const http = new HttpService(axios.create());
const credentials = new EimsCredentialsProvider(configService);
const auth = new EimsAuthService(http, configService, new EimsSignerService(credentials));
console.log(`POST ${config.baseUrl}/auth/login (tin=${config.tin})`);
const token = await auth.getValidAccessToken();
console.log(`✔ login succeeded — access token received (${token.length} chars, not printed)`);
const cached = await auth.getValidAccessToken();
console.log(`✔ second call served from cache: ${cached === token}`);
}
main().catch((err: Error) => {
console.error(`${err.message}`);
process.exitCode = 1;
});