Credit and Debt plus reason attribute inide register

This commit is contained in:
Hagernesh
2026-08-14 13:51:00 +00:00
parent 0adbc096a1
commit 83d9265e85
7 changed files with 237 additions and 6 deletions

View File

@@ -157,6 +157,12 @@ export interface EimsContextInput {
session: EimsSessionContext;
/** Required when the invoice currency is not ETB. */
exchangeRate?: number | null;
/** `DocumentDetails.Type` — defaults to "INV" in the mapper when omitted. */
documentType?: EimsMapperContext["documentType"];
/** Required (by the mapper) when documentType is DEB/CRE. */
reason?: string | null;
/** `ReferenceDetails.RelatedDocument` — the original invoice's IRN, required for DEB/CRE. */
relatedDocument?: string | null;
}
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
@@ -206,5 +212,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,
exchangeRate: input.exchangeRate ?? null,
documentType: input.documentType,
reason: input.reason ?? null,
relatedDocument: input.relatedDocument ?? null,
};
}

View File

@@ -295,6 +295,58 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
});
it("files a credit note with Type/Reason/RelatedDocument from the invoice row", async () => {
const original = invoiceRow({
id: "original-invoice",
invoiceNumber: "INV-20260807-00001",
eimsIrn: IRN,
});
const db = new FakeDb([
invoiceRow({
eimsDocumentType: "CRE",
eimsReason: "Overbilled freight charge",
relatedInvoice: original,
} as Partial<Invoice>),
]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" });
expect(request.ReferenceDetails.RelatedDocument).toBe(IRN);
});
it("refuses a credit/debit note whose related invoice was never registered, before touching a counter", async () => {
const original = invoiceRow({ id: "original-invoice", eimsIrn: null });
const db = new FakeDb([
invoiceRow({
eimsDocumentType: "DEB",
eimsReason: "Additional handling",
relatedInvoice: original,
} as Partial<Invoice>),
]);
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({ nextInvoiceCounter: 7 }); // unchanged — never reserved
});
it("refuses a credit/debit note with no related invoice set at all", async () => {
const db = new FakeDb([
invoiceRow({ eimsDocumentType: "CRE", eimsReason: "x", relatedInvoice: null } as Partial<Invoice>),
]);
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(postSigned).not.toHaveBeenCalled();
});
it("takes SourceSystem from the token session, not from configuration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());

View File

@@ -13,6 +13,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import {
EimsDocumentType,
EimsInvoiceRequest,
EimsMapperLine,
toEimsInvoice,
@@ -96,6 +97,27 @@ export class EimsInvoiceRegistrationService {
const invoice = await this.loadInvoiceForMapping(invoiceId);
if (invoice.eimsIrn) return this.toView(invoice);
// Debit/credit notes (confirmed by MoR support: same endpoint, Type DEB/CRE + Reason,
// ReferenceDetails.RelatedDocument = the original's IRN) must fail here — before a counter is
// touched — if the original was never actually registered.
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
let relatedDocument: string | null = null;
if (documentType !== "INV") {
if (!invoice.relatedInvoice) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_REQUIRED",
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
});
}
if (!invoice.relatedInvoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
});
}
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
@@ -114,6 +136,9 @@ export class EimsInvoiceRegistrationService {
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
documentType,
reason: invoice.eimsReason,
relatedDocument,
}),
);
@@ -639,7 +664,7 @@ export class EimsInvoiceRegistrationService {
): Promise<Invoice & { lines: EimsMapperLine[] }> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId },
relations: { company: true, companyProfile: true },
relations: { company: true, companyProfile: true, relatedInvoice: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);