Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-08-15 10:12:59 +00:00
87 changed files with 4750 additions and 1175 deletions

View File

@@ -1,4 +1,10 @@
# Copy to .env for local/docker compose (not committed).
# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted),
# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda
# (canned verified profile, no eSignet call). Leave unset in production.
ENV=
PORT=3001
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
# `application` from this env var directly, bypassing MezgebModule.forRoot's

View File

@@ -0,0 +1,13 @@
/**
* Dev/staging bypass gate for OTP, payment and Fayda verification.
*
* Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be
* mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in
* production, so this is always false there.
*/
export function isBypassEnv(): boolean {
return ["dev", "staging"].includes(process.env.ENV ?? "");
}
/** Fixed code accepted in addition to the real one when isBypassEnv(). */
export const DEV_BYPASS_OTP = "000000";

View File

@@ -41,4 +41,16 @@ export class PaginationQueryDto {
@Transform(({ value }) => String(value).toUpperCase())
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
/**
* Column to sort by, as a public field name (not a raw SQL column). The
* actual whitelist lives in `applySort`'s `sortable` map at each call site,
* not here — a per-DTO `@IsIn` is opt-in and has been forgotten before.
* An unrecognized value falls back silently rather than 400ing, so a stale
* bookmark or shared link never breaks.
*/
@ApiPropertyOptional({ description: 'Public field name; unknown values fall back to the endpoint default.' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : undefined))
sortBy?: string;
}

View File

@@ -0,0 +1,48 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
export interface FacetBucket {
value: string;
count: number;
}
/**
* One `GROUP BY` query per faceted column, each with every OTHER active
* filter applied but its OWN predicate omitted. That omission is the point:
* with `status=SUBMITTED` selected, the status facet still reports
* `APPROVED: 8` so the user can switch, while the freightType facet reflects
* only the SUBMITTED-scoped set. Omit `search` from nothing — it's a scope,
* not a pill, and stays applied in every facet.
*
* Capped at 50 buckets per column — FK-id facets (warehouseId, yardId) can
* have real cardinality; beyond 50 the frontend should fall back to a
* typeahead instead of a checkbox list. Never facet a column whose popover
* would need its own search box (references, plate numbers, free text).
*
* @param base builds a FRESH query builder (soft-delete guard only,
* no filters) — called once per facet column.
* @param applyFilters applies every filter to `qb`, using `omit` to skip
* one column's own predicate.
* @param columns facet key -> "alias.column" SQL reference.
*/
export async function computeFacets<T extends ObjectLiteral>(
base: () => SelectQueryBuilder<T>,
applyFilters: (qb: SelectQueryBuilder<T>, omit?: string) => void,
columns: Record<string, string>,
): Promise<Record<string, FacetBucket[]>> {
const entries = await Promise.all(
Object.entries(columns).map(async ([key, column]) => {
const qb = base();
applyFilters(qb, key);
const rows = await qb
.select(column, 'value')
.addSelect('COUNT(*)::int', 'count')
.andWhere(`${column} IS NOT NULL`)
.groupBy(column)
.orderBy('count', 'DESC')
.limit(50)
.getRawMany<{ value: string; count: number }>();
return [key, rows.map((r) => ({ value: String(r.value), count: Number(r.count) }))] as const;
}),
);
return Object.fromEntries(entries);
}

View File

