eims integration master test complete

This commit is contained in:
Hagernesh
2026-08-12 14:08:28 +00:00
parent 8d53dc17ce
commit 2d4da8110b
38 changed files with 2270 additions and 173 deletions

View File

@@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
});
@@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
return { service, mg, events };
}
@@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
);
return { service, mg, events };
}
@@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never,
{} as never,
{} as never, // config
);
return { service, defaultManager, txManager, transaction };
};
@@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never,
{} as never,
{} as never, // config
);
return { service, manager };
};
@@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never,
{} as never,
{} as never,
{} as never, // config
);
return { service, repo };
};
@@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never,
{} as never,
{} as never,
{} as never, // config
);
return { service, repo };
};
@@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
});
});
});
describe("BillingService.document", () => {
const invoiceRow = (over: Record<string, unknown> = {}) => ({
id: "inv-1",
invoiceNumber: "INV-20260812-00001",
source: "booking",
sourceId: "booking-1",
status: Freight.InvoiceStatus.Pending,
type: "freight",
currency: "ETB",
subtotalAmount: 100,
taxAmount: 0,
totalAmount: 100,
paidAmount: 0,
balanceAmount: 100,
issuedAt: new Date(2026, 7, 12),
dueAt: new Date(2026, 7, 19),
eimsIrn: null,
eimsSignedQr: null,
company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
...over,
});
const build = (invoice: Record<string, unknown>) => {
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
const service = new BillingService(
{} as never,
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
{ findAll: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{} as never,
{} as never,
{ render } as never,
{} as never,
{
get: (key: string) =>
key === "eims"
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
: undefined,
} as never, // config
);
return { service, render };
};
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
expect(model.qrImageUrl).toBeNull();
});
it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
});
it("omits the VAT row when the buyer company has none", async () => {
const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
});
it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
expect(model.summary).toContainEqual({
label: "Seller VAT No.",
value: "43256663343256663322",
});
});
it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
const { service, render } = build(
invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
);
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
});
});

View File

@@ -1,4 +1,5 @@
import { Freight, PaymentReferenceType } from "@edr/types";
import { ConfigService } from "@nestjs/config";
import {
BadRequestException,
forwardRef,
@@ -12,6 +13,7 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
@@ -161,6 +163,7 @@ export class BillingService {
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
private readonly config: ConfigService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -384,7 +387,7 @@ export class BillingService {
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
await this.toDocumentModel(invoice, "INVOICE"),
);
}
@@ -397,15 +400,24 @@ export class BillingService {
);
}
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
await this.toDocumentModel(invoice, "RECEIPT"),
);
}
/**
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
*/
private renderEimsQr(signedQr: string): string {
return `data:image/png;base64,${signedQr}`;
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel(
private async toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
): Promise<InvoiceDocumentModel> {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
@@ -423,6 +435,43 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
const summary: InvoiceDocumentModel["summary"] = [
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
// filed against, not just the seller. VatNumber shown only when the company has one.
{ label: "Buyer", value: invoice.company?.name ?? null },
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
...(invoice.company?.vatNumber
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
: []),
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
];
// Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
// codebase). Shown only when actually configured, same as the buyer VAT row.
const eimsCfg = this.config.get<EimsConfig>("eims");
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
if (eimsCfg?.invoice?.sellerVatNumber) {
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
}
// MoR EIMS reference — only once actually registered, never a placeholder row.
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
return {
kind,
title,
@@ -430,24 +479,7 @@ export class BillingService {
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
],
summary,
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
@@ -458,6 +490,7 @@ export class BillingService {
currency: l.currency,
})),
totals,
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
};
}

View File

@@ -0,0 +1,46 @@
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
kind: "INVOICE",
title: "Freight",
documentNumber: "INV-20260812-00001",
issuedAt: new Date(2026, 7, 12),
status: "PENDING",
currency: "ETB",
summary: [{ label: "Status", value: "PENDING" }],
lines: [],
totals: [{ label: "Total", amount: 100, grand: true }],
...over,
});
describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
const service = new InvoiceDocumentService({} as never, {} as never);
it("renders no QR block when qrImageUrl is unset", () => {
const html = service.buildHtml(model());
expect(html).not.toContain('class="qr"');
});
it("renders the QR image when qrImageUrl is set", () => {
const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
expect(html).toContain('class="qr"');
expect(html).toContain('src="data:image/png;base64,QR"');
});
it("still shows the IRN text row via the ordinary summary grid", () => {
const html = service.buildHtml(
model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }),
);
expect(html).toContain("EIMS IRN");
expect(html).toContain("IRN-123");
});
it("widens the summary's right margin only when a QR is present, to clear the QR block", () => {
// "summary-with-qr" also appears in the always-present <style> rule, so the check has to be
// the actual div's class attribute, not a bare substring match.
expect(service.buildHtml(model())).not.toContain('class="summary summary-with-qr"');
expect(service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }))).toContain(
'class="summary summary-with-qr"',
);
});
});

View File

