mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
Merge branch 'freight/nati-2' into freight/feat/element-chat
This commit is contained in:
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import eimsConfigFactory from "./eims.config";
|
||||
|
||||
const REQUIRED = {
|
||||
EIMS_ENABLED: "true",
|
||||
EIMS_CLIENT_ID: "cid",
|
||||
EIMS_CLIENT_SECRET: "secret",
|
||||
EIMS_API_KEY: "apikey",
|
||||
EIMS_TIN: "0000000000",
|
||||
};
|
||||
|
||||
const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
|
||||
const prior: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
prior[key] = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(prior)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("eims.config — private key / certificate resolution", () => {
|
||||
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a PEM with real newlines untouched", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws naming all three key/cert options when none are set", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY_PATH: undefined,
|
||||
EIMS_PRIVATE_KEY_BASE64: undefined,
|
||||
EIMS_PRIVATE_KEY: undefined,
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
},
|
||||
() => {
|
||||
expect(() => eimsConfigFactory()).toThrow(
|
||||
/EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("is satisfied by any single one of the three key options", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(() => eimsConfigFactory()).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,23 @@ export interface EimsConfig {
|
||||
privateKeyPath: string;
|
||||
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
|
||||
certificatePath: string;
|
||||
/**
|
||||
* Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a
|
||||
* container that can't be given a host bind mount can still receive it as a plain env var.
|
||||
* Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64`
|
||||
* > `privateKeyPath`.
|
||||
*/
|
||||
privateKeyBase64: string;
|
||||
/** Inline alternative to `certificatePath`, same precedence rule as the key. */
|
||||
certificateBase64: string;
|
||||
/**
|
||||
* The PEM key pasted directly into the env var, no encoding step at all — the most direct of the
|
||||
* three inline forms, and the hardest for a broken transport step to mangle since there's no
|
||||
* decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set.
|
||||
*/
|
||||
privateKeyPem: string;
|
||||
/** Inline alternative to `certificateBase64`, same precedence rule. */
|
||||
certificatePem: string;
|
||||
httpTimeoutMs: number;
|
||||
/** Re-authenticate this many ms before the access token actually expires. */
|
||||
tokenSkewMs: number;
|
||||
@@ -80,7 +97,19 @@ export interface EimsInvoiceConfig {
|
||||
paymentMode: string;
|
||||
paymentTerm: string;
|
||||
unitDefault: string;
|
||||
/**
|
||||
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
|
||||
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
|
||||
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
|
||||
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
|
||||
*/
|
||||
buyerCountryCode: string | null;
|
||||
/**
|
||||
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
|
||||
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
|
||||
* this is not validated against a fixed digit pattern, only looked up by name.
|
||||
*/
|
||||
buyerCountryCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
||||
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
||||
@@ -89,6 +118,14 @@ export interface EimsInvoiceConfig {
|
||||
buyerRegionCodes: Record<string, string>;
|
||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||
buyerWeredaCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
|
||||
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
|
||||
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
|
||||
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
|
||||
* mapping.
|
||||
*/
|
||||
buyerCityCodes: Record<string, string>;
|
||||
/**
|
||||
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||
@@ -115,14 +152,14 @@ export interface EimsInvoiceConfig {
|
||||
buyerIdNumber: string | null;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = [
|
||||
"EIMS_CLIENT_ID",
|
||||
"EIMS_CLIENT_SECRET",
|
||||
"EIMS_API_KEY",
|
||||
"EIMS_TIN",
|
||||
"EIMS_PRIVATE_KEY_PATH",
|
||||
"EIMS_CERTIFICATE_PATH",
|
||||
] as const;
|
||||
const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
|
||||
|
||||
// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
|
||||
// from REQUIRED_VARS since it's "at least one of", not "this exact var".
|
||||
const REQUIRED_ANY_OF: string[][] = [
|
||||
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
|
||||
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
|
||||
];
|
||||
|
||||
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
@@ -143,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record<string, string> => {
|
||||
return map;
|
||||
};
|
||||
|
||||
// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline
|
||||
// and expect the caller to write "\n" as two characters instead. If the raw value already has a
|
||||
// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses.
|
||||
const normalizePem = (raw: string | undefined): string => {
|
||||
if (!raw) return "";
|
||||
return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n");
|
||||
};
|
||||
|
||||
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
|
||||
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||
if (raw === undefined || raw === "") return null;
|
||||
@@ -169,6 +214,10 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
|
||||
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
|
||||
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
|
||||
privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "",
|
||||
privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY),
|
||||
certificatePem: normalizePem(process.env.EIMS_CERTIFICATE),
|
||||
certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
|
||||
httpTimeoutMs,
|
||||
tokenSkewMs,
|
||||
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
|
||||
@@ -208,8 +257,10 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
|
||||
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
||||
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
||||
buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES),
|
||||
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||
@@ -223,7 +274,10 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
|
||||
if (!enabled) return base;
|
||||
|
||||
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||
const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||
for (const vars of REQUIRED_ANY_OF) {
|
||||
if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
|
||||
|
||||
Reference in New Issue
Block a user