fix(eims): match MoR's payload rules found by live rejections

Three live attempts turned six guesses into facts. Each fix below is the
gateway's own words, not a reading of the collection.

DocumentNumber and InvoiceCounter move differently, because MoR constrains
them differently. The counter must not skip -- "Invoice counter is not
correct. expected : 1" -- so a definitively refused document hands it back.
The document number must not repeat, so the attempt burns it. Both stay spent
after an ambiguous result, where MoR may have stored the document.

NatureOfSupplies is normalised to MoR's exact lowercase constant and rejected
outright if it is neither 'goods' nor 'service'; its schema branches on this
as a oneOf, so "Service" invalidated the whole ItemList.

Buyer region resolves through a name->code map and now FAILS locally when
unmapped. MoR validates Region against ^[0-9]{1,3}$ on both the seller and
buyer sides, so a name can never be sent and a guessed code on a tax document
is worse than refusing to file.

Seller phone, email, region and wereda are checked against MoR's own regexes
before anything is sent, so a placeholder like "_" fails locally instead of
costing a request and a counter.

EIMS_TAX_CODE stays required and unset in .env.example: the choice between
VAT0 (zero-rated) and VATEX (exempt) is a tax position awaiting finance, and
MoR's enum is recorded there for whoever decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-08 05:27:07 +00:00
parent 40a6ccc928
commit aec4f3d654
8 changed files with 174 additions and 30 deletions

View File

@@ -211,13 +211,14 @@ export interface EimsMapperContext {
/** MoR numeric country code for the buyer; our DB stores the country name. */
buyerCountryCode?: string | null;
/**
* Region code to use when the buyer's stored region is not already one.
* Region name → MoR numeric code, for buyers whose stored region is free text.
*
* MoR validates `BuyerDetails.Region` against `^[0-9]{1,3}$`, but `companies.region` is free
* text ("Addis Ababa"). Rather than ship a name→code table we cannot verify, a stored value that
* already looks like a code is passed through and anything else falls back to this.
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
* tax document is worse than refusing to file.
*/
buyerRegionFallback?: string | null;
buyerRegionCodes: Record<string, string>;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
@@ -228,9 +229,17 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/** MoR's own constraint on `Region`: one to three digits. */
/** MoR's own constraint on `Region`, on both the seller and buyer sides: one to three digits. */
const REGION_CODE = /^[0-9]{1,3}$/;
/**
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
*
* Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the
* whole `ItemList` — the error reads "must be the constant value 'service'".
*/
const NATURE_OF_SUPPLIES = ["goods", "service"] as const;
const num = (v: number | string): number => {
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`);
@@ -251,6 +260,30 @@ export const formatEimsDate = (issuedAt: Date): string =>
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
* 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.
*/
function resolveRegionCode(
region: string | null | undefined,
codes: Record<string, string>,
invoiceNumber: string,
): string {
const raw = (region ?? "").trim();
if (REGION_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;
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.",
);
}
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
@@ -277,6 +310,14 @@ export function toEimsInvoice(
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
}
const natureOfSupplies = context.natureOfSupplies.trim().toLowerCase();
if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) {
throw new Error(
`EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` +
`got "${context.natureOfSupplies}"`,
);
}
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
const lineNumber = index + 1;
const tax = context.taxForLine(line, lineNumber);
@@ -296,7 +337,7 @@ export function toEimsInvoice(
Discount: 0,
ExciseTaxValue,
HarmonizationCode: null,
NatureOfSupplies: context.natureOfSupplies,
NatureOfSupplies: natureOfSupplies,
ItemCode: line.chargeType,
ProductDescription: line.description?.trim() || line.chargeType,
PreTaxValue,
@@ -340,9 +381,7 @@ export function toEimsInvoice(
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: REGION_CODE.test(company.region ?? "")
? (company.region as string)
: (context.buyerRegionFallback ?? null),
Region: resolveRegionCode(company.region, context.buyerRegionCodes, invoice.invoiceNumber),
Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,