@@ -62,6 +62,12 @@ export interface InvoiceDocumentModel {
* explicitly only to override that default for one document.
*/
stampImageUrl?: string | null;
/**
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
* see that column's comment). Set only once an invoice is actually registered; the IRN text
* itself goes through the ordinary `summary` rows, not a dedicated field.
*/
qrImageUrl?: string | null;
}
/**
@@ -96,9 +102,11 @@ export class InvoiceDocumentService {
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
// ponytail: still draws the plain vector seal, not the uploaded stamp
// image — embedding a raster image needs a new PDF XObject primitive
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
// carry the real stamp too; today it's a rare degraded fallback.
// image, and omits the EIMS QR entirely — embedding a raster image
// needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
// when the Chromium-less path needs to carry the real stamp/QR too;
// today it's a rare degraded fallback. The IRN text itself still
// comes through (buildFallbackPdf renders model.summary same as HTML).
fallback: () => this.buildFallbackPdf(resolvedModel),
}),
};
@@ -243,6 +251,10 @@ export class InvoiceDocumentService {
const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl);
const qrMarkup = model.qrImageUrl
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
: "";
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
.join("");
@@ -281,8 +293,19 @@ export class InvoiceDocumentService {
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
${sealImageCss()}
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
.qr img { width: 90px; height: 90px; }
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
.summary.summary-with-qr { margin-right: 270px; }
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
the text itself was still escaping the box's own right edge until this was added). */
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
@@ -309,7 +332,8 @@ export class InvoiceDocumentService {
</div>
</div>
<div class="${sealCssClass}">${sealInner}</div>
<div class="summary">${summaryRows}</div>
${qrMarkup}
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
<table>
<thead>
<tr>

View File

@@ -55,7 +55,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
salesPersonName: null,
transactionType: "B2B",
payment: { mode: "CASH", term: "IMMIDIATE" },
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }),
natureOfSupplies: "Service",
unitDefault: "PCS",
incomeWithholdValue: 0,
@@ -115,8 +115,8 @@ describe("toEimsInvoice", () => {
context({
taxForLine: (line) =>
line.chargeType === "RAIL_FREIGHT"
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50, discount: 25 },
}),
);
@@ -130,6 +130,7 @@ describe("toEimsInvoice", () => {
TaxCode: "VAT15",
TaxAmount: 1500,
ExciseTaxValue: 0,
Discount: 0,
TotalLineAmount: 11500,
Unit: "PCS",
NatureOfSupplies: "service",
@@ -141,6 +142,9 @@ describe("toEimsInvoice", () => {
TaxCode: "EXEMPT",
TaxAmount: 0,
ExciseTaxValue: 50,
// Discount is carried on the line but does not (yet) reduce TotalLineAmount — see the
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
Discount: 25,
TotalLineAmount: 1050,
Unit: "CTR",
});
@@ -187,7 +191,17 @@ describe("toEimsInvoice", () => {
toEimsInvoice(
invoice(),
seller,
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0, discount: 0 }) }),
),
).toThrow(/unresolved tax treatment for line 1/);
expect(() =>
toEimsInvoice(
invoice(),
seller,
context({
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: NaN }),
}),
),
).toThrow(/unresolved tax treatment for line 1/);
});

View File

@@ -183,6 +183,12 @@ 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 {
@@ -336,7 +342,13 @@ export function toEimsInvoice(
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)) {
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}`,
@@ -349,7 +361,7 @@ export function toEimsInvoice(
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
return {
Discount: 0,
Discount: round2(tax.discount),
ExciseTaxValue,
HarmonizationCode: null,
NatureOfSupplies: natureOfSupplies,

View File

@@ -111,10 +111,22 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
eimsStatus!: EimsInvoiceStatus;
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
/**
* Invoice Reference Number returned by EIMS. Unique across invoices (partial index). `text`,
* not a fixed varchar — MoR has never documented an IRN format/length, and a real live value
* (a `test-` prefix + 64 hex chars, 69 chars total) already overflowed a prior varchar(64).
*/
@Column({ name: "eims_irn", type: "text", nullable: true })
eimsIrn?: string | null;
/**
* `signedQR` from the register response — a base64 PNG image, already rendered by MoR (confirmed
* against the Postman collection's saved response: decodes to a PNG magic-byte header). Stored
* verbatim; `BillingService.renderEimsQr` only wraps it in a `data:image/png;base64,` URL.
*/
@Column({ name: "eims_signed_qr", type: "text", nullable: true })
eimsSignedQr?: string | null;
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
@@ -133,4 +145,24 @@ export class Invoice extends BaseEntity {
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
eimsLastError?: EimsInvoiceError | null;
/**
* `POST /v1/cancel` — set together, only once `eimsStatus` reaches CANCELLED.
* `eimsCancelledAt` is our own server time (same convention as `eimsSubmittedAt`);
* `eimsCancellationDate` is MoR's own confirmation string, stored verbatim like `eimsAckDate` —
* its format (`"Sun Dec 22 21:55:03 EAT 2024"`, a Java `Date#toString()`) is not reliably
* `Date.parse`-able (the `EAT` zone abbreviation is non-standard), so it is never parsed.
*/
@Column({ name: "eims_cancelled_at", type: "timestamptz", nullable: true })
eimsCancelledAt?: Date | null;
@Column({ name: "eims_cancellation_date", type: "varchar", length: 64, nullable: true })
eimsCancellationDate?: string | null;
/** Numeric string per the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
@Column({ name: "eims_cancellation_reason_code", type: "varchar", length: 8, nullable: true })
eimsCancellationReasonCode?: string | null;
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
eimsCancellationRemark?: string | null;
}