mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1296 from Tria-plc/eims-integration
Eims integration
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint,
|
||||
* distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original
|
||||
* invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`.
|
||||
*/
|
||||
export class EimsDebitCreditNotes3550000000000 implements MigrationInterface {
|
||||
name = "EimsDebitCreditNotes3550000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV',
|
||||
ADD COLUMN IF NOT EXISTS eims_reason text,
|
||||
ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_document_type,
|
||||
DROP COLUMN IF EXISTS eims_reason,
|
||||
DROP COLUMN IF EXISTS related_invoice_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -99,11 +102,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;
|
||||
@@ -397,12 +411,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. */
|
||||
@@ -418,15 +440,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,
|
||||
@@ -542,7 +555,7 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -709,11 +722,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -736,9 +754,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;
|
||||
@@ -786,7 +918,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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
@@ -12,6 +13,9 @@ const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
/**
|
||||
* `query` is answered by shape: the first call is the system-state guard, the second is the
|
||||
* candidate lookup. Keeps the fake honest about the order the service actually asks in.
|
||||
*
|
||||
* `managerRow` backs `dataSource.manager.findOne`/`.update` — only exercised by the
|
||||
* pre-reservation-rejection path (`failStalledCandidate`), so it defaults to the candidate itself.
|
||||
*/
|
||||
const build = (
|
||||
opts: {
|
||||
@@ -19,6 +23,7 @@ const build = (
|
||||
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
|
||||
candidate?: { id: string; invoiceNumber: string } | null;
|
||||
register?: jest.Mock;
|
||||
managerRow?: { eimsStatus: EimsInvoiceStatus } | null;
|
||||
} = {},
|
||||
) => {
|
||||
const register =
|
||||
@@ -34,12 +39,17 @@ const build = (
|
||||
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
|
||||
});
|
||||
|
||||
const managerUpdate = jest.fn().mockResolvedValue(undefined);
|
||||
const managerFindOne = jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.managerRow === undefined ? { eimsStatus: EimsInvoiceStatus.NotSubmitted } : opts.managerRow);
|
||||
|
||||
const service = new EimsAutoSubmitService(
|
||||
{ query } as unknown as DataSource,
|
||||
{ query, manager: { findOne: managerFindOne, update: managerUpdate } } as unknown as DataSource,
|
||||
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
|
||||
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
|
||||
);
|
||||
return { service, register, query };
|
||||
return { service, register, query, managerUpdate, managerFindOne };
|
||||
};
|
||||
|
||||
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
|
||||
@@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => {
|
||||
expect(register).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drains a pre-reservation rejection so the sweep advances, without touching the DB row's own reservation state", async () => {
|
||||
const register = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" }));
|
||||
const { service, managerFindOne, managerUpdate } = build({ candidate, register });
|
||||
|
||||
await expect(service.tick()).resolves.toBeUndefined();
|
||||
|
||||
expect(managerFindOne).toHaveBeenCalledTimes(1);
|
||||
expect(managerUpdate).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
INVOICE_ID,
|
||||
expect.objectContaining({
|
||||
eimsStatus: EimsInvoiceStatus.Failed,
|
||||
eimsLastError: expect.objectContaining({ message: "no related invoice" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a row alone if it already moved past NOT_SUBMITTED by the time the rejection is handled", async () => {
|
||||
const register = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" }));
|
||||
const { service, managerUpdate } = build({
|
||||
candidate,
|
||||
register,
|
||||
managerRow: { eimsStatus: EimsInvoiceStatus.Submitting },
|
||||
});
|
||||
|
||||
await expect(service.tick()).resolves.toBeUndefined();
|
||||
|
||||
expect(managerUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not start a second tick while one is still filing", async () => {
|
||||
let release: () => void = () => {};
|
||||
const register = jest.fn().mockImplementation(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
import { EimsInvoiceError, EimsInvoiceStatus } from "./eims-registration.types";
|
||||
|
||||
/**
|
||||
* Files issued invoices with MoR EIMS on a timer.
|
||||
@@ -67,21 +69,62 @@ export class EimsAutoSubmitService {
|
||||
const candidate = await this.nextCandidate();
|
||||
if (!candidate) return;
|
||||
|
||||
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
||||
this.logger.log(
|
||||
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
|
||||
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
|
||||
);
|
||||
try {
|
||||
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
||||
this.logger.log(
|
||||
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
|
||||
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
|
||||
);
|
||||
} catch (err) {
|
||||
// Every other failure path inside registerInvoiceWithEims persists FAILED/UNKNOWN itself
|
||||
// (settleFailure) before throwing. A BadRequestException is the one exception: it is only
|
||||
// ever thrown *before* a reservation is taken (config assertion, DEB/CRE validation), so
|
||||
// nothing is persisted — left alone, this candidate is picked again next tick forever, a
|
||||
// permanent head-of-line block on every invoice behind it. Drain it instead.
|
||||
if (err instanceof BadRequestException) {
|
||||
await this.failStalledCandidate(candidate, err);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Never let a filing failure kill the job. The outcome is already persisted on the invoice
|
||||
// (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the
|
||||
// next tick at the guard above.
|
||||
// (FAILED or UNKNOWN with the gateway's own message, or drained by failStalledCandidate
|
||||
// above), and a blocked system number stops the next tick at the guard above.
|
||||
this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a pre-reservation rejection as FAILED so the sweep advances past it — but only if the
|
||||
* invoice is still exactly where this tick left it. A reservation's own transactions
|
||||
* (SUBMITTING/UNKNOWN, or a system-wide block) are authoritative; this must never clobber them,
|
||||
* so the status is re-read fresh rather than trusted from the stale `candidate` row.
|
||||
*/
|
||||
private async failStalledCandidate(
|
||||
candidate: { id: string; invoiceNumber: string },
|
||||
err: BadRequestException,
|
||||
): Promise<void> {
|
||||
const current = await this.dataSource.manager.findOne(Invoice, { where: { id: candidate.id } });
|
||||
if (current?.eimsStatus !== EimsInvoiceStatus.NotSubmitted) {
|
||||
this.logger.warn(
|
||||
`EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation, but is ` +
|
||||
`no longer NOT_SUBMITTED (${current?.eimsStatus ?? "not found"}) — leaving state untouched.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lastError: EimsInvoiceError = { kind: "VALIDATION", message: err.message, at: new Date().toISOString() };
|
||||
await this.dataSource.manager.update(Invoice, candidate.id, {
|
||||
eimsStatus: EimsInvoiceStatus.Failed,
|
||||
eimsLastError: lastError,
|
||||
} as QueryDeepPartialEntity<Invoice>);
|
||||
this.logger.error(
|
||||
`EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation: ${err.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Why filing is currently impossible for this system number, or null when it is free. */
|
||||
private async systemBlockReason(): Promise<string | null> {
|
||||
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { sendPdf } from "../billing/billing.controller";
|
||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
@@ -110,4 +112,16 @@ export class EimsInvoiceController {
|
||||
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.receipts.listReceipts(id);
|
||||
}
|
||||
|
||||
@Get(":id/eims/receipts/:receiptId/document")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed receipt PDF (RRN + QR) for a filed EIMS receipt" })
|
||||
async receiptDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("receiptId", ParseUUIDPipe) receiptId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.receipts.document(id, receiptId);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
pngDataUrl,
|
||||
} from "../billing/documents/invoice-document.service";
|
||||
import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||
import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types";
|
||||
|
||||
/**
|
||||
* Maps a filed `EimsReceipt` onto the shared invoice/receipt document layout — mirrors
|
||||
* `eims-invoice.mapper.ts`'s role for `/v1/register`: a pure function, no I/O.
|
||||
*
|
||||
* The amounts (collected amount, mode of payment, withholding amount) live only in
|
||||
* `receipt.request` — the exact body this app sent, typed and written in exactly one place
|
||||
* (`EimsReceiptService`). Reading it back is a cast, not a new source of truth; real columns
|
||||
* would mean a migration + backfill for data already present in a stable shape.
|
||||
*
|
||||
* Throws rather than returning a model for anything not actually filed: a sealed, stamped PDF
|
||||
* for a receipt MoR rejected, never acknowledged, or whose request was somehow never recorded
|
||||
* would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw
|
||||
* surface as a 400 — there is nothing sensible to render instead.
|
||||
*/
|
||||
export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel {
|
||||
if (receipt.status !== EimsReceiptStatus.Registered) {
|
||||
throw new Error(
|
||||
`Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`,
|
||||
);
|
||||
}
|
||||
if (!receipt.request) {
|
||||
throw new Error(`Receipt ${receipt.receiptNumber} has no stored request body — cannot render its amounts.`);
|
||||
}
|
||||
|
||||
const isSales = receipt.kind === "SALES";
|
||||
|
||||
if (isSales) {
|
||||
const req = receipt.request as unknown as EimsSalesReceiptRequest;
|
||||
return build(receipt, invoice, {
|
||||
title: "Sales Receipt",
|
||||
currency: req.ReceiptCurrency,
|
||||
amountLabel: "Collected",
|
||||
lineDescription: `Payment received against invoice ${invoice.invoiceNumber}`,
|
||||
amount: req.CollectedAmount,
|
||||
// A sales receipt is a real payment — this is the one case the shared layout's own default
|
||||
// ("EDR PAID" for kind RECEIPT) is already correct, but set it explicitly so it never drifts
|
||||
// if that default changes for an unrelated reason.
|
||||
sealText: "EDR PAID",
|
||||
extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }],
|
||||
});
|
||||
}
|
||||
|
||||
const req = receipt.request as unknown as EimsWithholdReceiptRequest;
|
||||
return build(receipt, invoice, {
|
||||
title: "Withholding Receipt",
|
||||
currency: req.InvoiceDetail.Currency,
|
||||
amountLabel: "Withheld",
|
||||
lineDescription: `Withholding (${req.WithholdDetail.Type}) against invoice ${invoice.invoiceNumber}`,
|
||||
amount: req.WithholdDetail.WithholdingAmount,
|
||||
// A withholding receipt is not a payment — the shared layout's "EDR PAID" default would be
|
||||
// wrong here, so this is the one case that MUST override it.
|
||||
sealText: "EDR",
|
||||
extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }],
|
||||
});
|
||||
}
|
||||
|
||||
function build(
|
||||
receipt: EimsReceipt,
|
||||
invoice: Invoice,
|
||||
opts: {
|
||||
title: string;
|
||||
currency: string;
|
||||
amountLabel: string;
|
||||
lineDescription: string;
|
||||
amount: number;
|
||||
sealText: string;
|
||||
extraSummary: Array<{ label: string; value: string | null }>;
|
||||
},
|
||||
): InvoiceDocumentModel {
|
||||
return {
|
||||
kind: "RECEIPT",
|
||||
title: opts.title,
|
||||
documentNumber: receipt.receiptNumber,
|
||||
issuedAt: receipt.submittedAt ?? null,
|
||||
status: receipt.status,
|
||||
currency: opts.currency,
|
||||
summary: [
|
||||
{ label: "Invoice", value: invoice.invoiceNumber },
|
||||
{ label: "Invoice IRN", value: invoice.eimsIrn ?? null },
|
||||
{ label: "RRN", value: receipt.rrn ?? null },
|
||||
{ label: "Ack status", value: receipt.ackStatus ?? null },
|
||||
...opts.extraSummary,
|
||||
],
|
||||
// No line items on a receipt — one synthetic line, since buildHtml renders the line table
|
||||
// unconditionally and an empty `lines: []` would print a header-only empty table.
|
||||
lines: [
|
||||
{
|
||||
description: opts.lineDescription,
|
||||
quantity: 1,
|
||||
unitRate: opts.amount,
|
||||
amount: opts.amount,
|
||||
currency: opts.currency,
|
||||
},
|
||||
],
|
||||
totals: [{ label: opts.amountLabel, amount: opts.amount, grand: true }],
|
||||
sealText: opts.sealText,
|
||||
qrImageUrl: receipt.qr ? pngDataUrl(receipt.qr) : null,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { DataSource } from "typeorm";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
@@ -40,10 +41,16 @@ class FakeDb {
|
||||
}
|
||||
|
||||
private manager = {
|
||||
findOne: async (entity: unknown, options: { where: { id: string } }) =>
|
||||
entity === Invoice
|
||||
? (this.invoices.get(options.where.id) ?? null)
|
||||
: (this.receipts.get(options.where.id) ?? null),
|
||||
findOne: async (
|
||||
entity: unknown,
|
||||
options: { where: { id?: string; invoiceId?: string } },
|
||||
) => {
|
||||
if (entity === Invoice) return this.invoices.get(options.where.id!) ?? null;
|
||||
const receipt = options.where.id ? this.receipts.get(options.where.id) : undefined;
|
||||
if (!receipt) return null;
|
||||
if (options.where.invoiceId && receipt.invoiceId !== options.where.invoiceId) return null;
|
||||
return receipt;
|
||||
},
|
||||
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
|
||||
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
||||
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
||||
@@ -71,6 +78,7 @@ const build = (
|
||||
db: FakeDb,
|
||||
postBearer: jest.Mock,
|
||||
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
documents: { render: jest.Mock } = { render: jest.fn() },
|
||||
) =>
|
||||
new EimsReceiptService(
|
||||
db.asDataSource(),
|
||||
@@ -78,6 +86,7 @@ const build = (
|
||||
{ postBearer } as unknown as EimsClientService,
|
||||
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||
{ directSend } as unknown as NotificationsService,
|
||||
documents as unknown as InvoiceDocumentService,
|
||||
);
|
||||
|
||||
const okResponse = (over: Record<string, unknown> = {}) => ({
|
||||
@@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => {
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsReceiptService.document", () => {
|
||||
it("renders a sealed PDF for a registered sales receipt, with RRN and QR in the model", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
|
||||
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
|
||||
const receipt = await service.registerSalesReceipt(INVOICE_ID, {
|
||||
modeOfPayment: "CASH",
|
||||
collectedAmount: 500,
|
||||
} as never);
|
||||
|
||||
await service.document(INVOICE_ID, receipt.id);
|
||||
|
||||
expect(documents.render).toHaveBeenCalledTimes(1);
|
||||
const model = documents.render.mock.calls[0][0];
|
||||
expect(model.kind).toBe("RECEIPT");
|
||||
expect(model.qrImageUrl).toBe("data:image/png;base64,iVBORw0KGgo...");
|
||||
expect(model.summary).toContainEqual({ label: "RRN", value: "rrn-value" });
|
||||
expect(model.lines[0].amount).toBe(500);
|
||||
expect(model.sealText).toBe("EDR PAID");
|
||||
});
|
||||
|
||||
it("renders a withholding receipt with the withheld amount and a non-PAID seal", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
|
||||
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
|
||||
const receipt = await service.registerWithholdingReceipt(INVOICE_ID, {
|
||||
type: "TWHT",
|
||||
preTaxAmount: 1000,
|
||||
withholdingAmount: 20,
|
||||
} as never);
|
||||
|
||||
await service.document(INVOICE_ID, receipt.id);
|
||||
|
||||
const model = documents.render.mock.calls[0][0];
|
||||
expect(model.lines[0].amount).toBe(20);
|
||||
expect(model.sealText).toBe("EDR");
|
||||
expect(model.sealText).not.toContain("PAID");
|
||||
});
|
||||
|
||||
it("refuses to render a receipt that was never acknowledged by MoR", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
|
||||
const documents = { render: jest.fn() };
|
||||
const service = build(db, postBearer, undefined, documents);
|
||||
await expect(
|
||||
service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
|
||||
).rejects.toBeInstanceOf(EimsApiException);
|
||||
const [receipt] = [...db.receipts.values()];
|
||||
|
||||
await expect(service.document(INVOICE_ID, receipt.id as string)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(documents.render).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes the lookup to the given invoice — a receipt from another invoice is not found", async () => {
|
||||
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||
const service = build(db, jest.fn().mockResolvedValue(okResponse()));
|
||||
const receipt = await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
|
||||
|
||||
await expect(service.document(OTHER_INVOICE_ID, receipt.id)).rejects.toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
import { toReceiptDocumentModel } from "./eims-receipt-document.mapper";
|
||||
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
@@ -52,6 +54,7 @@ export class EimsReceiptService {
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly documents: InvoiceDocumentService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
@@ -154,6 +157,31 @@ export class EimsReceiptService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed PDF for one filed receipt (RRN + QR), scoped to the invoice it belongs to. Not on
|
||||
* `loadRegisteredInvoice` — a receipt refused/never-acknowledged by MoR must not render as a
|
||||
* sealed tax document, and `toReceiptDocumentModel` is the one place that guards it.
|
||||
*/
|
||||
async document(invoiceId: string, receiptId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const receipt = await this.dataSource.manager.findOne(EimsReceipt, {
|
||||
where: { id: receiptId, invoiceId },
|
||||
});
|
||||
if (!receipt) throw new NotFoundException(`Receipt ${receiptId} not found on invoice ${invoiceId}`);
|
||||
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
|
||||
let model: ReturnType<typeof toReceiptDocumentModel>;
|
||||
try {
|
||||
model = toReceiptDocumentModel(receipt, invoice);
|
||||
} catch (err) {
|
||||
// Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 —
|
||||
// a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService
|
||||
// itself throws.
|
||||
throw new BadRequestException((err as Error).message);
|
||||
}
|
||||
return this.documents.render(model);
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async submit(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { DocumentsModule } from "../billing/documents/documents.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
@@ -29,6 +30,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
||||
NotificationInboxModule,
|
||||
NotificationsModule,
|
||||
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
|
||||
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
|
||||
DocumentsModule,
|
||||
],
|
||||
controllers: [EimsInvoiceController],
|
||||
providers: [
|
||||
|
||||
@@ -569,6 +569,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:invoices:eims_receipt_register",
|
||||
"Register a sales or withholding receipt with MoR EIMS",
|
||||
),
|
||||
// Issuing a credit/debit memo is itself filing-equivalent — auto-submit picks it up like any
|
||||
// other issued invoice — so it carries the same restricted grant as the eims_* actions above,
|
||||
// not invoices:export.
|
||||
perm(
|
||||
"d2b00001-0001-4000-8000-00000000000a",
|
||||
"edr_freight_app:invoices:memo_issue",
|
||||
"Issue a credit or debit memo against a registered invoice",
|
||||
),
|
||||
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
||||
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||
perm(
|
||||
@@ -1874,6 +1882,7 @@ export const FREIGHT_PERMS = {
|
||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||
memoIssue: "edr_freight_app:invoices:memo_issue",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
},
|
||||
firstMile: {
|
||||
@@ -2406,9 +2415,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
||||
// eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so
|
||||
// filing is not a Finance job function — the endpoints exist for controlled testing and
|
||||
// exceptional operations, and are assigned to named admins rather than a role preset.
|
||||
// eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all
|
||||
// (the cron sweep runs as the system); these are the *manual* exceptional-operations
|
||||
// endpoints, and stay off the general Finance role. They are granted to the `chief` position
|
||||
// instead — see below — the same maker–checker split already used for shipping-line credit
|
||||
// mark-paid/cancel (Finance raises, chief decides).
|
||||
FREIGHT_PERMS.payments.view,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
// Shipping-line credit ledger is a Finance surface: bill batches into
|
||||
@@ -2519,6 +2530,14 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.governmentExpedite,
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
// Manual MoR EIMS actions and credit/debit memo issuance: kept off the general Finance role
|
||||
// (see that preset's comment) and granted here instead — the chief is already the decision
|
||||
// side of every other sensitive finance action (mark-paid/cancel approval below), and these
|
||||
// are irreversible-at-MoR or receivable-creating in the same way.
|
||||
FREIGHT_PERMS.invoices.eimsCancel,
|
||||
FREIGHT_PERMS.invoices.eimsResolve,
|
||||
FREIGHT_PERMS.invoices.eimsReceiptRegister,
|
||||
FREIGHT_PERMS.invoices.memoIssue,
|
||||
FREIGHT_PERMS.payments.view,
|
||||
// Decision side of the credit-invoice two-step: finance raises
|
||||
// mark-paid/cancel requests, the chief approves or rejects them.
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { EimsInvoiceStatus } from "@/types/eims";
|
||||
import { eimsService } from "@/services/eims.service";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "edr-green",
|
||||
FAILED: "red",
|
||||
UNKNOWN: "orange",
|
||||
CANCELLED: "gray",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "Filed",
|
||||
FAILED: "Rejected",
|
||||
UNKNOWN: "Unacknowledged",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||
@@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null
|
||||
);
|
||||
}
|
||||
|
||||
/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
||||
const CANCEL_REASON_CODES = [
|
||||
{ value: "1", label: "1 — Duplicate" },
|
||||
{ value: "2", label: "2 — Buyer request" },
|
||||
{ value: "3", label: "3 — Data entry error" },
|
||||
{ value: "6", label: "6 — Calculation error" },
|
||||
];
|
||||
|
||||
function CancelModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [reasonCode, setReasonCode] = useState<string | null>(null);
|
||||
const [remark, setRemark] = useState("");
|
||||
|
||||
const cancel = useMutation(
|
||||
api.invoices.eimsCancel.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
toast({ title: "Cancelled with MoR" });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Cancel EIMS registration" centered>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Cancels this invoice's registered document at MoR. Irreversible — an already-cancelled
|
||||
invoice refuses a second attempt.
|
||||
</Text>
|
||||
<Select
|
||||
label="Reason code"
|
||||
withAsterisk
|
||||
data={CANCEL_REASON_CODES}
|
||||
value={reasonCode}
|
||||
onChange={setReasonCode}
|
||||
placeholder="Select a reason"
|
||||
/>
|
||||
<Textarea
|
||||
label="Remark"
|
||||
placeholder="Optional note"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
color="red"
|
||||
leftSection={<Ban size={16} />}
|
||||
loading={cancel.isPending}
|
||||
disabled={!reasonCode}
|
||||
onClick={() => cancel.mutate({ id: invoiceId, reasonCode: reasonCode!, remark: remark.trim() || undefined })}
|
||||
>
|
||||
Cancel with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SalesReceiptModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [modeOfPayment, setModeOfPayment] = useState<EimsModeOfPayment | null>(null);
|
||||
const [collectedAmount, setCollectedAmount] = useState<number | "">("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegisterSalesReceipt.mutationOptions({
|
||||
onSuccess: (receipt) => {
|
||||
onClose();
|
||||
toast({ title: "Sales receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="File sales receipt" centered>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Mode of payment"
|
||||
withAsterisk
|
||||
data={EIMS_MODE_OF_PAYMENT.map((v) => ({ value: v, label: v }))}
|
||||
value={modeOfPayment}
|
||||
onChange={(v) => setModeOfPayment(v as EimsModeOfPayment)}
|
||||
placeholder="Select"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Collected amount"
|
||||
placeholder="Defaults to the invoice's paid amount"
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={collectedAmount}
|
||||
onChange={(v) => setCollectedAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder='Defaults to "Payment received"'
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={register.isPending}
|
||||
disabled={!modeOfPayment}
|
||||
onClick={() =>
|
||||
register.mutate({
|
||||
id: invoiceId,
|
||||
modeOfPayment: modeOfPayment!,
|
||||
collectedAmount: collectedAmount === "" ? undefined : collectedAmount,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
File with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function WithholdingReceiptModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState("TWHT");
|
||||
const [preTaxAmount, setPreTaxAmount] = useState<number | "">("");
|
||||
const [withholdingAmount, setWithholdingAmount] = useState<number | "">("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegisterWithholdingReceipt.mutationOptions({
|
||||
onSuccess: (receipt) => {
|
||||
onClose();
|
||||
toast({ title: "Withholding receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const valid = preTaxAmount !== "" && withholdingAmount !== "";
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="File withholding receipt" centered>
|
||||
<Stack gap="md">
|
||||
<TextInput label="Type" withAsterisk value={type} onChange={(e) => setType(e.currentTarget.value)} />
|
||||
<NumberInput
|
||||
label="Pre-tax amount"
|
||||
withAsterisk
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={preTaxAmount}
|
||||
onChange={(v) => setPreTaxAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Withholding amount"
|
||||
withAsterisk
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={withholdingAmount}
|
||||
onChange={(v) => setWithholdingAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder='Defaults to "Withholding"'
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={register.isPending}
|
||||
disabled={!valid}
|
||||
onClick={() =>
|
||||
register.mutate({
|
||||
id: invoiceId,
|
||||
type,
|
||||
preTaxAmount: preTaxAmount as number,
|
||||
withholdingAmount: withholdingAmount as number,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
File with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState<"CRE" | "DEB">("CRE");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const issue = useMutation(
|
||||
api.invoices.issueMemo.mutationOptions({
|
||||
onSuccess: (memo) => {
|
||||
onClose();
|
||||
toast({ title: "Memo issued", description: `${memo.invoiceNumber} — file it with MoR separately` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not issue memo", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue credit/debit memo" centered>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Creates a new invoice linked to this one, with every line copied verbatim. Filing it with
|
||||
MoR is a separate step — it does not happen automatically here.
|
||||
</Text>
|
||||
<Radio.Group value={type} onChange={(v) => setType(v as "CRE" | "DEB")} label="Type">
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio value="CRE" label="Credit memo" description="Reduces what the buyer owes; created settled." />
|
||||
<Radio value="DEB" label="Debit memo" description="An additional charge; created as a new open invoice." />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
withAsterisk
|
||||
placeholder="Why this memo is being issued"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={issue.isPending}
|
||||
disabled={!reason.trim()}
|
||||
onClick={() => issue.mutate({ id: invoiceId, type, reason: reason.trim() })}
|
||||
>
|
||||
Issue memo
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptsSection({ invoiceId, canFile }: { invoiceId: string; canFile: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const { data: receipts } = useQuery(api.invoices.eimsReceipts.queryOptions({ input: { id: invoiceId } }));
|
||||
const [salesOpen, setSalesOpen] = useState(false);
|
||||
const [withholdingOpen, setWithholdingOpen] = useState(false);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const download = async (receiptId: string, receiptNumber: string) => {
|
||||
setDownloadingId(receiptId);
|
||||
try {
|
||||
const { data } = await eimsService.downloadReceiptDocument(invoiceId, receiptId);
|
||||
openPdfBlob(data, `${receiptNumber}.pdf`);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Could not download receipt",
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Receipts
|
||||
</Text>
|
||||
{canFile && (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => setSalesOpen(true)}>
|
||||
File sales receipt
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={() => setWithholdingOpen(true)}>
|
||||
File withholding receipt
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{receipts && receipts.length > 0 ? (
|
||||
<Table striped withTableBorder={false} verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>RRN</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{receipts.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.kind}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[r.status] ?? "gray"} variant="light" size="sm">
|
||||
{STATUS_LABEL[r.status] ?? r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ fontFamily: "monospace" }}>{r.rrn ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.status === "REGISTERED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<Download size={14} />}
|
||||
loading={downloadingId === r.id}
|
||||
onClick={() => void download(r.id, r.receiptNumber)}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No receipts filed yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<SalesReceiptModal invoiceId={invoiceId} opened={salesOpen} onClose={() => setSalesOpen(false)} />
|
||||
<WithholdingReceiptModal invoiceId={invoiceId} opened={withholdingOpen} onClose={() => setWithholdingOpen(false)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR EIMS filing state for one invoice, with the manual actions.
|
||||
*
|
||||
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
|
||||
const canCancel = hasPermission(user, FREIGHT_PERMS.invoices.eimsCancel);
|
||||
const canFileReceipt = hasPermission(user, FREIGHT_PERMS.invoices.eimsReceiptRegister);
|
||||
const canIssueMemo = hasPermission(user, FREIGHT_PERMS.invoices.memoIssue);
|
||||
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [memoOpen, setMemoOpen] = useState(false);
|
||||
|
||||
const { data: eims, isLoading } = useQuery(
|
||||
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
|
||||
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canFile && (
|
||||
<Group gap="sm">
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={register.isPending}
|
||||
disabled={busy}
|
||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||
onClick={() => register.mutate({ id: invoiceId })}
|
||||
>
|
||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{eims.eimsIrn && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={verify.isPending}
|
||||
disabled={busy}
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
onClick={() => verify.mutate({ id: invoiceId })}
|
||||
>
|
||||
Verify with MoR
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{status === "CANCELLED" && (
|
||||
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
|
||||
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
|
||||
{eims.eimsCancellationRemark}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
{canFile && (
|
||||
<>
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && status !== "CANCELLED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={register.isPending}
|
||||
disabled={busy}
|
||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||
onClick={() => register.mutate({ id: invoiceId })}
|
||||
>
|
||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{eims.eimsIrn && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={verify.isPending}
|
||||
disabled={busy}
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
onClick={() => verify.mutate({ id: invoiceId })}
|
||||
>
|
||||
Verify with MoR
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCancel && eims.eimsIrn && status !== "CANCELLED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={14} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel with MoR
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canIssueMemo && status === "REGISTERED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => setMemoOpen(true)}
|
||||
>
|
||||
Issue credit/debit memo
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{eims.eimsIrn && <ReceiptsSection invoiceId={invoiceId} canFile={canFileReceipt} />}
|
||||
</Stack>
|
||||
|
||||
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
|
||||
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export const QUERY_KEYS = {
|
||||
offlineUsd: (filter?: InvoiceListFilter) =>
|
||||
["invoices", "offline-usd", filter ?? {}] as const,
|
||||
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||
eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const,
|
||||
},
|
||||
|
||||
BOOKINGS: {
|
||||
|
||||
@@ -136,6 +136,7 @@ export const URL_CONSTANTS = {
|
||||
INVOICES: "/billing/invoices",
|
||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||
INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`,
|
||||
OFFLINE_USD: "/billing/offline-usd",
|
||||
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
|
||||
},
|
||||
@@ -146,6 +147,12 @@ export const URL_CONSTANTS = {
|
||||
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
|
||||
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
|
||||
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
|
||||
CANCEL: (id: string) => `/invoices/${id}/eims/cancel`,
|
||||
RECEIPT_SALES: (id: string) => `/invoices/${id}/eims/receipt/sales`,
|
||||
RECEIPT_WITHHOLDING: (id: string) => `/invoices/${id}/eims/receipt/withholding`,
|
||||
RECEIPTS: (id: string) => `/invoices/${id}/eims/receipts`,
|
||||
RECEIPT_DOCUMENT: (id: string, receiptId: string) =>
|
||||
`/invoices/${id}/eims/receipts/${receiptId}/document`,
|
||||
},
|
||||
|
||||
CUSTOMERS_API: {
|
||||
|
||||
@@ -145,10 +145,16 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:invoices:view",
|
||||
export: "edr_freight_app:invoices:export",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
|
||||
// irreversible at the tax authority, and resolving clears a system-wide filing block.
|
||||
// Filing with MoR EIMS. Off the general Finance role — automatic filing needs no permission
|
||||
// at all (the cron sweep runs as the system); these are the manual, exceptional-operations
|
||||
// actions, granted to the `chief` position (maker-checker, same as shipping-line credit
|
||||
// mark-paid/cancel approval) rather than every Finance user.
|
||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||
// Issuing a credit/debit memo is filing-equivalent — same restricted grant as the eims_* keys.
|
||||
memoIssue: "edr_freight_app:invoices:memo_issue",
|
||||
},
|
||||
firstMile: {
|
||||
view: "edr_freight_app:first_mile:view",
|
||||
|
||||
@@ -8,16 +8,18 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
|
||||
import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -145,6 +147,7 @@ export default function InvoiceDetailPage() {
|
||||
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: invoice, isLoading } = useQuery(
|
||||
@@ -154,12 +157,27 @@ export default function InvoiceDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const downloadDocument = async () => {
|
||||
const downloadDocument = async (format?: "a4" | "thermal") => {
|
||||
if (!id) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { data } = await invoicesService.downloadDocument(id);
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
||||
const { data } = await invoicesService.downloadDocument(id, format);
|
||||
const suffix = format === "thermal" ? "-thermal" : "";
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`);
|
||||
} catch (error) {
|
||||
// Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped,
|
||||
// QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than
|
||||
// let it become a silent unhandled rejection with just a spinner stopping.
|
||||
toast({
|
||||
title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice",
|
||||
description:
|
||||
format === "thermal"
|
||||
? "Thermal rendering requires Chromium on the server. The A4 PDF is still available."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
@@ -202,17 +220,34 @@ export default function InvoiceDetailPage() {
|
||||
subtitle={humanize(invoice.source)}
|
||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
disabled={!canExport}
|
||||
loading={downloading}
|
||||
onClick={() => void downloadDocument()}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
disabled={!canExport}
|
||||
loading={downloading}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => void downloadDocument("a4")}
|
||||
>
|
||||
Download PDF (A4)
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => void downloadDocument("thermal")}
|
||||
>
|
||||
Download thermal invoice (80mm)
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ import { customersService } from "./customers.service";
|
||||
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||
import { shippingLineCreditsService } from "./shippingLineCredits.service";
|
||||
import { eimsService } from "./eims.service";
|
||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||
import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVerifyResult } from "@/types/eims";
|
||||
import { invoicesService } from "./invoices.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
@@ -3189,6 +3189,51 @@ export const api = {
|
||||
undefined,
|
||||
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
|
||||
),
|
||||
|
||||
eimsCancel: endpoint<{ id: string; reasonCode: string; remark?: string }, EimsInvoiceStatusView>(
|
||||
"invoices",
|
||||
"eimsCancel",
|
||||
({ id, reasonCode, remark }) => eimsService.cancel(id, { reasonCode, remark }),
|
||||
undefined,
|
||||
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
|
||||
),
|
||||
|
||||
eimsReceipts: endpoint<{ id: string }, EimsReceiptView[]>(
|
||||
"invoices",
|
||||
"eimsReceipts",
|
||||
({ id }) => eimsService.listReceipts(id),
|
||||
({ id }) => QUERY_KEYS.INVOICES.eimsReceipts(id),
|
||||
),
|
||||
|
||||
eimsRegisterSalesReceipt: endpoint<
|
||||
{ id: string; modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
|
||||
EimsReceiptView
|
||||
>(
|
||||
"invoices",
|
||||
"eimsRegisterSalesReceipt",
|
||||
({ id, ...input }) => eimsService.registerSalesReceipt(id, input),
|
||||
undefined,
|
||||
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
|
||||
),
|
||||
|
||||
eimsRegisterWithholdingReceipt: endpoint<
|
||||
{ id: string; type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
|
||||
EimsReceiptView
|
||||
>(
|
||||
"invoices",
|
||||
"eimsRegisterWithholdingReceipt",
|
||||
({ id, ...input }) => eimsService.registerWithholdingReceipt(id, input),
|
||||
undefined,
|
||||
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
|
||||
),
|
||||
|
||||
issueMemo: endpoint<{ id: string; type: "CRE" | "DEB"; reason: string }, Invoice>(
|
||||
"invoices",
|
||||
"issueMemo",
|
||||
({ id, ...input }) => invoicesService.issueMemo(id, input),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.INVOICES.ROOT],
|
||||
),
|
||||
},
|
||||
|
||||
overview: {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||
import type {
|
||||
EimsInvoiceStatusView,
|
||||
EimsModeOfPayment,
|
||||
EimsReceiptView,
|
||||
EimsVerifyResult,
|
||||
} from "@/types/eims";
|
||||
|
||||
/**
|
||||
* MoR EIMS filing actions on an invoice.
|
||||
@@ -36,4 +41,45 @@ export const eimsService = {
|
||||
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled one. */
|
||||
cancel(
|
||||
invoiceId: string,
|
||||
input: { reasonCode: string; remark?: string },
|
||||
): Promise<EimsInvoiceStatusView> {
|
||||
return apiClient
|
||||
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.CANCEL(invoiceId), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
registerSalesReceipt(
|
||||
invoiceId: string,
|
||||
input: { modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
|
||||
): Promise<EimsReceiptView> {
|
||||
return apiClient
|
||||
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_SALES(invoiceId), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
registerWithholdingReceipt(
|
||||
invoiceId: string,
|
||||
input: { type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
|
||||
): Promise<EimsReceiptView> {
|
||||
return apiClient
|
||||
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_WITHHOLDING(invoiceId), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
listReceipts(invoiceId: string): Promise<EimsReceiptView[]> {
|
||||
return apiClient
|
||||
.get<EimsReceiptView[]>(URL_CONSTANTS.EIMS.RECEIPTS(invoiceId))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Blob download — same pattern as `invoicesService.downloadDocument`. */
|
||||
downloadReceiptDocument(invoiceId: string, receiptId: string) {
|
||||
return apiClient.get<Blob>(URL_CONSTANTS.EIMS.RECEIPT_DOCUMENT(invoiceId, receiptId), {
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,12 +29,24 @@ export const invoicesService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
downloadDocument(id: string) {
|
||||
/** `format` omitted or "a4" → standard A4 PDF; "thermal" → 80mm thermal layout (ADD-P001). */
|
||||
downloadDocument(id: string, format?: "a4" | "thermal") {
|
||||
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
||||
responseType: "blob",
|
||||
params: format ? { format } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/** Issue a credit/debit memo against a registered invoice (MoR DEB/CRE) — filing-equivalent. */
|
||||
issueMemo(
|
||||
id: string,
|
||||
input: { type: "CRE" | "DEB"; reason: string },
|
||||
): Promise<Invoice> {
|
||||
return apiClient
|
||||
.post<Invoice>(URL_CONSTANTS.BILLING.INVOICE_MEMO(id), input)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
||||
listOfflineUsd(
|
||||
filter: InvoiceListFilter,
|
||||
|
||||
@@ -65,3 +65,16 @@ export interface EimsVerifyResult {
|
||||
[section: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** MoR `ModeOfPayment` enum — confirmed by a live schema error, verbatim spelling/casing. */
|
||||
export const EIMS_MODE_OF_PAYMENT = [
|
||||
"CASH",
|
||||
"CHEQUE",
|
||||
"CPO",
|
||||
"Local Bank Transfer",
|
||||
"SWIFT",
|
||||
"Wire Transfer",
|
||||
"Letter of Credit",
|
||||
"Card",
|
||||
] as const;
|
||||
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];
|
||||
|
||||
Reference in New Issue
Block a user