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}$. # 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. # An unmapped region fails locally rather than being filed with a guess.
EIMS_BUYER_REGION_CODES=Addis Ababa=13 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_CASHIER_NAME=
EIMS_SALESPERSON_NAME= EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick). # 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. * locally rather than being filed with a guessed one.
*/ */
buyerRegionCodes: Record<string, string>; buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
cashierName: string | null; cashierName: string | null;
salesPersonName: 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" }. */ /** "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> = {}; const map: Record<string, string> = {};
for (const pair of (raw ?? "").split(",")) { for (const pair of (raw ?? "").split(",")) {
const [name, code] = pair.split("="); const [name, code] = pair.split("=");
@@ -184,7 +186,8 @@ export default registerAs("eims", (): EimsConfig => {
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, 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, cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
}, },

View File

@@ -61,6 +61,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
incomeWithholdValue: 0, incomeWithholdValue: 0,
transactionWithholdValue: 0, transactionWithholdValue: 0,
buyerRegionCodes: { "Addis Ababa": "13" }, buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
...over, ...over,
}); });
@@ -230,7 +231,7 @@ describe("toEimsInvoice — MoR field constraints", () => {
seller, seller,
context(), 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", () => { it("refuses a buyer with no region at all rather than guessing one", () => {
@@ -240,7 +241,31 @@ describe("toEimsInvoice — MoR field constraints", () => {
seller, seller,
context(), 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", () => { 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. * tax document is worse than refusing to file.
*/ */
buyerRegionCodes: Record<string, string>; 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; buyerIdType?: string | null;
buyerIdNumber?: string | null; buyerIdNumber?: string | null;
buyerCity?: string | null; buyerCity?: string | null;
@@ -229,8 +236,13 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string; 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. * The only two values MoR accepts for `NatureOfSupplies`, lowercase.
@@ -261,26 +273,29 @@ export const formatEimsDate = (issuedAt: Date): string =>
* exchange rate. * exchange rate.
*/ */
/** /**
* A buyer's region as a MoR code: passed through when already numeric, otherwise looked up by name * A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
* (case- and space-insensitive). Throws when neither applies. * 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( function resolveLocationCode(
region: string | null | undefined, field: "Region" | "Wereda",
value: string | null | undefined,
codes: Record<string, string>, codes: Record<string, string>,
envVar: string,
invoiceNumber: string, invoiceNumber: string,
): string { ): string {
const raw = (region ?? "").trim(); const raw = (value ?? "").trim();
if (REGION_CODE.test(raw)) return raw; if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " "); const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find( const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1]; )?.[1];
if (mapped && REGION_CODE.test(mapped)) return mapped; if (mapped && LOCATION_CODE.test(mapped)) return mapped;
throw new Error( throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer region ${raw ? `"${raw}"` : "(unset)"}, ` + `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
"which is not a MoR region code and has no mapping. Add it to EIMS_BUYER_REGION_CODES.", `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, Tin: company.tin,
LegalName: company.name, LegalName: company.name,
Phone: company.phone ?? null, 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, Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null, Zone: company.zone ?? null,
Kebele: company.kebele ?? null, Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null, VatNumber: company.vatNumber ?? null,
Wereda: company.woreda ?? null, Wereda: resolveLocationCode(
"Wereda",
company.woreda,
context.buyerWeredaCodes,
"EIMS_BUYER_WEREDA_CODES",
invoice.invoiceNumber,
),
}, },
DocumentDetails: { DocumentDetails: {
DocumentNumber: context.documentNumber, DocumentNumber: context.documentNumber,

View File

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

View File

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