mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
Merge pull request #1175 from Tria-plc/eims-integration
fix(eims): match MoR's payload rules found by live rejections
This commit is contained in:
@@ -172,8 +172,10 @@ EIMS_SELLER_LOCALITY=
|
||||
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
||||
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
||||
# locally, naming the missing variables, until these are set.
|
||||
# MoR enum: TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
||||
EIMS_TAX_CODE=VAT0
|
||||
# Required, and deliberately unset: the choice is a tax position, not a default.
|
||||
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
||||
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
|
||||
EIMS_TAX_CODE=
|
||||
EIMS_TAX_RATE_PERCENT=0
|
||||
EIMS_EXCISE_TAX_VALUE=0
|
||||
EIMS_INCOME_WITHHOLD_VALUE=0
|
||||
@@ -187,6 +189,9 @@ EIMS_PAYMENT_TERM=IMMIDIATE
|
||||
EIMS_UNIT_DEFAULT=PCS
|
||||
# MoR numeric country code for the buyer; our companies store the country name.
|
||||
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
|
||||
EIMS_CASHIER_NAME=
|
||||
EIMS_SALESPERSON_NAME=
|
||||
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
||||
|
||||
@@ -81,8 +81,12 @@ export interface EimsInvoiceConfig {
|
||||
paymentTerm: string;
|
||||
unitDefault: string;
|
||||
buyerCountryCode: string | null;
|
||||
/** MoR region code used when a buyer's stored region is a name rather than a code. */
|
||||
buyerRegionFallback: string | null;
|
||||
/**
|
||||
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
||||
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
||||
* locally rather than being filed with a guessed one.
|
||||
*/
|
||||
buyerRegionCodes: Record<string, string>;
|
||||
cashierName: string | null;
|
||||
salesPersonName: string | null;
|
||||
}
|
||||
@@ -105,6 +109,16 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n
|
||||
return value;
|
||||
};
|
||||
|
||||
/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */
|
||||
const parseRegionCodes = (raw: string | undefined): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const pair of (raw ?? "").split(",")) {
|
||||
const [name, code] = pair.split("=");
|
||||
if (name?.trim() && code?.trim()) map[name.trim()] = code.trim();
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
|
||||
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||
if (raw === undefined || raw === "") return null;
|
||||
@@ -170,7 +184,7 @@ 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,
|
||||
buyerRegionFallback: process.env.EIMS_BUYER_REGION_FALLBACK || null,
|
||||
buyerRegionCodes: parseRegionCodes(process.env.EIMS_BUYER_REGION_CODES),
|
||||
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
||||
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
||||
},
|
||||
|
||||
@@ -60,6 +60,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
||||
unitDefault: "PCS",
|
||||
incomeWithholdValue: 0,
|
||||
transactionWithholdValue: 0,
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -130,7 +131,7 @@ describe("toEimsInvoice", () => {
|
||||
ExciseTaxValue: 0,
|
||||
TotalLineAmount: 11500,
|
||||
Unit: "PCS",
|
||||
NatureOfSupplies: "Service",
|
||||
NatureOfSupplies: "service",
|
||||
HarmonizationCode: null,
|
||||
});
|
||||
expect(doc.ItemList[1]).toMatchObject({
|
||||
@@ -207,6 +208,53 @@ describe("toEimsInvoice", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toEimsInvoice — MoR field constraints", () => {
|
||||
it("passes a buyer region through when it is already a MoR code", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context());
|
||||
expect(doc.BuyerDetails.Region).toBe("13");
|
||||
});
|
||||
|
||||
it("maps a region name to its code, ignoring case and spacing", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
|
||||
seller,
|
||||
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
|
||||
);
|
||||
expect(doc.BuyerDetails.Region).toBe("13");
|
||||
});
|
||||
|
||||
it("refuses to file a buyer whose region has no mapping", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
|
||||
seller,
|
||||
context(),
|
||||
),
|
||||
).toThrow(/not a MoR region code and has no mapping/);
|
||||
});
|
||||
|
||||
it("refuses a buyer with no region at all rather than guessing one", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, region: null } }),
|
||||
seller,
|
||||
context(),
|
||||
),
|
||||
).toThrow(/buyer region \(unset\)/);
|
||||
});
|
||||
|
||||
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
||||
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
||||
});
|
||||
|
||||
it("rejects a NatureOfSupplies MoR does not accept", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })),
|
||||
).toThrow(/must be one of goods, service/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatEimsDate", () => {
|
||||
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
|
||||
expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user