mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +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:
67
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
67
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsSignerService, toSignedBody } from "./eims-signer.service";
|
||||
import { toEimsApiException } from "./eims.errors";
|
||||
|
||||
/**
|
||||
* Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …).
|
||||
*
|
||||
* Login is not routed through here: `/auth/login` carries no bearer token and lives in
|
||||
* `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsClientService {
|
||||
private readonly logger = new Logger(EimsClientService.name);
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly signer: EimsSignerService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response.
|
||||
* A 401 invalidates the cached token and retries exactly once.
|
||||
*/
|
||||
async postSigned<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
return this.send<TRequest, TResponse>(path, request, false);
|
||||
}
|
||||
|
||||
private async send<TRequest, TResponse>(
|
||||
path: string,
|
||||
request: TRequest,
|
||||
isRetry: boolean,
|
||||
): Promise<TResponse> {
|
||||
const cfg = this.cfg;
|
||||
const token = await this.auth.getValidAccessToken();
|
||||
const body = toSignedBody(this.signer.signRequest(request));
|
||||
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<TResponse>(`${cfg.baseUrl}${path}`, body, {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
timeout: cfg.httpTimeoutMs,
|
||||
}),
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
const mapped = toEimsApiException(err, `POST ${path}`);
|
||||
if (mapped.kind === "AUTH" && !isRetry) {
|
||||
this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`);
|
||||
this.auth.invalidate();
|
||||
return this.send<TRequest, TResponse>(path, request, true);
|
||||
}
|
||||
this.logger.error(mapped.message);
|
||||
throw mapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user