mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
454 lines
16 KiB
TypeScript
454 lines
16 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";
|
|
|
|
/** The only `DocumentDetails.Type` observed in the supplied material. */
|
|
const EIMS_DOCUMENT_TYPE = "INV";
|
|
|
|
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: 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;
|
|
/** Null for an ordinary invoice; set only for a real related-document case. */
|
|
relatedDocument?: string | null;
|
|
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
|
buyerCountryCode?: string | null;
|
|
/**
|
|
* 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>;
|
|
buyerIdType?: string | null;
|
|
buyerIdNumber?: string | null;
|
|
buyerCity?: 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 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.
|
|
*/
|
|
function resolveLocationCode(
|
|
field: "Region" | "Wereda",
|
|
value: string | null | undefined,
|
|
codes: Record<string, string>,
|
|
envVar: string,
|
|
invoiceNumber: string,
|
|
): string {
|
|
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;
|
|
|
|
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}.`,
|
|
);
|
|
}
|
|
|
|
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 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);
|
|
const unit = typeof line.metadata?.unit === "string" ? line.metadata.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: {
|
|
City: context.buyerCity ?? null,
|
|
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: context.buyerCountryCode ?? null,
|
|
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: EIMS_DOCUMENT_TYPE,
|
|
},
|
|
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,
|
|
};
|
|
}
|