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; 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; buyerCountryCode: string | null; cashierName: string | null; salesPersonName: 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 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; }; /** 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 ?? "", 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, cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, }, }; if (!enabled) return base; const missing = REQUIRED_VARS.filter((name) => !process.env[name]); 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; });