feat(eims): resolve buyer geography from the MoR location master

BuyerDetails Country/Region/City/Wereda now resolve from the Ministry's own
EIMS_COUNTRY_REGION_VW master instead of the EIMS_BUYER_*_CODES env maps and
the ethiopia-geo-codes table. Both invented their codes and looked names up
globally, so KERSA/GORO/BABILE/BURE — each present in several zones with
different LOCALITY_NOs — could be filed against the wrong jurisdiction.

Resolution is hierarchical and refuses to guess: an unknown or ambiguous
address raises a local validation error naming the level that failed, and
never selects the first matching row. Spelling differences between EDR and
MoR live in a reviewed, parent-scoped alias layer; the dataset itself stays
verbatim so it remains traceable to the Ministry sheet.

Resolution now runs before the counter reservation in both the single and
bulk paths, so a bad company address no longer burns an EIMS sequence number.

Adds eims:import-locations to regenerate the dataset from a future workbook,
reporting duplicate rows and same-hierarchy code conflicts.
This commit is contained in:
Hagernesh
2026-08-24 04:23:47 +00:00
parent d285006114
commit c937290019
25 changed files with 1150 additions and 566 deletions

View File

@@ -36,9 +36,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",

View File

@@ -7,6 +7,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver";
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { NotificationsService } from "../notifications/notifications.service";
@@ -32,6 +33,8 @@ interface BulkReservation {
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
/** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */
buyerGeo: MorGeoCodes;
invoiceCounter: number;
documentNumber: string;
previousIrn: string;
@@ -123,7 +126,16 @@ export class EimsBulkRegistrationService {
}
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
return { invoice, documentType, relatedDocument };
// Same rule as the single-invoice path: buyer geography is resolved from the MoR location
// master before reserveBulk touches a counter, so one bad company address fails the whole
// batch locally instead of burning a block of EIMS sequence numbers.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
return { invoice, documentType, relatedDocument, buyerGeo };
});
if (prepared.length === 0) {
@@ -141,6 +153,7 @@ export class EimsBulkRegistrationService {
r.invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo: r.buyerGeo,
documentNumber: r.documentNumber,
invoiceCounter: r.invoiceCounter,
previousIrn: r.previousIrn,
@@ -269,7 +282,12 @@ export class EimsBulkRegistrationService {
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
private async reserveBulk(
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
prepared: Array<{
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
buyerGeo: MorGeoCodes;
}>,
systemNumber: string,
placeholder: string,
): Promise<BulkReservation[]> {
@@ -302,7 +320,7 @@ export class EimsBulkRegistrationService {
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
// on the opposite lock order.
for (const { invoice, documentType, relatedDocument } of prepared) {
for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) {
const locked = await this.lockInvoice(manager, invoice.id);
const thisCounter = counter++;
const thisDocNumber = String(docNumber++);
@@ -322,6 +340,7 @@ export class EimsBulkRegistrationService {
invoice: Object.assign(locked, { lines: invoice.lines }),
documentType,
relatedDocument,
buyerGeo,
invoiceCounter: thisCounter,
documentNumber: thisDocNumber,
previousIrn: thisPreviousIrn,

View File

@@ -66,7 +66,13 @@ describe("assertEimsInvoiceConfig — charge-type overrides", () => {
});
describe("buildEimsContext — taxForLine", () => {
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
const input = {
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
documentNumber: "24",
invoiceCounter: 7,
previousIrn: "",
session: SESSION,
};
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
it("uses the per-chargeType override when one is configured", () => {

View File

@@ -1,5 +1,6 @@
import { BadRequestException } from "@nestjs/common";
import { EimsConfig } from "../../config/eims.config";
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { EimsSessionContext } from "./eims-auth.service";
import {
EimsMapperContext,
@@ -149,6 +150,12 @@ export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
}
export interface EimsContextInput {
/**
* The buyer's MoR location codes, resolved from the Ministry location master by
* `resolveMorGeo` **before** the caller reserved an EIMS counter — see
* `EimsMapperContext.buyerGeo`.
*/
buyerGeo: MorGeoCodes;
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
documentNumber: string;
invoiceCounter: number;
@@ -205,11 +212,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerCountryCodes: invoice.buyerCountryCodes,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
buyerCityCodes: invoice.buyerCityCodes,
buyerGeo: input.buyerGeo,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,

View File

@@ -13,6 +13,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
@@ -58,9 +59,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
@@ -502,21 +506,23 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
it("a mapper failure after reservation still releases the reservation", async () => {
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
]);
//
// The trigger used to be an unmapped buyer country. That can no longer get this far: geography
// is resolved before the reservation now (see the test below). A line/total mismatch is a
// mapper-only failure that still reaches this point.
const db = new FakeDb([invoiceRow({ totalAmount: 999999 })]);
const postSigned = jest.fn();
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
// known exception types.
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR country code mapping/,
/lines sum to/,
);
expect(postSigned).not.toHaveBeenCalled();
@@ -532,6 +538,69 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("an unmappable buyer address fails before a counter is ever reserved", async () => {
// The whole point of resolving geography ahead of reserve(): a company-record problem is a
// local data problem, and it must not cost an EIMS sequence number. Nothing about the invoice
// or the system state may change.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, woreda: "Nowhere" } as never }),
]);
const before = { ...db.state };
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR LOCALITY_DESC match/,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({
nextInvoiceCounter: before.nextInvoiceCounter,
nextDocumentNumber: before.nextDocumentNumber,
inFlightInvoiceId: null,
});
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.NotSubmitted,
eimsInvoiceCounter: null,
});
});
it("files the buyer's MoR codes, resolved from the location master with no e-Trade call", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "IRN-1" } });
// The real seller cache, wired to an e-Trade mock that must never be reached: registration
// reads the company row EDR already stored, so filing stays deterministic and independent of
// e-Trade's availability. `refresh()` is deliberately not called — the cache stays empty and
// the seller falls back to static config, exactly as it does on a cold process.
const cfg = config();
const resolveCompanyData = jest.fn();
const sellerCache = new EimsSellerCacheService(
{ resolveCompanyData, extractRegistrationData: jest.fn() } as unknown as ETradeService,
{ get: () => cfg } as unknown as ConfigService,
);
const service = new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer: jest.fn() } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ notify: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationInboxService,
{ directSend: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationsService,
sellerCache,
);
await service.registerInvoiceWithEims(INVOICE_ID);
const [, body] = postSigned.mock.calls[0];
expect(body.BuyerDetails).toMatchObject({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
expect(resolveCompanyData).not.toHaveBeenCalled();
});
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });

View File

@@ -29,6 +29,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { resolveMorGeo } from "../../config/mor-location.resolver";
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import {
EimsInvoiceError,
@@ -116,6 +117,17 @@ export class EimsInvoiceRegistrationService {
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
// Buyer geography is resolved from the MoR location master *here*, ahead of the reservation:
// an unknown or ambiguous company address is a local data problem, and failing it after
// reserving would consume an EIMS sequence number for an invoice that was never filable. It
// needs no network access, so there is no reason for it to sit behind the login either.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
@@ -135,6 +147,7 @@ export class EimsInvoiceRegistrationService {
invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo,
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,

View File

@@ -5,11 +5,13 @@ import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
// A real MoR address (PARISH_NO 13 / CITY_NO 78 / LOCALITY_NO 1100) — the resolver now works off
// the Ministry's own hierarchy, so a made-up address would simply not resolve.
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
woreda: "Woreda 1",
mobilePhone: "0911000000",
regularPhone: "",
...over,
@@ -24,16 +26,10 @@ const build = (cfg: EimsConfig = eimsConfig()) => {
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C." }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
@@ -56,7 +52,6 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
@@ -67,13 +62,32 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("78");
expect(seller.Wereda).toBe("1100");
});
it("leaves the static seller values alone when MoR does not list the e-Trade address", async () => {
// e-Trade's free text does not always correspond to a MoR row (here "Yeka" is a MoR *City*
// under ADDIS ABABA, not a locality under BOLE). That must degrade to the static config, which
// MoR has already cleared under rule 7017 — never throw, and never file a guessed code.
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerRegion: "1", sellerWereda: "13", sellerCity: "101" }),
});
const { service, resolveCompanyData, extractRegistrationData } = build(cfg);
extractRegistrationData.mockReturnValue(registrationData({ woreda: "Yeka" }));
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await expect(service.refresh()).resolves.toBeUndefined();
const seller = service.getSellerDetails(cfg);
expect(seller.Region).toBe("1");
expect(seller.Wereda).toBe("13");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et" }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
@@ -108,7 +122,7 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
@@ -123,7 +137,7 @@ describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();

View File

@@ -3,7 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
import { tryResolveMorGeo } from "../../config/mor-location.resolver";
import { EimsSellerDetails } from "../billing/eims-invoice.mapper";
import { buildEimsSeller } from "./eims-invoice-context";
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
@@ -104,17 +105,24 @@ export class EimsSellerCacheService implements OnModuleInit {
);
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
const codes = cfg.invoice;
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the
// same MoR location master the buyer side uses, since the geography is objective, not
// buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply
// leaves these fields to getSellerDetails' static-config fallback, which is authoritative
// anyway (see the class comment — MoR has already cleared the static seller values under
// rule 7017, so nothing here may override one). e-Trade carries no country field; the
// resolver reads a blank country as domestic, which is correct for EDR's own registration.
const geo = tryResolveMorGeo({
region: data.region,
zone: data.zone,
woreda: data.woreda,
});
this.cached = {
LegalName: data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
// getSellerDetails' static-config fallback.
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
Region: geo?.Region,
Wereda: geo?.Wereda,
City: geo?.City,
};
} catch (err) {
this.logger.warn(

View File

@@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentMode: "CASH",
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},