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

@@ -57,6 +57,37 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
});
}
assertSellerFormats(config.invoice);
}
/**
* MoR's own patterns for the seller fields, checked here rather than at the gateway.
*
* A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a
* consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us.
*/
const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [
{ env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ },
{
env: "EIMS_SELLER_EMAIL",
value: (i) => i.sellerEmail,
pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/,
},
{ env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ },
{ env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ },
];
function assertSellerFormats(invoice: EimsConfig["invoice"]): void {
const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map(
({ env, pattern }) => `${env} (must match ${pattern.source})`,
);
if (bad.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INVALID",
message: `EIMS seller details would be rejected by MoR: ${bad.join("; ")}`,
});
}
}
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
@@ -112,9 +143,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
// companies.region is free text ("Addis Ababa"); MoR wants ^[0-9]{1,3}$. A stored value that
// already looks like a code wins, otherwise the seller's own region stands in.
buyerRegionFallback: invoice.buyerRegionFallback || invoice.sellerRegion,
buyerRegionCodes: invoice.buyerRegionCodes,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -421,10 +421,14 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
// A refused document returns its counter, so the next attempt reuses it — MoR expects a
// contiguous sequence of *accepted* documents, not of attempts.
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
// The two numbers move differently, because MoR constrains them differently: the counter must
// not skip (it returns), the document number must not repeat (it is burned).
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
expect(first.SourceSystem.InvoiceCounter).toBe(7);
expect(second.SourceSystem.InvoiceCounter).toBe(7);
expect(first.DocumentDetails.DocumentNumber).toBe("5");
expect(second.DocumentDetails.DocumentNumber).toBe("6");
});
});

View File

@@ -397,10 +397,15 @@ export class EimsInvoiceRegistrationService {
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* Returning the counter is not an optimisation — MoR tracks the sequence itself and rejects a
* gap: "Invoice counter is not correct. expected : 1". A document it definitively refused was
* never counted on its side, so ours must not advance either. An ambiguous result is the
* opposite case: MoR may have counted it, so the number stays spent until a human resolves it.
* The two numbers move differently, because MoR constrains them differently:
*
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
* document MoR definitively refused was never counted there, so ours must not advance either.
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
* unique". It is therefore spent by the attempt itself and never handed back, even for a
* refusal.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
*/
private async settleFailure(
invoiceId: string,
@@ -429,9 +434,9 @@ export class EimsInvoiceRegistrationService {
reservation.stateId,
deterministic
? {
// Hand both numbers back: MoR never counted a document it refused outright.
// Counter returns (MoR never counted a refused document); the document number does
// not (MoR requires it to be unique, so it is burned by the attempt).
nextInvoiceCounter: reservation.invoiceCounter,
nextDocumentNumber: Number(reservation.documentNumber),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,

View File

@@ -33,7 +33,7 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerRegionFallback: "13",
buyerRegionCodes: { "Addis Ababa": "13" },
cashierName: null,
salesPersonName: null,
...over,