mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
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:
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user