mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(eims): derive buyer City/Country from company profile, not global config
City: EimsMapperContext.buyerCity was declared but never wired anywhere — always null, silently, for every buyer. No dedicated city column on Company; derives from Zone via a new EIMS_BUYER_CITY_CODES map, same lookup mechanism as Region/Wereda but optional (an unmapped zone resolves to null rather than throwing) — MoR has already accepted a live filing with City null. Country: previously a single flat EIMS_BUYER_COUNTRY_CODE applied to every buyer regardless of Company.country. Now reads company.country, resolved via a new EIMS_BUYER_COUNTRY_CODES name-to-code map; the flat env var becomes a domestic-only fallback (applies only when country is empty/Ethiopia), so an unmapped foreign buyer fails locally instead of silently filing as Ethiopia. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,19 @@ export interface EimsInvoiceConfig {
|
||||
paymentMode: string;
|
||||
paymentTerm: string;
|
||||
unitDefault: string;
|
||||
/**
|
||||
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
|
||||
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
|
||||
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
|
||||
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
|
||||
*/
|
||||
buyerCountryCode: string | null;
|
||||
/**
|
||||
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
|
||||
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
|
||||
* this is not validated against a fixed digit pattern, only looked up by name.
|
||||
*/
|
||||
buyerCountryCodes: Record<string, string>;
|
||||
/**
|
||||
* 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
|
||||
@@ -89,6 +101,14 @@ export interface EimsInvoiceConfig {
|
||||
buyerRegionCodes: Record<string, string>;
|
||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||
buyerWeredaCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
|
||||
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
|
||||
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
|
||||
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
|
||||
* mapping.
|
||||
*/
|
||||
buyerCityCodes: Record<string, string>;
|
||||
/**
|
||||
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||
@@ -208,8 +228,10 @@ 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,
|
||||
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
|
||||
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
||||
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
||||
buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES),
|
||||
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||
|
||||
@@ -60,8 +60,11 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
||||
unitDefault: "PCS",
|
||||
incomeWithholdValue: 0,
|
||||
transactionWithholdValue: 0,
|
||||
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
|
||||
buyerCountryCodes: {},
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
buyerWeredaCodes: {},
|
||||
buyerCityCodes: {},
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -92,6 +95,9 @@ describe("toEimsInvoice", () => {
|
||||
|
||||
expect(doc.BuyerDetails).toEqual({
|
||||
City: null,
|
||||
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
|
||||
// buyerCountryCode fallback, not null, per resolveCountryCode.
|
||||
Country: "231",
|
||||
Email: "buyer@abc.et",
|
||||
HouseNumber: "NEW",
|
||||
IdNumber: null,
|
||||
@@ -100,7 +106,6 @@ describe("toEimsInvoice", () => {
|
||||
LegalName: "ABC Trading PLC",
|
||||
Phone: "0912345678",
|
||||
Region: "13",
|
||||
Country: null,
|
||||
Zone: "SHA",
|
||||
Kebele: "03",
|
||||
VatNumber: "123475885858",
|
||||
@@ -335,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => {
|
||||
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
|
||||
});
|
||||
|
||||
it("derives City from the buyer's zone via the city code map", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
|
||||
seller,
|
||||
context({ buyerCityCodes: { Kirkos: "101" } }),
|
||||
);
|
||||
expect(doc.BuyerDetails.City).toBe("101");
|
||||
});
|
||||
|
||||
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
|
||||
seller,
|
||||
context({ buyerCityCodes: {} }),
|
||||
);
|
||||
expect(doc.BuyerDetails.City).toBeNull();
|
||||
});
|
||||
|
||||
it("maps a buyer country name to its code via the country code map", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
|
||||
seller,
|
||||
context({ buyerCountryCodes: { Djibouti: "071" } }),
|
||||
);
|
||||
expect(doc.BuyerDetails.Country).toBe("071");
|
||||
});
|
||||
|
||||
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
|
||||
seller,
|
||||
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
|
||||
);
|
||||
expect(doc.BuyerDetails.Country).toBe("231");
|
||||
});
|
||||
|
||||
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
|
||||
seller,
|
||||
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
|
||||
),
|
||||
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -234,8 +234,13 @@ export interface EimsMapperContext {
|
||||
* from a registered invoice").
|
||||
*/
|
||||
relatedDocument?: string | null;
|
||||
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
||||
/**
|
||||
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
|
||||
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
|
||||
*/
|
||||
buyerCountryCode?: string | null;
|
||||
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
|
||||
buyerCountryCodes: Record<string, string>;
|
||||
/**
|
||||
* Region name → MoR numeric code, for buyers whose stored region is free text.
|
||||
*
|
||||
@@ -252,9 +257,15 @@ export interface EimsMapperContext {
|
||||
* fail locally on an unmapped name rather than file a guess.
|
||||
*/
|
||||
buyerWeredaCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
|
||||
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
|
||||
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
|
||||
* the mapping.
|
||||
*/
|
||||
buyerCityCodes: Record<string, string>;
|
||||
buyerIdType?: string | null;
|
||||
buyerIdNumber?: string | null;
|
||||
buyerCity?: string | null;
|
||||
/** Required when the invoice currency is not ETB. */
|
||||
exchangeRate?: number | null;
|
||||
invoiceDiscount?: number | null;
|
||||
@@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string =>
|
||||
* exchange rate.
|
||||
*/
|
||||
/**
|
||||
* 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.
|
||||
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
|
||||
* numeric, otherwise looked up by name (case- and space-insensitive).
|
||||
*
|
||||
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
|
||||
* document is worse than refusing to file. City is optional (`required: false`, City's own
|
||||
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
|
||||
* null instead of blocking the invoice.
|
||||
*/
|
||||
function resolveLocationCode(
|
||||
field: "Region" | "Wereda",
|
||||
field: "Region" | "Wereda" | "City",
|
||||
value: string | null | undefined,
|
||||
codes: Record<string, string>,
|
||||
envVar: string,
|
||||
invoiceNumber: string,
|
||||
): string {
|
||||
opts: { required?: boolean } = {},
|
||||
): string | null {
|
||||
const raw = (value ?? "").trim();
|
||||
if (LOCATION_CODE.test(raw)) return raw;
|
||||
|
||||
@@ -319,12 +335,42 @@ function resolveLocationCode(
|
||||
)?.[1];
|
||||
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
||||
|
||||
if (opts.required === false) return null;
|
||||
|
||||
throw new Error(
|
||||
`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}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
|
||||
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
|
||||
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
|
||||
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
|
||||
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
|
||||
*/
|
||||
function resolveCountryCode(
|
||||
country: string | null | undefined,
|
||||
codes: Record<string, string>,
|
||||
domesticFallback: string | null,
|
||||
invoiceNumber: string,
|
||||
): string | null {
|
||||
const raw = (country ?? "").trim();
|
||||
const key = raw.toLowerCase().replace(/\s+/g, " ");
|
||||
const mapped = Object.entries(codes).find(
|
||||
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
|
||||
)?.[1];
|
||||
if (mapped) return mapped;
|
||||
|
||||
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
|
||||
|
||||
throw new Error(
|
||||
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
|
||||
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
|
||||
);
|
||||
}
|
||||
|
||||
export function toEimsInvoice(
|
||||
invoice: EimsMapperInvoice,
|
||||
seller: EimsSellerDetails,
|
||||
@@ -440,7 +486,16 @@ export function toEimsInvoice(
|
||||
|
||||
return {
|
||||
BuyerDetails: {
|
||||
City: context.buyerCity ?? null,
|
||||
// No dedicated city column on Company — Zone is the closest match; optional (see
|
||||
// resolveLocationCode's City comment).
|
||||
City: resolveLocationCode(
|
||||
"City",
|
||||
company.zone,
|
||||
context.buyerCityCodes,
|
||||
"EIMS_BUYER_CITY_CODES",
|
||||
invoice.invoiceNumber,
|
||||
{ required: false },
|
||||
),
|
||||
Email: company.email ?? null,
|
||||
HouseNumber: company.houseNo ?? null,
|
||||
IdNumber: context.buyerIdNumber ?? null,
|
||||
@@ -455,7 +510,12 @@ export function toEimsInvoice(
|
||||
"EIMS_BUYER_REGION_CODES",
|
||||
invoice.invoiceNumber,
|
||||
),
|
||||
Country: context.buyerCountryCode ?? null,
|
||||
Country: resolveCountryCode(
|
||||
company.country,
|
||||
context.buyerCountryCodes,
|
||||
context.buyerCountryCode ?? null,
|
||||
invoice.invoiceNumber,
|
||||
),
|
||||
Zone: company.zone ?? null,
|
||||
Kebele: company.kebele ?? null,
|
||||
VatNumber: company.vatNumber ?? null,
|
||||
|
||||
@@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||
buyerCountryCode: invoice.buyerCountryCode,
|
||||
buyerCountryCodes: invoice.buyerCountryCodes,
|
||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||
buyerCityCodes: invoice.buyerCityCodes,
|
||||
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||
buyerIdType: invoice.buyerIdType,
|
||||
buyerIdNumber: invoice.buyerIdNumber,
|
||||
|
||||
@@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
||||
paymentTerm: "IMMIDIATE",
|
||||
unitDefault: "PCS",
|
||||
buyerCountryCode: null,
|
||||
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
|
||||
taxCodeByChargeType: {},
|
||||
taxRateByChargeType: {},
|
||||
exciseByChargeType: {},
|
||||
|
||||
Reference in New Issue
Block a user