mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
BuyerDetails.Wereda had the same problem Region did: companies.woreda holds
names ("Yeka") MoR has no confirmed regex for, but every Wereda value MoR has
actually shown us (seller "12"/"13", the collection's "574") is 1-3 digits
like Region. Precautionary, not confirmed -- but the fix is identical either
way: resolve through EIMS_BUYER_WEREDA_CODES and refuse to file rather than
send a guessed code.
Generalises the Region resolver (resolveRegionCode -> resolveLocationCode) to
cover both fields instead of duplicating it.
No code was invented for "Yeka" -- EIMS_BUYER_WEREDA_CODES ships empty, so
this buyer now fails locally (new stop) instead of silently sending a name
that was never verified against MoR's schema.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
8.5 KiB
TypeScript
206 lines
8.5 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;
|
|
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;
|
|
/**
|
|
* 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>;
|
|
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;
|
|
};
|
|
|
|
/** "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 ?? "",
|
|
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,
|
|
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
|
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
|
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;
|
|
});
|