@@ -0,0 +1,76 @@
import { SelectQueryBuilder } from 'typeorm';
import { applySort, buildPaginationMeta, normalizePagination } from './pagination.util';
/** Minimal fake — just enough of the SelectQueryBuilder chain applySort touches. */
function fakeQb() {
const calls: Array<{ method: string; args: unknown[] }> = [];
const qb = {
alias: 'contract',
orderBy(...args: unknown[]) {
calls.push({ method: 'orderBy', args });
return qb;
},
addOrderBy(...args: unknown[]) {
calls.push({ method: 'addOrderBy', args });
return qb;
},
};
return { qb: qb as unknown as SelectQueryBuilder<any>, calls };
}
const SORTABLE = {
createdAt: 'contract.createdAt',
contractValidUntil: 'contract.contractValidUntil',
};
describe('applySort', () => {
it('resolves a whitelisted sortBy to its column', () => {
const { qb, calls } = fakeQb();
applySort(qb, { sortBy: 'contractValidUntil', sortOrder: 'ASC' }, SORTABLE, 'createdAt');
expect(calls[0]).toEqual({
method: 'orderBy',
args: ['contract.contractValidUntil', 'ASC'],
});
});
it('falls back to the default column for an unknown sortBy instead of throwing', () => {
const { qb, calls } = fakeQb();
// A stale bookmark or shared link naming a removed/renamed column must
// never 400 — it should silently behave as if sortBy were absent.
expect(() =>
applySort(qb, { sortBy: "id; DROP TABLE contracts; --" }, SORTABLE, 'createdAt'),
).not.toThrow();
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
});
it('defaults sortOrder to DESC when absent or not ASC', () => {
const { qb, calls } = fakeQb();
applySort(qb, {}, SORTABLE, 'createdAt');
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
});
it('always appends an id ASC tiebreaker', () => {
const { qb, calls } = fakeQb();
applySort(qb, { sortBy: 'createdAt' }, SORTABLE, 'createdAt');
expect(calls[1]).toEqual({ method: 'addOrderBy', args: ['contract.id', 'ASC'] });
});
});
describe('normalizePagination / buildPaginationMeta', () => {
it('clamps page to >= 1 and pageSize to the configured max', () => {
const p = normalizePagination({ page: 0, pageSize: 999 }, { maxPageSize: 100 });
expect(p).toEqual({ page: 1, pageSize: 100, skip: 0, take: 100 });
});
it('computes hasNextPage/hasPreviousPage from total', () => {
const meta = buildPaginationMeta(45, 2, 20);
expect(meta).toEqual({
page: 2,
pageSize: 20,
total: 45,
totalPages: 3,
hasNextPage: true,
hasPreviousPage: true,
});
});
});

View File

@@ -83,3 +83,32 @@ export function paginateArray<T>(
meta: buildPaginationMeta(rows.length, page, pageSize),
};
}
/**
* Apply `ORDER BY` from a query DTO's `sortBy`/`sortOrder`, resolved against a
* whitelist — never interpolate `sortBy` into a query builder directly, it is
* unvalidated user input and an unwhitelisted `orderBy(\`alias.${sortBy}\`)`
* is a SQL-injection primitive (see the deleted `findAllWithFilters` methods
* on drivers/vehicles repositories, which had exactly that bug).
*
* An unknown `sortBy` falls back to `fallback` instead of throwing — a stale
* bookmark or shared link should never 400.
*
* Always appends `id ASC` as a tiebreaker: sorting by a non-unique column
* (status, createdAt on bulk-imported rows) without one can drop or
* duplicate rows across pages once LIMIT/OFFSET is involved.
*
* @param sortable public sort key -> "alias.column" SQL reference. Also
* doubles as the Swagger enum / frontend's sortable-column list.
* @param fallback a key that must exist in `sortable`.
*/
export function applySort<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
query: { sortBy?: string; sortOrder?: 'ASC' | 'DESC' },
sortable: Record<string, string>,
fallback: string,
): SelectQueryBuilder<T> {
const column = (query.sortBy && sortable[query.sortBy]) || sortable[fallback];
qb.orderBy(column, query.sortOrder === 'ASC' ? 'ASC' : 'DESC');
return qb.addOrderBy(`${qb.alias}.id`, 'ASC');
}

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Supports the Stripe-style pill filter bar: every list it lands on filters
* and sorts server-side now. `@Index` decorators alone do nothing —
* `synchronize: false` means an index exists only if a migration created it
* (see the `RepairSynchronizeDrift`-style gaps this closes).
*
* `idx_warehouse_inventory_status` already exists (FreightBaseline). Bookings
* and wagons have no plain `status` index — `idx_bookings_route_day` and
* `idx_wagons_readiness` only cover `status` as a trailing/partial column,
* not a standalone `WHERE status = $1`, and `status` is the single
* most-filtered column on both lists (bookings: 37 values).
*
* `(created_at DESC, id ASC)` partials match the default sort + id
* tiebreaker `applySort` now appends everywhere, and none of these tables
* had a created_at index at all.
*/
export class FilterableListIndexes3540000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_status
ON freight.bookings USING btree (status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_status
ON freight.wagons USING btree (status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_contracts_created_at_id
ON freight.contracts (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_created_at_id
ON freight.bookings (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_created_at_id
ON freight.warehouse_inventory (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_created_at_id
ON freight.wagons (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_contracts_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_status`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_status`);
}
}

View File

@@ -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
`);
}
}

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -29,6 +30,7 @@ import { actorLabel } from "../warehouses/current-actor.util";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
import { IssueMemoDto } from "./dto/issue-memo.dto";
@ApiTags("billing")
@Controller("billing")
@@ -38,6 +40,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
FREIGHT_PERMS.invoices.confirmOffline,
FREIGHT_PERMS.invoices.memoIssue,
])
@ApiBearerAuth()
export class BillingController {
@@ -116,11 +119,31 @@ export class BillingController {
});
}
@Post("invoices/:id/memo")
@BookingStaff(FREIGHT_PERMS.invoices.memoIssue)
@ApiOperation({
summary:
"Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.",
})
issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) {
return this.billingService.issueMemo(id, dto);
}
@Get("invoices/:id/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
@ApiOperation({
summary:
'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).',
})
async document(
@Param("id", ParseUUIDPipe) id: string,
@Query("format") format: string | undefined,
@Res() res: Response,
) {
if (format !== undefined && format !== "a4" && format !== "thermal") {
throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`);
}
const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4");
sendPdf(res, filename, buffer);
}

