mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 18:13:27 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -29,6 +30,7 @@ import { actorLabel } from "../warehouses/current-actor.util";
|
||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
import { IssueMemoDto } from "./dto/issue-memo.dto";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@@ -38,6 +40,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
FREIGHT_PERMS.invoices.memoIssue,
|
||||
])
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
@@ -116,11 +119,31 @@ export class BillingController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post("invoices/:id/memo")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.memoIssue)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.",
|
||||
})
|
||||
issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) {
|
||||
return this.billingService.issueMemo(id, dto);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.document(id);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).',
|
||||
})
|
||||
async document(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("format") format: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (format !== undefined && format !== "a4" && format !== "thermal") {
|
||||
throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`);
|
||||
}
|
||||
const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4");
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,159 @@ describe("BillingService.generateInvoice", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.issueMemo", () => {
|
||||
const ORIGINAL_ID = "original-invoice-1";
|
||||
|
||||
function originalInvoice(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: ORIGINAL_ID,
|
||||
invoiceNumber: "INV-20260807-00042",
|
||||
eimsIrn: "irn-value",
|
||||
eimsDocumentType: "INV",
|
||||
eimsStatus: "REGISTERED",
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
companyId: "company-1",
|
||||
companyProfileId: "profile-1",
|
||||
shippingLineCompanyId: null,
|
||||
currency: "ETB",
|
||||
totalAmount: 1500,
|
||||
lines: [
|
||||
{ chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null },
|
||||
{ chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function build(original: ReturnType<typeof originalInvoice>) {
|
||||
const savedLines: unknown[] = [];
|
||||
const manager = makeManager(savedLines);
|
||||
const dataSource = {
|
||||
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
};
|
||||
const invoices = { findById: jest.fn().mockResolvedValue(original) };
|
||||
const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) };
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
invoices as never,
|
||||
invoiceLines as never,
|
||||
makeEvents() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ get: () => undefined } as never,
|
||||
);
|
||||
return { service, manager, savedLines };
|
||||
}
|
||||
|
||||
it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => {
|
||||
const { service, savedLines } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" });
|
||||
|
||||
expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/);
|
||||
expect(memo.totalAmount).toBe(1500);
|
||||
expect(memo.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect((memo as unknown as Record<string, unknown>).eimsDocumentType).toBe("CRE");
|
||||
expect((memo as unknown as Record<string, unknown>).eimsReason).toBe("Overbilled freight charge");
|
||||
expect((memo as unknown as Record<string, unknown>).relatedInvoiceId).toBe(ORIGINAL_ID);
|
||||
expect((memo as unknown as Record<string, unknown>).paidAmount).toBe(1500);
|
||||
expect((memo as unknown as Record<string, unknown>).balanceAmount).toBe(0);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" });
|
||||
|
||||
expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/);
|
||||
expect(memo.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
expect(memo.balanceAmount).toBe(1500);
|
||||
expect(memo.paidAmount).toBe(0);
|
||||
});
|
||||
|
||||
it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" });
|
||||
|
||||
expect(memo.sourceId).toBe(ORIGINAL_ID);
|
||||
expect(memo.sourceId).not.toBe("booking-1");
|
||||
});
|
||||
|
||||
it("allows a partial memo with explicit lines instead of copying the original", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, {
|
||||
type: "CRE",
|
||||
reason: "Partial credit",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }],
|
||||
});
|
||||
|
||||
expect(memo.totalAmount).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses a memo against an invoice never registered with EIMS", async () => {
|
||||
const { service } = build(originalInvoice({ eimsIrn: null }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a memo against a memo", async () => {
|
||||
const { service } = build(originalInvoice({ eimsDocumentType: "CRE" }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow(
|
||||
"cannot issue a memo against a memo",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a memo against an EIMS-cancelled invoice", async () => {
|
||||
const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" }));
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow(
|
||||
"cancelled with EIMS",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a credit memo whose total exceeds the original", async () => {
|
||||
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
||||
|
||||
await expect(
|
||||
service.issueMemo(ORIGINAL_ID, {
|
||||
type: "CRE",
|
||||
reason: "too much",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }],
|
||||
}),
|
||||
).rejects.toThrow(/exceeds/);
|
||||
});
|
||||
|
||||
it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => {
|
||||
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
||||
|
||||
const memo = await service.issueMemo(ORIGINAL_ID, {
|
||||
type: "DEB",
|
||||
reason: "additional charge",
|
||||
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }],
|
||||
});
|
||||
|
||||
expect(memo.totalAmount).toBe(5000);
|
||||
});
|
||||
|
||||
it("refuses a blank reason", async () => {
|
||||
const { service } = build(originalInvoice());
|
||||
|
||||
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow(
|
||||
"requires a reason",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.markInvoiceAsPaid", () => {
|
||||
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
@@ -774,6 +927,7 @@ describe("BillingService.document", () => {
|
||||
|
||||
const build = (invoice: Record<string, unknown>) => {
|
||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||
const service = new BillingService(
|
||||
{} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||
@@ -781,7 +935,7 @@ describe("BillingService.document", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ render } as never,
|
||||
{ render, renderThermal } as never,
|
||||
{} as never,
|
||||
{
|
||||
get: (key: string) =>
|
||||
@@ -790,7 +944,7 @@ describe("BillingService.document", () => {
|
||||
: undefined,
|
||||
} as never, // config
|
||||
);
|
||||
return { service, render };
|
||||
return { service, render, renderThermal };
|
||||
};
|
||||
|
||||
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
||||
@@ -847,4 +1001,24 @@ describe("BillingService.document", () => {
|
||||
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||
});
|
||||
|
||||
it("calls render (not renderThermal) for the default format", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
expect(renderThermal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls renderThermal (not render) for format 'thermal'", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
|
||||
await service.document("inv-1", "thermal");
|
||||
|
||||
expect(renderThermal).toHaveBeenCalledTimes(1);
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
@@ -25,6 +26,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
@@ -145,6 +147,18 @@ export interface GenerateInvoiceInput {
|
||||
status?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
|
||||
export type MemoType = "CRE" | "DEB";
|
||||
|
||||
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
|
||||
export interface IssueMemoInput {
|
||||
type: MemoType;
|
||||
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
|
||||
reason: string;
|
||||
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
|
||||
lines?: InvoiceLineInput[];
|
||||
}
|
||||
|
||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||
export interface InvoiceEventPayload {
|
||||
invoiceId: string;
|
||||
@@ -481,12 +495,20 @@ export class BillingService {
|
||||
|
||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||
|
||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
/**
|
||||
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
|
||||
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
|
||||
* boundary check, not a business rule.
|
||||
*/
|
||||
async document(
|
||||
id: string,
|
||||
format: "a4" | "thermal" = "a4",
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(
|
||||
await this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
const model = await this.toDocumentModel(invoice, "INVOICE");
|
||||
return format === "thermal"
|
||||
? this.invoiceDocuments.renderThermal(model)
|
||||
: this.invoiceDocuments.render(model);
|
||||
}
|
||||
|
||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||
@@ -502,15 +524,6 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `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}`;
|
||||
}
|
||||
|
||||
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
|
||||
private async bookingSummaryRows(
|
||||
invoice: Invoice,
|
||||
@@ -626,7 +639,7 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -793,11 +806,16 @@ export class BillingService {
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
/**
|
||||
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
|
||||
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
|
||||
* daily sequence (different prefix hashes to a different advisory lock, see
|
||||
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
|
||||
*/
|
||||
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, {
|
||||
table: "freight.invoices",
|
||||
code: "INV",
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -820,9 +838,123 @@ export class BillingService {
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
|
||||
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
|
||||
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
|
||||
* unchanged: it has no side effects (no events, no notifications, no payment records — every
|
||||
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
|
||||
* is just an ordinary invoice with three extra columns set.
|
||||
*
|
||||
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
|
||||
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
|
||||
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
|
||||
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
|
||||
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
|
||||
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
|
||||
*
|
||||
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
|
||||
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
|
||||
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
|
||||
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
|
||||
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
|
||||
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
|
||||
*/
|
||||
async issueMemo(
|
||||
originalId: string,
|
||||
input: IssueMemoInput,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const reason = input.reason?.trim();
|
||||
if (!reason) {
|
||||
throw new BadRequestException("A memo requires a reason.");
|
||||
}
|
||||
|
||||
const original = await this.findById(originalId);
|
||||
if (!original.eimsIrn) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
||||
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
|
||||
});
|
||||
}
|
||||
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
|
||||
);
|
||||
}
|
||||
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException(
|
||||
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceLines = input.lines?.length ? input.lines : original.lines;
|
||||
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
|
||||
chargeType: l.chargeType,
|
||||
description: l.description,
|
||||
quantity: Number(l.quantity),
|
||||
unitRate: Number(l.unitRate),
|
||||
amount: Number(l.amount),
|
||||
currency: l.currency,
|
||||
metadata: l.metadata ?? null,
|
||||
}));
|
||||
|
||||
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||
if (!(total > 0)) {
|
||||
throw new BadRequestException("A memo must have a positive total.");
|
||||
}
|
||||
// Only a credit note is bounded by the original — it can only give back what was charged. A
|
||||
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
|
||||
// assume the credit-note ceiling is correct for DEB).
|
||||
if (input.type === "CRE" && total > Number(original.totalAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = input.type === "CRE" ? "CRE" : "DEB";
|
||||
const settled = input.type === "CRE";
|
||||
|
||||
return this.dataSource.transaction(async (mg) => {
|
||||
const memo = await this.createInvoice(
|
||||
{
|
||||
source: original.source as Freight.InvoiceSource,
|
||||
sourceId: original.id,
|
||||
type: input.type === "CRE" ? "credit_note" : "debit_note",
|
||||
companyId: original.companyId,
|
||||
companyProfileId: original.companyProfileId,
|
||||
shippingLineCompanyId: original.shippingLineCompanyId,
|
||||
lines,
|
||||
currency: original.currency,
|
||||
subtotalAmount: total,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||
},
|
||||
mg,
|
||||
code,
|
||||
);
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
eimsDocumentType: input.type,
|
||||
eimsReason: reason,
|
||||
relatedInvoiceId: original.id,
|
||||
...(settled
|
||||
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, memo.id, patch);
|
||||
|
||||
this.logger.log(
|
||||
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
|
||||
);
|
||||
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
|
||||
});
|
||||
}
|
||||
|
||||
private async createInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
mg: EntityManager,
|
||||
code = "INV",
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const currency = input.currency ?? "ETB";
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
@@ -870,7 +1002,7 @@ export class BillingService {
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||
|
||||
const invoice = await mg.save(
|
||||
mg.create(Invoice, {
|
||||
|
||||
@@ -44,3 +44,46 @@ describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvoiceDocumentService.buildThermalHtml", () => {
|
||||
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
||||
|
||||
it("renders no seal markup at all — dropped for thermal, not shrunk", () => {
|
||||
const html = service.buildThermalHtml(model());
|
||||
expect(html).not.toContain('class="seal"');
|
||||
expect(html).not.toContain("seal-image");
|
||||
});
|
||||
|
||||
it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => {
|
||||
const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
||||
expect(html).toContain('class="qr"');
|
||||
expect(html).toContain('src="data:image/png;base64,QR"');
|
||||
expect(html).not.toContain("position: absolute");
|
||||
});
|
||||
|
||||
it("wraps a long IRN summary value rather than truncating it", () => {
|
||||
const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||
const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] }));
|
||||
expect(html).toContain(irn);
|
||||
expect(html).toContain("overflow-wrap: anywhere");
|
||||
});
|
||||
|
||||
it("renders a line item as stacked description + qty x rate = amount, not a table row", () => {
|
||||
const html = service.buildThermalHtml(
|
||||
model({
|
||||
lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }],
|
||||
}),
|
||||
);
|
||||
expect(html).not.toContain("<table");
|
||||
expect(html).not.toContain("<td");
|
||||
expect(html).toContain("40ft container rail freight");
|
||||
expect(html).toContain("12 x");
|
||||
expect(html).toContain("2,948,207.4 Birr (ETB)");
|
||||
});
|
||||
|
||||
it("uses fluid, full-width layout — no fixed-px A4 geometry", () => {
|
||||
const html = service.buildThermalHtml(model());
|
||||
expect(html).not.toContain("width: 330px");
|
||||
expect(html).not.toContain("right: 160px");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,36 @@ import {
|
||||
|
||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
|
||||
/**
|
||||
* MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the
|
||||
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a
|
||||
* payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr`
|
||||
* (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention,
|
||||
* same gateway.
|
||||
*/
|
||||
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
|
||||
|
||||
// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ──────────────────────────────────
|
||||
// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already-
|
||||
// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here.
|
||||
|
||||
function esc(value: unknown): string {
|
||||
return String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function money(amount: unknown, currency: string): string {
|
||||
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
}
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
@@ -120,6 +150,117 @@ export class InvoiceDocumentService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within
|
||||
* `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since
|
||||
* thermal print mechanisms have a dead zone at the roll edge they can't reach either way.
|
||||
*
|
||||
* A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is
|
||||
* absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`,
|
||||
* `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width.
|
||||
* No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS
|
||||
* thermal receipt carries one, and thermal heads render rotated circles badly) and line items
|
||||
* are stacked (description, then `qty x rate = amount` below it) rather than a table — a real
|
||||
* multi-column table leaves ~10-14 chars for description at this width, truncating almost every
|
||||
* line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`.
|
||||
*/
|
||||
async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const logoImageUrl =
|
||||
model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl();
|
||||
// Seal deliberately dropped — never fetched, so no stampSettings call either.
|
||||
const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null };
|
||||
|
||||
const html = this.buildThermalHtml(resolvedModel);
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||
label: `${model.title} thermal invoice`,
|
||||
thermal: true,
|
||||
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||
// printer output" — fail loudly instead; the caller has the A4 download to fall back to.
|
||||
noFallback: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
buildThermalHtml(model: InvoiceDocumentModel): string {
|
||||
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||
const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo");
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map(
|
||||
(row) =>
|
||||
`<div class="row"><span class="label">${esc(row.label)}</span><span class="value">${esc(row.value)}</span></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const itemBlocks = model.lines
|
||||
.map((item) => {
|
||||
const currency = item.currency ?? model.currency;
|
||||
return `<div class="item">
|
||||
<div class="item-desc">${esc(item.description)}</div>
|
||||
<div class="item-calc">${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = <strong>${esc(money(item.amount, currency))}</strong></div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const qrMarkup = model.qrImageUrl
|
||||
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(heading)}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; font-size: 9px; color: #0f172a; margin: 0; }
|
||||
.doc { width: 100%; box-sizing: border-box; }
|
||||
.thermal-logo { display: block; max-height: 28px; max-width: 100%; object-fit: contain; margin: 0 auto 4px; }
|
||||
.brand { text-align: center; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.title { text-align: center; font-size: 13px; font-weight: 800; margin: 2px 0; }
|
||||
.meta { text-align: center; font-size: 8px; color: #475569; margin-bottom: 4px; }
|
||||
.rule { border-top: 1px dashed #334155; margin: 6px 0; }
|
||||
.row { display: flex; justify-content: space-between; gap: 6px; font-family: monospace; font-size: 8.5px; padding: 1px 0; }
|
||||
.row .label { color: #64748b; white-space: nowrap; }
|
||||
.row .value { text-align: right; overflow-wrap: anywhere; }
|
||||
.item { margin: 4px 0; }
|
||||
.item-desc { font-size: 9px; overflow-wrap: anywhere; }
|
||||
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
|
||||
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
|
||||
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
|
||||
.qr { text-align: center; margin: 8px 0; }
|
||||
.qr img { width: 150px; height: 150px; }
|
||||
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
|
||||
.footer { text-align: center; font-size: 7px; color: #94a3b8; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
${logoInner}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<div class="title">${esc(heading)}</div>
|
||||
<div class="meta">${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}</div>
|
||||
<div class="rule"></div>
|
||||
${summaryRows}
|
||||
<div class="rule"></div>
|
||||
${itemBlocks}
|
||||
<div class="rule"></div>
|
||||
${totalRows}
|
||||
${qrMarkup}
|
||||
<div class="footer">Thank you</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||
@@ -241,18 +382,7 @@ export class InvoiceDocumentService {
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const money = (amount: unknown, currency = model.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const date = formatDate;
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
@@ -283,7 +413,7 @@ export class InvoiceDocumentService {
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
|
||||
@@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = `
|
||||
}
|
||||
</style>`;
|
||||
|
||||
/**
|
||||
* Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream
|
||||
* ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll
|
||||
* it physically can't reach, so the page itself must stay 80mm (matching the roll the printer
|
||||
* driver expects) with the safe area carved out by margin, not by shrinking the page.
|
||||
*/
|
||||
const THERMAL_PAGE_WIDTH_MM = 80;
|
||||
const THERMAL_MARGIN_MM = 4;
|
||||
/** Extra length past the measured content, so the cut isn't flush against the last line. */
|
||||
const THERMAL_FEED_MM = 6;
|
||||
/** Guard against a runaway line-item list producing an absurd page. */
|
||||
const THERMAL_MAX_HEIGHT_MM = 1500;
|
||||
|
||||
export interface PdfRenderOptions {
|
||||
/** Label used in logs to identify the document kind. */
|
||||
label?: string;
|
||||
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
||||
landscape?: boolean;
|
||||
/**
|
||||
* Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is
|
||||
* measured and the page height grows to fit it, rather than a fixed page with the format's
|
||||
* `format: "A4"`.
|
||||
*/
|
||||
thermal?: boolean;
|
||||
/**
|
||||
* Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a
|
||||
* generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer
|
||||
* output" (it silently hands back a different document shape than what was asked for); the
|
||||
* caller has an existing A4 download to point the user at instead. Ignored when `fallback` is
|
||||
* also supplied — an explicit fallback always wins.
|
||||
*/
|
||||
noFallback?: boolean;
|
||||
/**
|
||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||
* header). When omitted, a generic single-page fallback is produced.
|
||||
* header). When omitted (and `noFallback` is not set), a generic single-page fallback is
|
||||
* produced.
|
||||
*/
|
||||
fallback?: (preparedHtml: string) => Buffer;
|
||||
}
|
||||
@@ -54,17 +82,31 @@ export class PdfRenderService {
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
const thermal = opts.thermal ?? false;
|
||||
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
||||
await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||
await page.emulateMediaType("print");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
landscape: opts.landscape ?? false,
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
const pdf = thermal
|
||||
? await page.pdf({
|
||||
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
|
||||
height: `${await this.thermalContentHeightMm(page)}mm`,
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: `${THERMAL_MARGIN_MM}mm`,
|
||||
bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`,
|
||||
left: `${THERMAL_MARGIN_MM}mm`,
|
||||
right: `${THERMAL_MARGIN_MM}mm`,
|
||||
},
|
||||
})
|
||||
: await page.pdf({
|
||||
format: "A4",
|
||||
landscape: opts.landscape ?? false,
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
@@ -79,6 +121,15 @@ export class PdfRenderService {
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||
if (!opts.fallback && opts.noFallback) {
|
||||
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||
// printer output" — it silently hands back a different document than what was asked for.
|
||||
// Fail loudly instead; the caller already has a working A4 download to fall back to.
|
||||
throw new InternalServerErrorException(
|
||||
`${label} could not be generated — thermal rendering requires Chromium. ` +
|
||||
"Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.",
|
||||
);
|
||||
}
|
||||
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
@@ -92,6 +143,20 @@ export class PdfRenderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered
|
||||
* content's actual height and adds feed clearance, so the PDF page is exactly as long as the
|
||||
* receipt, not a fixed A4-length page with blank space at the bottom.
|
||||
*/
|
||||
private async thermalContentHeightMm(page: import("puppeteer").Page): Promise<number> {
|
||||
// String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document`
|
||||
// isn't a known global to type-check against — the string is evaluated in the page's own
|
||||
// browser context regardless, same as the closure form would be.
|
||||
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
||||
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
||||
return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm);
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes("edr-pdf-print-fix")) return html;
|
||||
if (html.includes("</head>")) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
/** One line on a memo; omit the whole `lines` array on the parent DTO to copy the original's. */
|
||||
export class MemoLineDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
chargeType!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
unitRate?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
amount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** `POST billing/invoices/:id/memo` body — see `BillingService.issueMemo`. */
|
||||
export class IssueMemoDto {
|
||||
@ApiProperty({ enum: ["CRE", "DEB"], description: "MoR DocumentDetails.Type for the memo." })
|
||||
@IsIn(["CRE", "DEB"])
|
||||
type!: "CRE" | "DEB";
|
||||
|
||||
@ApiProperty({ description: "Why the memo was issued — MoR DocumentDetails.Reason." })
|
||||
@IsString()
|
||||
@Length(1, 500)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [MemoLineDto],
|
||||
description: "Omit to copy every line of the original invoice verbatim.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MemoLineDto)
|
||||
lines?: MemoLineDto[];
|
||||
}
|
||||
@@ -212,6 +212,59 @@ describe("toEimsInvoice", () => {
|
||||
expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/);
|
||||
});
|
||||
|
||||
describe("debit/credit notes — confirmed by MoR support, same /v1/register endpoint", () => {
|
||||
it("defaults DocumentDetails.Type to INV with no Reason field", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context());
|
||||
expect(doc.DocumentDetails.Type).toBe("INV");
|
||||
expect(doc.DocumentDetails).not.toHaveProperty("Reason");
|
||||
});
|
||||
|
||||
it("files a credit note with Type, Reason and RelatedDocument", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice(),
|
||||
seller,
|
||||
context({
|
||||
documentType: "CRE",
|
||||
reason: "Overbilled freight charge",
|
||||
relatedDocument: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||
}),
|
||||
);
|
||||
expect(doc.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" });
|
||||
expect(doc.ReferenceDetails.RelatedDocument).toBe(
|
||||
"9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||
);
|
||||
});
|
||||
|
||||
it("files a debit note the same way", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice(),
|
||||
seller,
|
||||
context({ documentType: "DEB", reason: "Additional handling fee", relatedDocument: "IRN-1" }),
|
||||
);
|
||||
expect(doc.DocumentDetails).toMatchObject({ Type: "DEB", Reason: "Additional handling fee" });
|
||||
});
|
||||
|
||||
it("throws when a credit/debit note has no reason", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice(),
|
||||
seller,
|
||||
context({ documentType: "CRE", reason: null, relatedDocument: "IRN-1" }),
|
||||
),
|
||||
).toThrow(/needs a reason/);
|
||||
});
|
||||
|
||||
it("throws when a credit/debit note has no relatedDocument", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice(),
|
||||
seller,
|
||||
context({ documentType: "CRE", reason: "Overbilled", relatedDocument: null }),
|
||||
),
|
||||
).toThrow(/needs.*relatedDocument/);
|
||||
});
|
||||
});
|
||||
|
||||
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/,
|
||||
|
||||
@@ -20,8 +20,15 @@ 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";
|
||||
/**
|
||||
* `DocumentDetails.Type`. `"INV"` is the only value observed in the collection; `"DEB"`/`"CRE"`
|
||||
* (debit/credit note) were confirmed directly by MoR support — same `/v1/register` endpoint, no
|
||||
* separate API. MoR's answer, verbatim: "the same endpoint used for registration should be used
|
||||
* ... within the Document Detail object, you should specify DEB for a debit note, CRE for a
|
||||
* credit note... add a Reason attribute under document detail object".
|
||||
*/
|
||||
export const EIMS_DOCUMENT_TYPES = ["INV", "DEB", "CRE"] as const;
|
||||
export type EimsDocumentType = (typeof EIMS_DOCUMENT_TYPES)[number];
|
||||
|
||||
export interface EimsBuyerDetails {
|
||||
City: string | null;
|
||||
@@ -60,7 +67,9 @@ 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;
|
||||
Type: EimsDocumentType;
|
||||
/** Only for DEB/CRE, per MoR support — why the debit/credit note was issued. Absent for INV. */
|
||||
Reason?: string;
|
||||
}
|
||||
|
||||
export interface EimsInvoiceItem {
|
||||
@@ -212,7 +221,18 @@ export interface EimsMapperContext {
|
||||
unitDefault: string;
|
||||
incomeWithholdValue: number;
|
||||
transactionWithholdValue: number;
|
||||
/** Null for an ordinary invoice; set only for a real related-document case. */
|
||||
/**
|
||||
* `DocumentDetails.Type`. Defaults to `"INV"`. For `"DEB"`/`"CRE"` both `reason` and
|
||||
* `relatedDocument` become required — confirmed directly by MoR support, not the collection.
|
||||
*/
|
||||
documentType?: EimsDocumentType;
|
||||
/** Required when `documentType` is `"DEB"`/`"CRE"` — why the note was issued. Unused for INV. */
|
||||
reason?: string | null;
|
||||
/**
|
||||
* `ReferenceDetails.RelatedDocument`. Null for an ordinary invoice; required for a DEB/CRE —
|
||||
* the original registered invoice's IRN, per MoR's own IRC-P06/P07 checklist ("credit memo
|
||||
* from a registered invoice").
|
||||
*/
|
||||
relatedDocument?: string | null;
|
||||
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
||||
buyerCountryCode?: string | null;
|
||||
@@ -326,6 +346,26 @@ export function toEimsInvoice(
|
||||
);
|
||||
}
|
||||
|
||||
const documentType = context.documentType ?? "INV";
|
||||
if (!EIMS_DOCUMENT_TYPES.includes(documentType)) {
|
||||
throw new Error(
|
||||
`EIMS mapping: invoice ${invoice.invoiceNumber} has documentType "${documentType}", must be one of ${EIMS_DOCUMENT_TYPES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (documentType !== "INV") {
|
||||
if (!context.reason?.trim()) {
|
||||
throw new Error(
|
||||
`EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs a reason`,
|
||||
);
|
||||
}
|
||||
if (!context.relatedDocument?.trim()) {
|
||||
throw new Error(
|
||||
`EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs ` +
|
||||
"relatedDocument — the original registered invoice's IRN",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -430,7 +470,8 @@ export function toEimsInvoice(
|
||||
DocumentDetails: {
|
||||
DocumentNumber: context.documentNumber,
|
||||
Date: (context.formatDate ?? formatEimsDate)(issuedAt),
|
||||
Type: EIMS_DOCUMENT_TYPE,
|
||||
Type: documentType,
|
||||
...(documentType !== "INV" ? { Reason: context.reason! } : {}),
|
||||
},
|
||||
ItemList,
|
||||
PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term },
|
||||
|
||||
@@ -180,4 +180,27 @@ export class Invoice extends BaseEntity {
|
||||
|
||||
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
|
||||
eimsCancellationRemark?: string | null;
|
||||
|
||||
/**
|
||||
* `DocumentDetails.Type` to file this invoice as — "INV" (default), "DEB" or "CRE". Confirmed
|
||||
* by MoR support directly (not the collection): debit/credit notes go through this same
|
||||
* `/v1/register` endpoint, distinguished only by `Type` + `Reason`, linked via
|
||||
* `ReferenceDetails.RelatedDocument` to the original invoice's IRN. This module does not create
|
||||
* debit/credit note invoices — that is a freight-workflow decision — it only files one
|
||||
* correctly once these columns are set on an existing row.
|
||||
*/
|
||||
@Column({ name: "eims_document_type", type: "varchar", length: 8, default: "INV" })
|
||||
eimsDocumentType!: string;
|
||||
|
||||
/** Required by MoR when `eimsDocumentType` is DEB/CRE — why the note was issued. */
|
||||
@Column({ name: "eims_reason", type: "text", nullable: true })
|
||||
eimsReason?: string | null;
|
||||
|
||||
/** The original registered invoice this debit/credit note adjusts. Required for DEB/CRE. */
|
||||
@Column({ name: "related_invoice_id", type: "uuid", nullable: true })
|
||||
relatedInvoiceId?: string | null;
|
||||
|
||||
@ManyToOne(() => Invoice)
|
||||
@JoinColumn({ name: "related_invoice_id" })
|
||||
relatedInvoice?: Invoice | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user