feat(eims): bake in a starter table of Ethiopia region/zone/woreda codes

EIMS_BUYER_REGION_CODES/WEREDA_CODES/CITY_CODES were hand-maintained,
empty-by-default env vars — every buyer from a not-yet-seen area
crashed filing until someone found the MoR code and redeployed.
Happened three times in one afternoon (2026-08-17): Somali region,
Fafan zone, Jigjiga woreda, even the Ethiopia country code were all
unset on the triaplc.com deployment.

Ethiopia's administrative divisions are fixed reference data, not
buyer-specific config. Added ethiopia-geo-codes.ts, a static table
(source: ethiopia_administrative_hierarchy_master.csv, supplied
2026-08-17 — a representative sample, not all ~1000 real woredas)
merged in as the fallback under the existing env vars, which still
win on a name collision — for a quick correction without a redeploy,
or a buyer profile spelled differently than the table (already hit:
DB has zone "Fafen", official spelling "Fafan").

Known limitation, documented in the file: zone/woreda names aren't
always unique nationwide (e.g. "North Shewa" is both an Amhara and
an Oromia zone) and Company stores region/zone/woreda as three
independent strings with no parent linkage, so a flat name map can't
always disambiguate. Only affects the optional City field — never
blocks filing, unlike Region/Wereda.
This commit is contained in:
Hagernesh
2026-08-17 14:05:47 +00:00
parent b73939a768
commit 7946c16327
3 changed files with 190 additions and 3 deletions

View File

@@ -70,3 +70,47 @@ describe("eims.config — private key / certificate resolution", () => {
);
});
});
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
it("resolves a known region/wereda/zone with no env var set at all", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
const cfg = eimsConfigFactory();
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
},
);
});
it("an env var entry overrides the baked-in code for the same name", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY: "x",
EIMS_CERTIFICATE_PATH: "/dev/null",
EIMS_BUYER_REGION_CODES: "Somali=99",
},
() => {
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
},
);
});
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY: "x",
EIMS_CERTIFICATE_PATH: "/dev/null",
EIMS_BUYER_CITY_CODES: "Fafen=01",
},
() => {
const codes = eimsConfigFactory().invoice.buyerCityCodes;
expect(codes.Fafen).toBe("01");
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
},
);
});
});

View File

@@ -1,5 +1,7 @@
import { registerAs } from "@nestjs/config";
import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes";
/**
* Ethiopian MoR EIMS e-invoicing gateway.
*
@@ -258,9 +260,11 @@ export default registerAs("eims", (): EimsConfig => {
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),
// Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a
// deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts.
buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) },
buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) },
buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...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),

View File

@@ -0,0 +1,139 @@
/**
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
*
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
* someone hunted down the code and added it to an env var by hand — happened three times in one
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
* not all ~1000 real woredas), extend as new gaps surface.
*
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
*
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
*/
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
["Amhara", "Awi", "Injibara", "03", "09", "01"],
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
["Oromia", "Arsi", "Asella", "04", "05", "01"],
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
["Oromia", "Bale", "Robe", "04", "07", "01"],
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
["Somali", "Fafan", "Awbare", "05", "01", "03"],
["Somali", "Sitti", "Shinile", "05", "02", "01"],
["Somali", "Erer", "Fiq", "05", "03", "01"],
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
["Somali", "Nogob", "Segeg", "05", "05", "01"],
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
["Somali", "Shabelle", "Gode", "05", "07", "01"],
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
["Somali", "Liben", "Filtu", "05", "09", "01"],
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
["Somali", "Dollo", "Warder", "05", "11", "01"],
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
["Gambela", "Nuer", "Lare", "08", "02", "01"],
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
];
/** First occurrence wins on a name collision — see the class comment. */
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
const map: Record<string, string> = {};
for (const row of ROWS) {
const [name, code] = pick(row);
if (!(name in map)) map[name] = code;
}
return map;
};
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);