fix(eims): map buyer Wereda to a MoR code too, fail locally if unmapped

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>
This commit is contained in:
Hagernesh
2026-08-08 07:47:03 +00:00
parent 1bfd7a4f97
commit 8d3dfa4113
6 changed files with 77 additions and 17 deletions

View File

@@ -192,6 +192,9 @@ EIMS_BUYER_COUNTRY_CODE=
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
# An unmapped region fails locally rather than being filed with a guess.
EIMS_BUYER_REGION_CODES=Addis Ababa=13
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
EIMS_BUYER_WEREDA_CODES=
EIMS_CASHIER_NAME=
EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).

View File

@@ -87,6 +87,8 @@ export interface EimsInvoiceConfig {
* 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;
}
@@ -110,7 +112,7 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n
};
/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */
const parseRegionCodes = (raw: string | undefined): Record<string, string> => {
const parseCodeMap = (raw: string | undefined): Record<string, string> => {
const map: Record<string, string> = {};
for (const pair of (raw ?? "").split(",")) {
const [name, code] = pair.split("=");
@@ -184,7 +186,8 @@ export default registerAs("eims", (): EimsConfig => {
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerRegionCodes: parseRegionCodes(process.env.EIMS_BUYER_REGION_CODES),
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,
},

View File

@@ -61,6 +61,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
...over,
});
@@ -230,7 +231,7 @@ describe("toEimsInvoice — MoR field constraints", () => {
seller,
context(),
),
).toThrow(/not a MoR region code and has no mapping/);
).toThrow(/not a MoR Region code and has no mapping/);
});
it("refuses a buyer with no region at all rather than guessing one", () => {
@@ -240,7 +241,31 @@ describe("toEimsInvoice — MoR field constraints", () => {
seller,
context(),
),
).toThrow(/buyer region \(unset\)/);
).toThrow(/buyer Region \(unset\)/);
});
it("passes a buyer wereda through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Wereda).toBe("574");
});
it("maps a wereda name to its code", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: { Yeka: "99" } }),
);
expect(doc.BuyerDetails.Wereda).toBe("99");
});
it("refuses to file a buyer whose wereda has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: {} }),
),
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {

View File

@@ -219,6 +219,13 @@ export interface EimsMapperContext {
* tax document is worse than refusing to file.
*/
buyerRegionCodes: Record<string, string>;
/**
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
@@ -229,8 +236,13 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/** MoR's own constraint on `Region`, on both the seller and buyer sides: one to three digits. */
const REGION_CODE = /^[0-9]{1,3}$/;
/**
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
* the way it named Region's.
*/
const LOCATION_CODE = /^[0-9]{1,3}$/;
/**
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
@@ -261,26 +273,29 @@ export const formatEimsDate = (issuedAt: Date): string =>
* exchange rate.
*/
/**
* A buyer's region as a MoR code: passed through when already numeric, otherwise looked up by name
* (case- and space-insensitive). Throws when neither applies.
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
* a guessed code onto a tax document is worse than refusing to file.
*/
function resolveRegionCode(
region: string | null | undefined,
function resolveLocationCode(
field: "Region" | "Wereda",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
): string {
const raw = (region ?? "").trim();
if (REGION_CODE.test(raw)) return raw;
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped && REGION_CODE.test(mapped)) return mapped;
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer region ${raw ? `"${raw}"` : "(unset)"}, ` +
"which is not a MoR region code and has no mapping. Add it to EIMS_BUYER_REGION_CODES.",
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
);
}
@@ -381,12 +396,24 @@ export function toEimsInvoice(
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: resolveRegionCode(company.region, context.buyerRegionCodes, invoice.invoiceNumber),
Region: resolveLocationCode(
"Region",
company.region,
context.buyerRegionCodes,
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
Wereda: company.woreda ?? null,
Wereda: resolveLocationCode(
"Wereda",
company.woreda,
context.buyerWeredaCodes,
"EIMS_BUYER_WEREDA_CODES",
invoice.invoiceNumber,
),
},
DocumentDetails: {
DocumentNumber: context.documentNumber,

View File

@@ -144,6 +144,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -34,6 +34,7 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
unitDefault: "PCS",
buyerCountryCode: null,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
cashierName: null,
salesPersonName: null,
...over,