mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
eims integration master test complete
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user