View File

@@ -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();
});
});

View File

@@ -18,6 +18,7 @@ import { Booking } from "../bookings/entities/booking.entity";
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
@@ -25,6 +26,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
@@ -145,6 +147,18 @@ export interface GenerateInvoiceInput {
status?: Freight.InvoiceStatus;
}
/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */
export type MemoType = "CRE" | "DEB";
/** Everything needed to issue a credit or debit memo against an already-registered invoice. */
export interface IssueMemoInput {
type: MemoType;
/** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */
reason: string;
/** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */
lines?: InvoiceLineInput[];
}
/** Payload broadcast on `${source}.invoice.<event>`. */
export interface InvoiceEventPayload {
invoiceId: string;
@@ -481,12 +495,20 @@ export class BillingService {
// ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
/**
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
* boundary check, not a business rule.
*/
async document(
id: string,
format: "a4" | "thermal" = "a4",
): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
await this.toDocumentModel(invoice, "INVOICE"),
);
const model = await this.toDocumentModel(invoice, "INVOICE");
return format === "thermal"
? this.invoiceDocuments.renderThermal(model)
: this.invoiceDocuments.render(model);
}
/** Sealed PDF receipt; available once any payment has been recorded. */
@@ -502,15 +524,6 @@ export class BillingService {
);
}
/**
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
*/
private renderEimsQr(signedQr: string): string {
return `data:image/png;base64,${signedQr}`;
}
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
private async bookingSummaryRows(
invoice: Invoice,
@@ -626,7 +639,7 @@ export class BillingService {
currency: l.currency,
})),
totals,
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
};
}
@@ -793,11 +806,16 @@ export class BillingService {
// ── Generation ───────────────────────────────────────────────────────────────
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
/**
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
* daily sequence (different prefix hashes to a different advisory lock, see
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
*/
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
return nextDailyInvoiceNumber(mg, {
table: "freight.invoices",
code: "INV",
code,
});
}
@@ -820,9 +838,123 @@ export class BillingService {
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
* unchanged: it has no side effects (no events, no notifications, no payment records — every
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
* is just an ordinary invoice with three extra columns set.
*
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
*
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
*/
async issueMemo(
originalId: string,
input: IssueMemoInput,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const reason = input.reason?.trim();
if (!reason) {
throw new BadRequestException("A memo requires a reason.");
}
const original = await this.findById(originalId);
if (!original.eimsIrn) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
});
}
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
throw new BadRequestException(
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
);
}
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
throw new BadRequestException(
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
);
}
const sourceLines = input.lines?.length ? input.lines : original.lines;
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
chargeType: l.chargeType,
description: l.description,
quantity: Number(l.quantity),
unitRate: Number(l.unitRate),
amount: Number(l.amount),
currency: l.currency,
metadata: l.metadata ?? null,
}));
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
if (!(total > 0)) {
throw new BadRequestException("A memo must have a positive total.");
}
// Only a credit note is bounded by the original — it can only give back what was charged. A
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
// assume the credit-note ceiling is correct for DEB).
if (input.type === "CRE" && total > Number(original.totalAmount)) {
throw new BadRequestException(
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
);
}
const code = input.type === "CRE" ? "CRE" : "DEB";
const settled = input.type === "CRE";
return this.dataSource.transaction(async (mg) => {
const memo = await this.createInvoice(
{
source: original.source as Freight.InvoiceSource,
sourceId: original.id,
type: input.type === "CRE" ? "credit_note" : "debit_note",
companyId: original.companyId,
companyProfileId: original.companyProfileId,
shippingLineCompanyId: original.shippingLineCompanyId,
lines,
currency: original.currency,
subtotalAmount: total,
taxAmount: 0,
totalAmount: total,
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
},
mg,
code,
);
const patch: Record<string, unknown> = {
eimsDocumentType: input.type,
eimsReason: reason,
relatedInvoiceId: original.id,
...(settled
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
: {}),
};
await mg.update(Invoice, memo.id, patch);
this.logger.log(
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
);
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
});
}
private async createInvoice(
input: GenerateInvoiceInput,
mg: EntityManager,
code = "INV",
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
@@ -870,7 +1002,7 @@ export class BillingService {
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
const invoice = await mg.save(
mg.create(Invoice, {

View File

@@ -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");
});
});

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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)} &middot; ${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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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("");

View File

@@ -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>")) {

View File

@@ -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[];
}

