mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Confirmed live 2026-08-17 on INV-20260817-00008: MoR rejected the
document with a SCHEMA ERROR on ItemList[0].Unit — 'PER_CONTAINER'
(from the line's own metadata.unit) fails MoR's enum
(LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG), its 8-char max, and its
^[A-Za-z]{3,8}$ regex all at once.
line.metadata.unit is our own fee-basis tag (PER_CONTAINER/PER_TON/
PER_ITEM — how a charge is computed) and was never a MoR unit of
measure; the mapper was reusing the same field name for two unrelated
concepts. Every line now sends the single configured
EIMS_UNIT_DEFAULT instead of guessing a per-line value that doesn't
exist in MoR's vocabulary.
580 lines
22 KiB
TypeScript
580 lines
22 KiB
TypeScript
/**
|
|
* Pure mapper from an EDR invoice onto the Ethiopian MoR EIMS registration document
|
|
* (`POST https://core.mor.gov.et/v1/register`).
|
|
*
|
|
* Field names, casing and section layout are taken verbatim from the supplied
|
|
* `EimsCoreApiMockCollection2.postman_collection.json`. Note the payload spells the district
|
|
* `Wereda` even though the collection *variable* is named `sellerWoreda`.
|
|
*
|
|
* Scope: mapping only — no HTTP, no signing, no persistence, no counter allocation. Everything
|
|
* that does not live on the invoice (document number, counters, previous IRN, seller identity,
|
|
* tax treatment) is supplied by the caller and is never guessed here.
|
|
*
|
|
* Values that the collection only *demonstrates* by example — the date format, the meaning of an
|
|
* empty `PreviousIrn`, the `SystemType` enum, `PaymentTerm` values — are treated as observed, not
|
|
* authoritative: they are passed through or overridable rather than validated against a fixed set.
|
|
*/
|
|
|
|
import { round2 } from "./invoice-settlement.util";
|
|
|
|
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
|
|
const EIMS_VERSION = "1";
|
|
|
|
/**
|
|
* `DocumentDetails.Type`. `"INV"` is the only value observed in the collection; `"DEB"`/`"CRE"`
|
|
* (debit/credit note) were confirmed directly by MoR support — same `/v1/register` endpoint, no
|
|
* separate API. MoR's answer, verbatim: "the same endpoint used for registration should be used
|
|
* ... within the Document Detail object, you should specify DEB for a debit note, CRE for a
|
|
* credit note... add a Reason attribute under document detail object".
|
|
*/
|
|
export const EIMS_DOCUMENT_TYPES = ["INV", "DEB", "CRE"] as const;
|
|
export type EimsDocumentType = (typeof EIMS_DOCUMENT_TYPES)[number];
|
|
|
|
export interface EimsBuyerDetails {
|
|
City: string | null;
|
|
Email: string | null;
|
|
HouseNumber: string | null;
|
|
IdNumber: string | null;
|
|
IdType: string | null;
|
|
Tin: string;
|
|
LegalName: string;
|
|
Phone: string | null;
|
|
Region: string | null;
|
|
Country: string | null;
|
|
Zone: string | null;
|
|
Kebele: string | null;
|
|
VatNumber: string | null;
|
|
Wereda: string | null;
|
|
}
|
|
|
|
export interface EimsSellerDetails {
|
|
City: string | null;
|
|
Email: string | null;
|
|
HouseNumber: string | null;
|
|
LegalName: string;
|
|
Locality: string | null;
|
|
Phone: string | null;
|
|
/** MoR region *code* (e.g. "13"), not a region name. */
|
|
Region: string | null;
|
|
SubCity: string | null;
|
|
Tin: string;
|
|
VatNumber: string | null;
|
|
/** MoR wereda *code* (e.g. "574"). */
|
|
Wereda: string | null;
|
|
}
|
|
|
|
export interface EimsDocumentDetails {
|
|
DocumentNumber: string;
|
|
/** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */
|
|
Date: string;
|
|
Type: EimsDocumentType;
|
|
/** Only for DEB/CRE, per MoR support — why the debit/credit note was issued. Absent for INV. */
|
|
Reason?: string;
|
|
}
|
|
|
|
export interface EimsInvoiceItem {
|
|
Discount: number;
|
|
ExciseTaxValue: number;
|
|
HarmonizationCode: string | null;
|
|
NatureOfSupplies: string;
|
|
ItemCode: string;
|
|
ProductDescription: string;
|
|
PreTaxValue: number;
|
|
Quantity: number;
|
|
LineNumber: number;
|
|
TaxAmount: number;
|
|
TaxCode: string;
|
|
TotalLineAmount: number;
|
|
Unit: string;
|
|
UnitPrice: number;
|
|
}
|
|
|
|
export interface EimsPaymentDetails {
|
|
Mode: string;
|
|
PaymentTerm: string;
|
|
}
|
|
|
|
export interface EimsReferenceDetails {
|
|
PreviousIrn: string | null;
|
|
RelatedDocument: string | null;
|
|
}
|
|
|
|
export interface EimsSourceSystem {
|
|
CashierName: string | null;
|
|
InvoiceCounter: number;
|
|
SalesPersonName: string | null;
|
|
SystemNumber: string;
|
|
SystemType: string;
|
|
}
|
|
|
|
export interface EimsValueDetails {
|
|
Discount: number | null;
|
|
ExciseValue: number;
|
|
IncomeWithholdValue: number;
|
|
TaxValue: number;
|
|
TotalValue: number;
|
|
TransactionWithholdValue: number;
|
|
InvoiceCurrency: string;
|
|
/** Absent from the register sample, present on the verify response. Emitted only when supplied. */
|
|
ExchangeRate?: number;
|
|
}
|
|
|
|
export interface EimsInvoiceRequest {
|
|
BuyerDetails: EimsBuyerDetails;
|
|
DocumentDetails: EimsDocumentDetails;
|
|
ItemList: EimsInvoiceItem[];
|
|
PaymentDetails: EimsPaymentDetails;
|
|
ReferenceDetails: EimsReferenceDetails;
|
|
SellerDetails: EimsSellerDetails;
|
|
SourceSystem: EimsSourceSystem;
|
|
TransactionType: string;
|
|
ValueDetails: EimsValueDetails;
|
|
Version: string;
|
|
}
|
|
|
|
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
|
|
export interface EimsRegisterResponseBody {
|
|
irn: string;
|
|
ackDate: string;
|
|
signedQR: string;
|
|
signedInvoice: string;
|
|
status: string;
|
|
documentNumber: string;
|
|
errorMessage: string | null;
|
|
}
|
|
|
|
/** Numeric columns arrive from pg as strings; every money field is normalised through `num`. */
|
|
export interface EimsMapperLine {
|
|
chargeType: string;
|
|
description?: string | null;
|
|
quantity: number | string;
|
|
unitRate: number | string;
|
|
amount: number | string;
|
|
metadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface EimsMapperCompany {
|
|
name: string;
|
|
tin: string;
|
|
vatNumber?: string | null;
|
|
phone?: string | null;
|
|
email?: string | null;
|
|
region?: string | null;
|
|
zone?: string | null;
|
|
woreda?: string | null;
|
|
kebele?: string | null;
|
|
houseNo?: string | null;
|
|
country?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Structurally what `BillingService.findById` returns — the only read path that loads the header,
|
|
* the buyer company and the lines together.
|
|
*/
|
|
export interface EimsMapperInvoice {
|
|
invoiceNumber: string;
|
|
currency: string;
|
|
issuedAt?: Date | string | null;
|
|
totalAmount: number | string;
|
|
company?: EimsMapperCompany | null;
|
|
lines: EimsMapperLine[];
|
|
}
|
|
|
|
/**
|
|
* Tax treatment for a single line. EIMS models `TaxCode`/`TaxAmount`/`ExciseTaxValue` per item, and
|
|
* different charge types may eventually be treated differently, so this is resolved per line.
|
|
*
|
|
* Nothing in this repo can supply it: `Invoice.taxAmount` is hardcoded to 0 with no caller ever
|
|
* setting it, `invoice_lines` has no tax column, and the rate catalogue has no fiscal field. That
|
|
* is the absence of a tax model, not evidence of zero-rating — hence no default here.
|
|
*/
|
|
export interface EimsLineTax {
|
|
code: string;
|
|
ratePercent: number;
|
|
exciseTaxValue: number;
|
|
/**
|
|
* Line-level `Discount`. Its effect on `TotalLineAmount` has never been observed live (every
|
|
* prior test ran it at 0), so the total below still sums PreTax + Tax + Excise only — do not
|
|
* start subtracting this without a confirmed MoR example.
|
|
*/
|
|
discount: number;
|
|
}
|
|
|
|
export interface EimsMapperContext {
|
|
systemNumber: string;
|
|
/** Observed values: POS, MAN, CRM, EFD, SYS (the collection prose also mentions ERP). */
|
|
systemType: string;
|
|
/** Caller decides the source — our own `invoiceNumber` or a dedicated EIMS sequence. */
|
|
documentNumber: string;
|
|
invoiceCounter: number;
|
|
/** Passed through verbatim; the collection shows `""` used for an unchained document. */
|
|
previousIrn: string | null;
|
|
cashierName: string | null;
|
|
salesPersonName: string | null;
|
|
/** B2B / B2C — a tax classification, so the caller states it. */
|
|
transactionType: string;
|
|
payment: { mode: string; term: string };
|
|
/** Must return a treatment for every line, or throw. */
|
|
taxForLine: (line: EimsMapperLine, lineNumber: number) => EimsLineTax;
|
|
natureOfSupplies: string;
|
|
/** Used when a line carries no `metadata.unit`. */
|
|
unitDefault: string;
|
|
incomeWithholdValue: number;
|
|
transactionWithholdValue: number;
|
|
/**
|
|
* `DocumentDetails.Type`. Defaults to `"INV"`. For `"DEB"`/`"CRE"` both `reason` and
|
|
* `relatedDocument` become required — confirmed directly by MoR support, not the collection.
|
|
*/
|
|
documentType?: EimsDocumentType;
|
|
/** Required when `documentType` is `"DEB"`/`"CRE"` — why the note was issued. Unused for INV. */
|
|
reason?: string | null;
|
|
/**
|
|
* `ReferenceDetails.RelatedDocument`. Null for an ordinary invoice; required for a DEB/CRE —
|
|
* the original registered invoice's IRN, per MoR's own IRC-P06/P07 checklist ("credit memo
|
|
* from a registered invoice").
|
|
*/
|
|
relatedDocument?: string | null;
|
|
/**
|
|
* 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.
|
|
*
|
|
* `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.
|
|
*/
|
|
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>;
|
|
/**
|
|
* 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;
|
|
/** Required when the invoice currency is not ETB. */
|
|
exchangeRate?: number | null;
|
|
invoiceDiscount?: number | null;
|
|
/** Override while the observed `dd-MM-yyyyTHH:mm:ss` format is unconfirmed by MoR. */
|
|
formatDate?: (issuedAt: Date) => string;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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)}`);
|
|
return n;
|
|
};
|
|
|
|
const pad = (n: number, width = 2): string => String(n).padStart(width, "0");
|
|
|
|
/** Observed EIMS document-date format: `dd-MM-yyyyTHH:mm:ss`, no timezone marker. */
|
|
export const formatEimsDate = (issuedAt: Date): string =>
|
|
`${pad(issuedAt.getDate())}-${pad(issuedAt.getMonth() + 1)}-${issuedAt.getFullYear()}` +
|
|
`T${pad(issuedAt.getHours())}:${pad(issuedAt.getMinutes())}:${pad(issuedAt.getSeconds())}`;
|
|
|
|
/**
|
|
* Map one loaded invoice onto an EIMS registration document.
|
|
*
|
|
* Throws rather than emitting a payload EIMS would reject opaquely: missing buyer TIN, no lines,
|
|
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
|
|
* exchange rate.
|
|
*/
|
|
/**
|
|
* 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" | "City",
|
|
value: string | null | undefined,
|
|
codes: Record<string, string>,
|
|
envVar: string,
|
|
invoiceNumber: string,
|
|
opts: { required?: boolean } = {},
|
|
): string | null {
|
|
const raw = (value ?? "").trim();
|
|
if (LOCATION_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 && 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.",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
|
|
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
|
|
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
|
|
* name lookup, `undefined` on no match — the caller falls back to static config either way.
|
|
*/
|
|
export function resolveOptionalCode(
|
|
value: string | null | undefined,
|
|
codes: Record<string, string>,
|
|
): string | undefined {
|
|
const raw = (value ?? "").trim();
|
|
if (LOCATION_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];
|
|
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
|
|
}
|
|
|
|
export function toEimsInvoice(
|
|
invoice: EimsMapperInvoice,
|
|
seller: EimsSellerDetails,
|
|
context: EimsMapperContext,
|
|
): EimsInvoiceRequest {
|
|
const company = invoice.company;
|
|
if (!company || !company.tin?.trim()) {
|
|
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no buyer company TIN`);
|
|
}
|
|
if (!invoice.lines?.length) {
|
|
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no lines`);
|
|
}
|
|
if (!invoice.issuedAt) {
|
|
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} is not issued (issuedAt is null)`);
|
|
}
|
|
if (invoice.currency !== "ETB" && context.exchangeRate == null) {
|
|
throw new Error(
|
|
`EIMS mapping: invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`,
|
|
);
|
|
}
|
|
|
|
const documentType = context.documentType ?? "INV";
|
|
if (!EIMS_DOCUMENT_TYPES.includes(documentType)) {
|
|
throw new Error(
|
|
`EIMS mapping: invoice ${invoice.invoiceNumber} has documentType "${documentType}", must be one of ${EIMS_DOCUMENT_TYPES.join(", ")}`,
|
|
);
|
|
}
|
|
if (documentType !== "INV") {
|
|
if (!context.reason?.trim()) {
|
|
throw new Error(
|
|
`EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs a reason`,
|
|
);
|
|
}
|
|
if (!context.relatedDocument?.trim()) {
|
|
throw new Error(
|
|
`EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs ` +
|
|
"relatedDocument — the original registered invoice's IRN",
|
|
);
|
|
}
|
|
}
|
|
|
|
const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt);
|
|
if (Number.isNaN(issuedAt.getTime())) {
|
|
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);
|
|
if (
|
|
!tax ||
|
|
!tax.code ||
|
|
!Number.isFinite(tax.ratePercent) ||
|
|
!Number.isFinite(tax.exciseTaxValue) ||
|
|
!Number.isFinite(tax.discount)
|
|
) {
|
|
throw new Error(
|
|
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
|
|
`on invoice ${invoice.invoiceNumber}`,
|
|
);
|
|
}
|
|
|
|
const PreTaxValue = round2(num(line.amount));
|
|
const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100);
|
|
const ExciseTaxValue = round2(tax.exciseTaxValue);
|
|
// `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge
|
|
// is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here
|
|
// (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex
|
|
// all at once) is what a prior version of this mapper did by mistake. MoR's own enum
|
|
// (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line
|
|
// uses the single configured default rather than guessing a per-line value that doesn't exist.
|
|
const unit = context.unitDefault;
|
|
|
|
return {
|
|
Discount: round2(tax.discount),
|
|
ExciseTaxValue,
|
|
HarmonizationCode: null,
|
|
NatureOfSupplies: natureOfSupplies,
|
|
ItemCode: line.chargeType,
|
|
ProductDescription: line.description?.trim() || line.chargeType,
|
|
PreTaxValue,
|
|
Quantity: round2(num(line.quantity)),
|
|
LineNumber: lineNumber,
|
|
TaxAmount,
|
|
TaxCode: tax.code,
|
|
TotalLineAmount: round2(PreTaxValue + TaxAmount + ExciseTaxValue),
|
|
Unit: unit,
|
|
UnitPrice: round2(num(line.unitRate)),
|
|
};
|
|
});
|
|
|
|
const preTaxTotal = round2(ItemList.reduce((sum, item) => sum + item.PreTaxValue, 0));
|
|
const invoiceTotal = round2(num(invoice.totalAmount));
|
|
if (Math.abs(preTaxTotal - invoiceTotal) > 0.01) {
|
|
throw new Error(
|
|
`EIMS mapping: invoice ${invoice.invoiceNumber} lines sum to ${preTaxTotal} ` +
|
|
`but the invoice total is ${invoiceTotal}`,
|
|
);
|
|
}
|
|
|
|
const ValueDetails: EimsValueDetails = {
|
|
Discount: context.invoiceDiscount ?? null,
|
|
ExciseValue: round2(ItemList.reduce((sum, item) => sum + item.ExciseTaxValue, 0)),
|
|
IncomeWithholdValue: context.incomeWithholdValue,
|
|
TaxValue: round2(ItemList.reduce((sum, item) => sum + item.TaxAmount, 0)),
|
|
TotalValue: round2(ItemList.reduce((sum, item) => sum + item.TotalLineAmount, 0)),
|
|
TransactionWithholdValue: context.transactionWithholdValue,
|
|
InvoiceCurrency: invoice.currency,
|
|
};
|
|
if (context.exchangeRate != null) ValueDetails.ExchangeRate = context.exchangeRate;
|
|
|
|
return {
|
|
BuyerDetails: {
|
|
// 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,
|
|
IdType: context.buyerIdType ?? null,
|
|
Tin: company.tin,
|
|
LegalName: company.name,
|
|
Phone: company.phone ?? null,
|
|
Region: resolveLocationCode(
|
|
"Region",
|
|
company.region,
|
|
context.buyerRegionCodes,
|
|
"EIMS_BUYER_REGION_CODES",
|
|
invoice.invoiceNumber,
|
|
),
|
|
Country: resolveCountryCode(
|
|
company.country,
|
|
context.buyerCountryCodes,
|
|
context.buyerCountryCode ?? null,
|
|
invoice.invoiceNumber,
|
|
),
|
|
Zone: company.zone ?? null,
|
|
Kebele: company.kebele ?? null,
|
|
VatNumber: company.vatNumber ?? null,
|
|
Wereda: resolveLocationCode(
|
|
"Wereda",
|
|
company.woreda,
|
|
context.buyerWeredaCodes,
|
|
"EIMS_BUYER_WEREDA_CODES",
|
|
invoice.invoiceNumber,
|
|
),
|
|
},
|
|
DocumentDetails: {
|
|
DocumentNumber: context.documentNumber,
|
|
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
|
|
Type: documentType,
|
|
...(documentType !== "INV" ? { Reason: context.reason! } : {}),
|
|
},
|
|
ItemList,
|
|
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },
|
|
ReferenceDetails: {
|
|
PreviousIrn: context.previousIrn,
|
|
RelatedDocument: context.relatedDocument ?? null,
|
|
},
|
|
SellerDetails: seller,
|
|
SourceSystem: {
|
|
CashierName: context.cashierName,
|
|
InvoiceCounter: context.invoiceCounter,
|
|
SalesPersonName: context.salesPersonName,
|
|
SystemNumber: context.systemNumber,
|
|
SystemType: context.systemType,
|
|
},
|
|
TransactionType: context.transactionType,
|
|
ValueDetails,
|
|
Version: EIMS_VERSION,
|
|
};
|
|
}
|