mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
EIMS_PRIVATE_KEY_BASE64 / EIMS_CERTIFICATE_BASE64, alternative to the existing _PATH vars. Wins over the path when set; falls back to the file otherwise. Neither var required at boot on its own — the either/or check moved out of the flat REQUIRED_VARS list. Lets a dockerized deployment receive the key/cert the same way it already receives every other EIMS_* secret (plain env var into the container) instead of needing a host bind mount into the container filesystem.
269 lines
13 KiB
TypeScript
269 lines
13 KiB
TypeScript
import { registerAs } from "@nestjs/config";
|
|
|
|
/**
|
|
* Ethiopian MoR EIMS e-invoicing gateway.
|
|
*
|
|
* Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS
|
|
* service throws a clear error on use, so a deployment without credentials still boots.
|
|
*
|
|
* Secrets (client secret, API key) and the credential file paths live only here and are never
|
|
* logged — validation reports missing variable *names*, never their values.
|
|
*/
|
|
export interface EimsConfig {
|
|
enabled: boolean;
|
|
baseUrl: string;
|
|
clientId: string;
|
|
clientSecret: string;
|
|
apiKey: string;
|
|
tin: string;
|
|
/**
|
|
* Optional *expectations* for the source-system identity, not inputs.
|
|
*
|
|
* The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials
|
|
* that authenticated, and those are what registration uses. When these are set they are compared
|
|
* against the token and a mismatch fails fast — neither side silently wins. Leave them empty to
|
|
* take whatever the gateway says.
|
|
*/
|
|
systemNumber: string;
|
|
systemType: string;
|
|
/** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */
|
|
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.
|
|
* Takes precedence over the path when set. Either one must be present when EIMS is enabled.
|
|
*/
|
|
privateKeyBase64: string;
|
|
/** Inline alternative to `certificatePath`, same precedence rule. */
|
|
certificateBase64: string;
|
|
httpTimeoutMs: number;
|
|
/** Re-authenticate this many ms before the access token actually expires. */
|
|
tokenSkewMs: number;
|
|
/**
|
|
* Automatic submission of issued invoices, off by default.
|
|
*
|
|
* Invoices are produced by the workflow, so the production path is a sweep rather than a human
|
|
* action — but enabling it starts filing real documents with the tax authority, which is
|
|
* irreversible from our side. It therefore needs its own deliberate switch, separate from
|
|
* `EIMS_ENABLED`, so that authentication can be live long before filing is.
|
|
*/
|
|
autoSubmit: boolean;
|
|
autoSubmitCron: string;
|
|
/** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */
|
|
autoSubmitMaxAgeDays: number;
|
|
/**
|
|
* Seller identity and tax/business treatment for the invoice document.
|
|
*
|
|
* None of this is derivable from the database: EDR's own legal identity exists nowhere in the
|
|
* codebase, and the app models no tax at all. Values are required at registration time and are
|
|
* validated there rather than at boot, so a deployment can run with EIMS enabled for
|
|
* authentication before finance has signed off on the tax treatment.
|
|
*/
|
|
invoice: EimsInvoiceConfig;
|
|
}
|
|
|
|
export interface EimsInvoiceConfig {
|
|
sellerLegalName: string;
|
|
sellerVatNumber: string;
|
|
sellerPhone: string;
|
|
sellerEmail: string;
|
|
/** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */
|
|
sellerRegion: string;
|
|
sellerWereda: string;
|
|
sellerCity: string | null;
|
|
sellerSubCity: string | null;
|
|
sellerHouseNumber: string | null;
|
|
sellerLocality: string | null;
|
|
/** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */
|
|
taxCode: string;
|
|
taxRatePercent: number | null;
|
|
exciseTaxValue: number | null;
|
|
incomeWithholdValue: number | null;
|
|
transactionWithholdValue: number | null;
|
|
/** B2B / B2C — a tax classification, so it is configured, not inferred. */
|
|
transactionType: string;
|
|
natureOfSupplies: string;
|
|
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
|
|
* locally rather than being filed with a guessed one.
|
|
*/
|
|
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
|
|
* `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax
|
|
* treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above
|
|
* cannot express that. Values are raw strings; the context builder parses/validates them.
|
|
*/
|
|
taxCodeByChargeType: Record<string, string>;
|
|
taxRateByChargeType: Record<string, string>;
|
|
/** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge
|
|
* types not listed fall back to `exciseTaxValue` / 0 respectively. */
|
|
exciseByChargeType: Record<string, string>;
|
|
discountByChargeType: Record<string, string>;
|
|
cashierName: string | null;
|
|
salesPersonName: string | null;
|
|
/**
|
|
* TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every
|
|
* buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be
|
|
* one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead
|
|
* of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits.
|
|
* Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists.
|
|
*/
|
|
buyerIdType: string | null;
|
|
buyerIdNumber: string | null;
|
|
}
|
|
|
|
const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
|
|
|
|
// Key/cert each have two ways in (file path or inline base64) — checked separately from
|
|
// REQUIRED_VARS since it's "at least one of", not "this exact var".
|
|
const REQUIRED_EITHER_OR: Array<[string, string]> = [
|
|
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64"],
|
|
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64"],
|
|
];
|
|
|
|
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
|
|
if (raw === undefined || raw === "") return fallback;
|
|
const value = Number.parseInt(raw, 10);
|
|
if (Number.isNaN(value) || value <= 0) {
|
|
throw new Error(`${name} must be a positive integer`);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */
|
|
const parseCodeMap = (raw: string | undefined): Record<string, string> => {
|
|
const map: Record<string, string> = {};
|
|
for (const pair of (raw ?? "").split(",")) {
|
|
const [name, code] = pair.split("=");
|
|
if (name?.trim() && code?.trim()) map[name.trim()] = code.trim();
|
|
}
|
|
return map;
|
|
};
|
|
|
|
/** 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;
|
|
const value = Number(raw);
|
|
if (!Number.isFinite(value)) throw new Error(`${name} must be a number`);
|
|
return value;
|
|
};
|
|
|
|
export default registerAs("eims", (): EimsConfig => {
|
|
const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true";
|
|
const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, "");
|
|
const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS");
|
|
const tokenSkewMs =
|
|
positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000;
|
|
|
|
const base: EimsConfig = {
|
|
enabled,
|
|
baseUrl,
|
|
clientId: process.env.EIMS_CLIENT_ID ?? "",
|
|
clientSecret: process.env.EIMS_CLIENT_SECRET ?? "",
|
|
apiKey: process.env.EIMS_API_KEY ?? "",
|
|
tin: process.env.EIMS_TIN ?? "",
|
|
systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "",
|
|
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 ?? "",
|
|
certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
|
|
httpTimeoutMs,
|
|
tokenSkewMs,
|
|
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
|
|
// Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a
|
|
// misconfiguration from filing a burst of bad documents before anyone notices.
|
|
autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *",
|
|
autoSubmitMaxAgeDays: positiveInt(
|
|
process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS,
|
|
3,
|
|
"EIMS_AUTO_SUBMIT_MAX_AGE_DAYS",
|
|
),
|
|
invoice: {
|
|
sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "",
|
|
sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "",
|
|
sellerPhone: process.env.EIMS_SELLER_PHONE ?? "",
|
|
sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "",
|
|
sellerRegion: process.env.EIMS_SELLER_REGION ?? "",
|
|
sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "",
|
|
sellerCity: process.env.EIMS_SELLER_CITY || null,
|
|
sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null,
|
|
sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null,
|
|
sellerLocality: process.env.EIMS_SELLER_LOCALITY || null,
|
|
taxCode: process.env.EIMS_TAX_CODE ?? "",
|
|
taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"),
|
|
exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"),
|
|
incomeWithholdValue: optionalNumber(
|
|
process.env.EIMS_INCOME_WITHHOLD_VALUE,
|
|
"EIMS_INCOME_WITHHOLD_VALUE",
|
|
),
|
|
transactionWithholdValue: optionalNumber(
|
|
process.env.EIMS_TRANSACTION_WITHHOLD_VALUE,
|
|
"EIMS_TRANSACTION_WITHHOLD_VALUE",
|
|
),
|
|
transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "",
|
|
natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "",
|
|
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
|
|
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),
|
|
discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE),
|
|
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
|
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
|
buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null,
|
|
buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null,
|
|
},
|
|
};
|
|
|
|
if (!enabled) return base;
|
|
|
|
const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
|
|
for (const [pathVar, base64Var] of REQUIRED_EITHER_OR) {
|
|
if (!process.env[pathVar] && !process.env[base64Var]) missing.push(`${pathVar} or ${base64Var}`);
|
|
}
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
return base;
|
|
});
|