mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 13:40:57 +00:00
feat(eims): add invoice mapper and signed EIMS transport
Map EDR invoices onto the MoR EIMS /v1/register document and add the cryptographic transport needed to talk to core.mor.gov.et. Mapper: DTOs mirror the supplied Postman collection section by section. Tax is resolved per line via a caller-supplied resolver and throws when unresolved -- the app models no tax at all (invoice.taxAmount is always 0, invoice_lines and the rate catalogue carry no fiscal columns), so a zero-rated default would assert a tax position the codebase cannot support. Seller identity, document number, counters and previous IRN are passed in explicitly; the mapper stays pure. Transport: config, credential loading, RSA-SHA512 signing and /auth/login with an in-memory token cache. Signing reproduces the process that produced a working live token -- compact JSON of the inner request only, exact UTF-8 bytes, base64 signature, and base64 of the certificate file's exact bytes with no parsing or re-encoding. Concurrent callers share one login via an in-flight promise. Refresh is deliberately unimplemented: the collection shows an unsigned refresh body but also ships unsigned examples of calls that do require signing, so an expired token re-logs in instead. Errors normalise to EimsApiException carrying only the gateway's own error fields; secrets, signature, certificate and tokens never reach logs. Key and certificate file patterns are gitignored. Nothing calls EIMS automatically and no invoice entity, migration or UI is touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
EimsMapperContext,
|
||||
EimsMapperInvoice,
|
||||
EimsSellerDetails,
|
||||
formatEimsDate,
|
||||
toEimsInvoice,
|
||||
} from "./eims-invoice.mapper";
|
||||
|
||||
const seller: EimsSellerDetails = {
|
||||
City: null,
|
||||
Email: "finance@edr.et",
|
||||
HouseNumber: null,
|
||||
LegalName: "Ethio-Djibouti Railway S.C.",
|
||||
Locality: null,
|
||||
Phone: "0911223344",
|
||||
Region: "13",
|
||||
SubCity: null,
|
||||
Tin: "0016324478",
|
||||
VatNumber: "3215840010",
|
||||
Wereda: "574",
|
||||
};
|
||||
|
||||
const invoice = (over: Partial<EimsMapperInvoice> = {}): EimsMapperInvoice => ({
|
||||
invoiceNumber: "INV-20260807-00042",
|
||||
currency: "ETB",
|
||||
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
|
||||
totalAmount: "11000.00",
|
||||
company: {
|
||||
name: "ABC Trading PLC",
|
||||
tin: "0999930000",
|
||||
vatNumber: "123475885858",
|
||||
phone: "0912345678",
|
||||
email: "buyer@abc.et",
|
||||
region: "13",
|
||||
zone: "SHA",
|
||||
woreda: "574",
|
||||
kebele: "03",
|
||||
houseNo: "NEW",
|
||||
country: "Ethiopia",
|
||||
},
|
||||
lines: [
|
||||
{ chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" },
|
||||
{ chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
||||
systemNumber: "B0360154BA",
|
||||
systemType: "SYS",
|
||||
documentNumber: "24",
|
||||
invoiceCounter: 7,
|
||||
previousIrn: "",
|
||||
cashierName: null,
|
||||
salesPersonName: null,
|
||||
transactionType: "B2B",
|
||||
payment: { mode: "CASH", term: "IMMIDIATE" },
|
||||
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
|
||||
natureOfSupplies: "Service",
|
||||
unitDefault: "PCS",
|
||||
incomeWithholdValue: 0,
|
||||
transactionWithholdValue: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("toEimsInvoice", () => {
|
||||
it("emits the ten EIMS sections with the collection's field names", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context());
|
||||
|
||||
expect(Object.keys(doc)).toEqual([
|
||||
"BuyerDetails",
|
||||
"DocumentDetails",
|
||||
"ItemList",
|
||||
"PaymentDetails",
|
||||
"ReferenceDetails",
|
||||
"SellerDetails",
|
||||
"SourceSystem",
|
||||
"TransactionType",
|
||||
"ValueDetails",
|
||||
"Version",
|
||||
]);
|
||||
expect(doc.Version).toBe("1");
|
||||
expect(doc.DocumentDetails).toEqual({ DocumentNumber: "24", Date: "07-08-2026T09:05:03", Type: "INV" });
|
||||
expect(doc.SourceSystem.InvoiceCounter).toBe(7);
|
||||
expect(doc.SellerDetails).toBe(seller);
|
||||
});
|
||||
|
||||
it("maps the buyer from the company row and leaves unmodelled fields null", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context());
|
||||
|
||||
expect(doc.BuyerDetails).toEqual({
|
||||
City: null,
|
||||
Email: "buyer@abc.et",
|
||||
HouseNumber: "NEW",
|
||||
IdNumber: null,
|
||||
IdType: null,
|
||||
Tin: "0999930000",
|
||||
LegalName: "ABC Trading PLC",
|
||||
Phone: "0912345678",
|
||||
Region: "13",
|
||||
Country: null,
|
||||
Zone: "SHA",
|
||||
Kebele: "03",
|
||||
VatNumber: "123475885858",
|
||||
Wereda: "574",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies per-line tax and totals it into ValueDetails", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice(),
|
||||
seller,
|
||||
context({
|
||||
taxForLine: (line) =>
|
||||
line.chargeType === "RAIL_FREIGHT"
|
||||
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
|
||||
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(doc.ItemList[0]).toMatchObject({
|
||||
LineNumber: 1,
|
||||
ItemCode: "RAIL_FREIGHT",
|
||||
ProductDescription: "Addis → Djibouti",
|
||||
Quantity: 1,
|
||||
UnitPrice: 10000,
|
||||
PreTaxValue: 10000,
|
||||
TaxCode: "VAT15",
|
||||
TaxAmount: 1500,
|
||||
ExciseTaxValue: 0,
|
||||
TotalLineAmount: 11500,
|
||||
Unit: "PCS",
|
||||
NatureOfSupplies: "Service",
|
||||
HarmonizationCode: null,
|
||||
});
|
||||
expect(doc.ItemList[1]).toMatchObject({
|
||||
LineNumber: 2,
|
||||
ProductDescription: "HAZARD_SURCHARGE",
|
||||
TaxCode: "EXEMPT",
|
||||
TaxAmount: 0,
|
||||
ExciseTaxValue: 50,
|
||||
TotalLineAmount: 1050,
|
||||
Unit: "CTR",
|
||||
});
|
||||
expect(doc.ValueDetails).toEqual({
|
||||
Discount: null,
|
||||
ExciseValue: 50,
|
||||
IncomeWithholdValue: 0,
|
||||
TaxValue: 1500,
|
||||
TotalValue: 12550,
|
||||
TransactionWithholdValue: 0,
|
||||
InvoiceCurrency: "ETB",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes PreviousIrn through verbatim and defaults RelatedDocument to null", () => {
|
||||
expect(toEimsInvoice(invoice(), seller, context()).ReferenceDetails).toEqual({
|
||||
PreviousIrn: "",
|
||||
RelatedDocument: null,
|
||||
});
|
||||
expect(
|
||||
toEimsInvoice(invoice(), seller, context({ previousIrn: null, relatedDocument: "CN-9" }))
|
||||
.ReferenceDetails,
|
||||
).toEqual({ PreviousIrn: null, RelatedDocument: "CN-9" });
|
||||
});
|
||||
|
||||
it("emits ExchangeRate only when supplied", () => {
|
||||
expect(toEimsInvoice(invoice(), seller, context()).ValueDetails.ExchangeRate).toBeUndefined();
|
||||
|
||||
const usd = toEimsInvoice(
|
||||
invoice({ currency: "USD" }),
|
||||
seller,
|
||||
context({ exchangeRate: 132.5 }),
|
||||
);
|
||||
expect(usd.ValueDetails).toMatchObject({ InvoiceCurrency: "USD", ExchangeRate: 132.5 });
|
||||
});
|
||||
|
||||
it("honours a caller-supplied date formatter", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context({ formatDate: () => "2026-08-07T09:05:03Z" }));
|
||||
expect(doc.DocumentDetails.Date).toBe("2026-08-07T09:05:03Z");
|
||||
});
|
||||
|
||||
it("throws when tax treatment cannot be resolved for a line", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice(),
|
||||
seller,
|
||||
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
|
||||
),
|
||||
).toThrow(/unresolved tax treatment for line 1/);
|
||||
});
|
||||
|
||||
it("throws on a missing buyer TIN, no lines, or an unissued invoice", () => {
|
||||
expect(() => toEimsInvoice(invoice({ company: null }), seller, context())).toThrow(/buyer company TIN/);
|
||||
expect(() => toEimsInvoice(invoice({ lines: [] }), seller, context())).toThrow(/has no lines/);
|
||||
expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/);
|
||||
});
|
||||
|
||||
it("throws when the lines do not sum to the invoice total", () => {
|
||||
expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow(
|
||||
/lines sum to 11000 but the invoice total is 9000/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on a non-ETB invoice with no exchange rate", () => {
|
||||
expect(() => toEimsInvoice(invoice({ currency: "USD" }), seller, context())).toThrow(/needs an exchangeRate/);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
362
apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
Normal file
362
apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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 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)) {
|
||||
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: 0,
|
||||
ExciseTaxValue,
|
||||
HarmonizationCode: null,
|
||||
NatureOfSupplies: context.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: company.region ?? null,
|
||||
Country: context.buyerCountryCode ?? null,
|
||||
Zone: company.zone ?? null,
|
||||
Kebele: company.kebele ?? null,
|
||||
VatNumber: company.vatNumber ?? null,
|
||||
Wereda: company.woreda ?? null,
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user