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; /** * 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; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; /** * 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; /** * 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; taxRateByChargeType: Record; /** 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; discountByChargeType: Record; 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 => { const map: Record = {}; 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; });