View File

@@ -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/,

View File

@@ -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 },

View File

@@ -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;
}

View File

@@ -12,6 +12,7 @@ import {
SelectQueryBuilder,
} from 'typeorm';
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
@@ -857,6 +858,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
};
}
/**
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
* column, each with every OTHER active filter applied but its own
* predicate omitted (see `applyListFilters`'s `omit` param). `bookingType`
* is derived from `contract_kind` (see the comment in `applyListFilters`),
* not a plain column, so it facets on the same CASE expression the filter
* itself applies.
*/
async getFacets(options: BookingListFilterOptions): Promise<Record<string, FacetBucket[]>> {
return computeFacets(
() => this.repository.createQueryBuilder('booking').where('booking.deleted_at IS NULL'),
(qb, omit) => this.applyListFilters(qb, options, omit as keyof BookingListFilterOptions),
{
status: 'booking.status',
freightType: 'booking.freight_type',
tradeDirection: 'booking.trade_direction',
paymentStatus: 'booking.payment_status',
bookingType:
"CASE WHEN booking.contract_kind = 'GENERAL' THEN 'GENERAL_CONTRACT' ELSE 'ONE_TIME' END",
},
);
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('booking')
@@ -915,16 +939,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
return { inQueue, onThisPage, needsAction, urgent };
}
/**
* @param omit skip this one predicate — used by `getFacets` so a facet's
* own filter doesn't hide its own sibling values. Every other caller
* (list, summary metrics) passes nothing.
*/
private applyListFilters(
qb: SelectQueryBuilder<Booking>,
options: BookingListFilterOptions,
omit?: keyof BookingListFilterOptions | 'status',
): void {
if (options.statuses?.length) {
qb.andWhere('booking.status IN (:...statuses)', {
statuses: options.statuses,
});
} else if (options.status) {
qb.andWhere('booking.status = :status', { status: options.status });
if (omit !== 'status') {
if (options.statuses?.length) {
qb.andWhere('booking.status IN (:...statuses)', {
statuses: options.statuses,
});
} else if (options.status) {
qb.andWhere('booking.status = :status', { status: options.status });
}
}
if (options.companyId) {
@@ -957,12 +989,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
cargoTypeId: options.cargoTypeId,
});
}
if (options.freightType) {
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.bookingType) {
if (omit !== 'bookingType' && options.bookingType) {
// The stored booking_type column is 'ONE_TIME' for every row (contract
// drawdowns included — see contract-booking.service create), so the
// one-time vs general split keys on the denormalized contract_kind:
@@ -1011,12 +1043,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE');
}
if (options.tradeDirection) {
if (omit !== 'tradeDirection' && options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.tradeDirections) {
if (omit !== 'tradeDirection' && options.tradeDirections) {
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
}
if (options.paymentCurrency) {
@@ -1024,7 +1056,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
paymentCurrency: options.paymentCurrency,
});
}
if (options.paymentStatus) {
if (omit !== 'paymentStatus' && options.paymentStatus) {
qb.andWhere('booking.payment_status = :paymentStatus', {
paymentStatus: options.paymentStatus,
});

View File

@@ -2051,8 +2051,9 @@ export class BookingsService {
consolidationPaired: filter.consolidationPaired,
};
const [statusCounts, metrics] = await Promise.all([
const [statusCounts, facets, metrics] = await Promise.all([
this.bookingsRepository.getStatusCounts(),
this.bookingsRepository.getFacets(listFilter),
this.bookingsRepository.getListSummaryMetrics({
...listFilter,
page,
@@ -2064,7 +2065,9 @@ export class BookingsService {
return {
metrics,
// Tabs stay unfiltered (whole-set) on purpose — see the DTO comment.
tabs: mapStatusCountsToTabs(statusCounts),
facets,
};
}

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { FacetBucket } from '../../../common/utils/facets.util';
export class BookingListSummaryMetricsDto {
@ApiProperty({ example: 42 })
@@ -31,4 +32,18 @@ export class BookingListSummaryDto {
@ApiProperty({ type: BookingListSummaryTabsDto })
tabs!: BookingListSummaryTabsDto;
/**
* Per-column value counts for the filter bar's enum popovers, scoped to
* every OTHER currently-active filter (own predicate omitted per column —
* see `BookingsRepository.getFacets`). Unlike `tabs`, which is
* deliberately unfiltered so tab counts stay stable while you filter
* within a tab, these move with the filter set.
*/
@ApiProperty({
description: 'Facet counts keyed by filter field, for the pill filter bar',
type: 'object',
additionalProperties: { type: 'array', items: { type: 'object' } },
})
facets!: Record<string, FacetBucket[]>;
}

View File

@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
import { Booking } from '../bookings/entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
@@ -47,6 +48,8 @@ export interface ContractListFilterOptions {
hasClearanceDocuments?: boolean;
createdFrom?: string;
createdTo?: string;
originYardId?: string;
destinationYardId?: string;
}
@Injectable()
@@ -416,14 +419,22 @@ export class ContractsRepository extends BaseRepository<Contract> {
return { inQueue, onThisPage, needsAction };
}
/**
* @param omit skip this one predicate — used by `getFacets` so a facet's
* own filter doesn't hide its own sibling values (see class doc on
* `getFacets`). Every other list/summary/count caller passes nothing.
*/
private applyListFilters(
qb: SelectQueryBuilder<Contract>,
options: ContractListFilterOptions,
omit?: keyof ContractListFilterOptions | 'status',
): void {
if (options.statuses?.length) {
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
} else if (options.status) {
qb.andWhere('contract.status = :status', { status: options.status });
if (omit !== 'status') {
if (options.statuses?.length) {
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
} else if (options.status) {
qb.andWhere('contract.status = :status', { status: options.status });
}
}
if (options.companyId) {
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
@@ -433,7 +444,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
companyProfileId: options.companyProfileId,
});
}
if (options.contractKind) {
if (omit !== 'contractKind' && options.contractKind) {
qb.andWhere('contract.contract_kind = :contractKind', {
contractKind: options.contractKind,
});
@@ -454,20 +465,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
serviceTypeId: options.serviceTypeId,
});
}
if (options.freightType) {
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('contract.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.tradeDirection) {
if (omit !== 'tradeDirection' && options.tradeDirection) {
qb.andWhere('contract.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.tradeDirections) {
if (omit !== 'tradeDirection' && options.tradeDirections) {
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
}
if (options.paymentCurrency) {
if (omit !== 'paymentCurrency' && options.paymentCurrency) {
qb.andWhere('contract.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
});
@@ -480,6 +491,47 @@ export class ContractsRepository extends BaseRepository<Contract> {
if (options.createdTo) {
qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo });
}
// Routes are one-to-many (a contract can list several lanes), so origin
// and destination each need their own EXISTS — a plain join would
// duplicate the contract row per matching route.
if (omit !== 'originYardId' && options.originYardId) {
qb.andWhere(
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
'AND cr_o.origin_yard_id = :originYardId)',
{ originYardId: options.originYardId },
);
}
if (omit !== 'destinationYardId' && options.destinationYardId) {
qb.andWhere(
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
'AND cr_d.destination_yard_id = :destinationYardId)',
{ destinationYardId: options.destinationYardId },
);
}
}
/**
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
* column, each with every OTHER active filter applied but its own
* predicate omitted — so selecting `status=SUBMITTED` still shows
* `APPROVED: 8` in the status popover (to switch), while the freightType
* popover reflects only the SUBMITTED-scoped set. Supersedes
* `getStatusCounts`, which ignores the active filter entirely.
*/
async getFacets(options: ContractListFilterOptions): Promise<Record<string, FacetBucket[]>> {
return computeFacets(
() => this.repository.createQueryBuilder('contract').where('contract.deleted_at IS NULL'),
(qb, omit) => this.applyListFilters(qb, options, omit as keyof ContractListFilterOptions),
{
status: 'contract.status',
contractKind: 'contract.contract_kind',
freightType: 'contract.freight_type',
tradeDirection: 'contract.trade_direction',
paymentCurrency: 'contract.payment_currency',
},
);
}
// ── Approval steps ─────────────────────────────────────────────────────────

View File

@@ -768,6 +768,8 @@ export class ContractsService {
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
@@ -789,10 +791,12 @@ export class ContractsService {
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
};
const [statusCounts, metrics] = await Promise.all([
this.contractsRepository.getStatusCounts(),
const [facets, metrics] = await Promise.all([
this.contractsRepository.getFacets(listFilter),
this.contractsRepository.getListSummaryMetrics({
...listFilter,
page,
@@ -801,7 +805,13 @@ export class ContractsService {
}),
]);
return { metrics, statusCounts };
// statusCounts kept for existing callers; now filter-scoped like every
// other facet instead of the unfiltered global count `getStatusCounts` gave.
const statusCounts = Object.fromEntries(
(facets.status ?? []).map((b) => [b.value, b.count]),
);
return { metrics, statusCounts, facets };
}
/** Get a single contract by ID with relations and signed file URLs. */

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { FacetBucket } from '../../../common/utils/facets.util';
export class ContractListSummaryMetricsDto {
@ApiProperty({ example: 42 })
@@ -15,6 +16,23 @@ export class ContractListSummaryDto {
@ApiProperty({ type: ContractListSummaryMetricsDto })
metrics!: ContractListSummaryMetricsDto;
/** @deprecated use `facets.status` — kept for existing callers, computed
* from the same filter-scoped query now instead of `getStatusCounts`'s
* unfiltered global count. */
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
statusCounts!: Record<string, number>;
/**
* Per-column value counts for the filter bar's enum popovers, scoped to
* every OTHER currently-active filter (each column's own predicate is
* omitted from its own count — see `ContractsRepository.getFacets`).
* Absent/omitted keys mean the frontend falls back to its static option
* list with no counts, never an error.
*/
@ApiProperty({
description: 'Facet counts keyed by filter field, for the pill filter bar',
type: 'object',
additionalProperties: { type: 'array', items: { type: 'object' } },
})
facets!: Record<string, FacetBucket[]>;
}

View File

@@ -61,6 +61,22 @@ export class FilterContractDto {
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Only contracts with a route starting at this yard.',
})
@IsOptional()
@IsUUID()
originYardId?: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Only contracts with a route ending at this yard.',
})
@IsOptional()
@IsUUID()
destinationYardId?: string;
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
@IsOptional()
@IsDateString()

View File

@@ -29,52 +29,6 @@ export class DriversRepository extends BaseRepository<Driver> {
return this.repository.findOne({ where: { id } });
}
async findAllWithFilters(query: {
page?: number;
pageSize?: number;
search?: string;
status?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}) {
const page = query.page || 1;
const pageSize = query.pageSize || 10;
const skip = (page - 1) * pageSize;
let queryBuilder = this.repository.createQueryBuilder('driver');
if (query.search) {
queryBuilder = queryBuilder.where(
'(driver.firstName ILIKE :search OR driver.lastName ILIKE :search OR driver.email ILIKE :search OR driver.phoneNumber ILIKE :search OR driver.licenseNumber ILIKE :search)',
{ search: `%${query.search}%` },
);
}
if (query.status) {
queryBuilder = queryBuilder.andWhere('driver.status = :status', {
status: query.status,
});
}
const sortBy = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder || 'DESC';
queryBuilder = queryBuilder
.orderBy(`driver.${sortBy}`, sortOrder)
.skip(skip)
.take(pageSize);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async createDriver(driverData: any): Promise<Driver> {
const driver = this.repository.create(driverData);
const result = await this.repository.save(driver);

View File

@@ -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(

View File

@@ -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 }[] =

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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,
};
}

View File

@@ -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/);
});
});

View File

@@ -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(

View File

@@ -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: [

View File

@@ -8,6 +8,7 @@ import { OtpRepository } from "./otp.repository";
import { NotificationsService } from "../notifications/notifications.service";
import { EmailClientService } from "../notifications/email-client.service";
import { isBypassEnv, DEV_BYPASS_OTP } from "../../common/dev-bypass.util";
/**
* Where a code goes. At least one of phone/email must be set — enforced by the
@@ -146,6 +147,21 @@ export class OtpService {
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
);
// Dev/staging only: the row above still exists (so a real code would
// still verify), but skip the real SMS/email send — no carrier cost, no
// dependency on RabbitMQ/the mail relay being up. Verify with the fixed
// DEV_BYPASS_OTP code instead of whatever landed in the row.
if (isBypassEnv()) {
this.logger.warn(
`otp.dispatch.bypassed target=${label} — dev/staging, no real SMS/email sent (verify with ${DEV_BYPASS_OTP})`,
);
return {
success: true,
delivered: true,
message: "OTP sent successfully",
};
}
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
// here let an attacker wipe the per-target guess budget just by calling
// /otp/send between guesses. The counter is cleared only when the code is
@@ -394,7 +410,10 @@ export class OtpService {
// invalid otp — per-target attempt cap so a 6-digit code can't be
// brute-forced within its TTL; the code is burned once the budget is spent.
if (otpData.otp !== otp) {
// Dev/staging only: a fixed code verifies any pending OTP row without
// knowing the real one — the row still has to exist (sendOtp still runs).
const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP;
if (otpData.otp !== otp && !bypassed) {
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
await this.otpRepository.deleteOtp(otpData);
@@ -482,7 +501,10 @@ export class OtpService {
);
}
if (otpData.otp !== otp) {
// Dev/staging only: a fixed code verifies any pending OTP row without
// knowing the real one — the row still has to exist (sendOtp still runs).
const bypassed = isBypassEnv() && otp === DEV_BYPASS_OTP;
if (otpData.otp !== otp && !bypassed) {
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {

View File

@@ -31,6 +31,7 @@ import {
IntentStatusDto,
PaymentPlatformDto,
} from "./payments.dto";
import { isBypassEnv } from "../../common/dev-bypass.util";
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
* the caller (billing) — this service never derives them from a domain record. */
@@ -256,34 +257,52 @@ export class PaymentService {
);
}
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
// debited against the intent amount, so the dev shortcut would break it.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// shortcut floor is 10, not 1.
// amountMinor: isCbeBill
// ? input.amountMinor
// : input.method === ProviderMethod.CAC_BANK
// ? 10
// : 1,
amountMinor: input.amountMinor,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
returnUrl:
input.returnUrl ??
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
failureUrl:
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
// Dev/staging only: skip the real gateway call entirely and report an
// immediate SUCCEEDED snapshot — everything below (upsert, settle,
// billing notify) runs exactly as it would for a real synchronous
// provider success.
const snapshot: PaymentIntentSnapshot = isBypassEnv()
? {
intentId: `bypass-${input.referenceId}`,
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
merchantOrderId: input.orderRef,
provider: input.method as ProviderMethod,
status: ProviderPaymentStatus.SUCCEEDED,
amountMinor: input.amountMinor,
currency: input.currency,
providerTxnId: `bypass-${input.referenceId}`,
paidAt: new Date().toISOString(),
}
: await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
// debited against the intent amount, so the dev shortcut would break it.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// shortcut floor is 10, not 1.
// amountMinor: isCbeBill
// ? input.amountMinor
// : input.method === ProviderMethod.CAC_BANK
// ? 10
// : 1,
amountMinor: input.amountMinor,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
returnUrl:
input.returnUrl ??
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
failureUrl:
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
const immediateSuccess =
snapshot.status === ProviderPaymentStatus.SUCCEEDED;

View File

@@ -21,52 +21,6 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
return this.repository.findOne({ where: { id } });
}
async findAllWithFilters(query: {
page?: number;
pageSize?: number;
search?: string;
status?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}) {
const page = query.page || 1;
const pageSize = query.pageSize || 10;
const skip = (page - 1) * pageSize;
let queryBuilder = this.repository.createQueryBuilder('vehicle');
if (query.search) {
queryBuilder = queryBuilder.where(
'(vehicle.plateNumber ILIKE :search OR vehicle.manufacturer ILIKE :search OR vehicle.model ILIKE :search)',
{ search: `%${query.search}%` },
);
}
if (query.status) {
queryBuilder = queryBuilder.andWhere('vehicle.status = :status', {
status: query.status,
});
}
const sortBy = query.sortBy || 'createdAt';
const sortOrder = query.sortOrder || 'DESC';
queryBuilder = queryBuilder
.orderBy(`vehicle.${sortBy}`, sortOrder)
.skip(skip)
.take(pageSize);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
const vehicle = this.repository.create(vehicleData);
return this.repository.save(vehicle);

View File

@@ -28,6 +28,11 @@ import {
NormalizedFaydaUserInfo,
VerifaydaPurpose,
} from './verifayda.types';
import { randomUUID } from 'node:crypto';
import { isBypassEnv } from '../../common/dev-bypass.util';
/** Sentinel `code` that skips the real eSignet exchange in dev/staging. */
export const DEV_BYPASS_FAYDA_CODE = 'DEV_BYPASS';
export interface StartVerificationInput {
purpose: VerifaydaPurpose;
@@ -143,6 +148,26 @@ export class VerifaydaService {
async completeVerification(
query: VerifaydaCallbackDto,
): Promise<CompleteVerificationResult> {
// Dev/staging only: caller sends the sentinel code instead of a real
// eSignet redirect — skip the token exchange/session entirely and hand
// back a canned VERIFY result. `sub` is unique per call so binding both
// owner and PoA in the same bypass session doesn't collide.
if (isBypassEnv() && query.code === DEV_BYPASS_FAYDA_CODE) {
this.logger.warn('Fayda verification BYPASSED (dev/staging)');
return {
purpose: 'VERIFY',
verified: true,
sub: `dev-bypass-${randomUUID()}`,
fullName: 'Dev Bypass User',
email: 'dev-bypass@example.com',
phoneNumber: '+251900000000',
birthdate: '1990-01-01',
gender: 'M',
address: 'Dev Bypass Address',
userDataSaved: false,
};
}
if (query.error) {
this.logger.warn(`Fayda callback returned error: ${query.error}`);
if (query.state) {

View File

@@ -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 makerchecker 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.