Merge branch 'dev' into freight/nati-2

Conflict in ClearanceDocumentsPage: this branch migrated the page to the
pill FilterBar, dev added filters to the Select stack it replaced. Kept
the FilterBar and carried dev's additions across as a "Booked by"
(customerKind) FilterDef plus the shipping-line search placeholder; dev's
startOfDayIso/endOfDayIso went away because dateRangeParams already does
that. The Ship icon import is needed by dev's shipping-line customer cell,
which merged cleanly on its own.
This commit is contained in:
Nathnael
2026-08-17 12:43:35 +00:00
117 changed files with 6115 additions and 830 deletions

View File

@@ -0,0 +1,9 @@
import pg from 'pg';
import fs from 'fs';
const env = Object.fromEntries(fs.readFileSync('.env','utf8').split('\n').filter(l=>/^[A-Z_]+=/.test(l)).map(l=>{const i=l.indexOf('=');return [l.slice(0,i),l.slice(i+1).replace(/^"|"$/g,'')]}));
const c = new pg.Client({host:env.DB_HOST,port:+env.DB_PORT,database:env.DB_NAME,user:env.DB_USER,password:env.DB_PASSWORD});
await c.connect();
const sql = process.argv[2];
const r = await c.query(sql);
console.log(JSON.stringify(r.rows,null,1));
await c.end();

View File

@@ -0,0 +1,72 @@
import eimsConfigFactory from "./eims.config";
const REQUIRED = {
EIMS_ENABLED: "true",
EIMS_CLIENT_ID: "cid",
EIMS_CLIENT_SECRET: "secret",
EIMS_API_KEY: "apikey",
EIMS_TIN: "0000000000",
};
const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
const prior: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(vars)) {
prior[key] = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
fn();
} finally {
for (const [key, value] of Object.entries(prior)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
};
describe("eims.config — private key / certificate resolution", () => {
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
},
);
});
it("leaves a PEM with real newlines untouched", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
},
);
});
it("throws naming all three key/cert options when none are set", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY_PATH: undefined,
EIMS_PRIVATE_KEY_BASE64: undefined,
EIMS_PRIVATE_KEY: undefined,
EIMS_CERTIFICATE_PATH: "/dev/null",
},
() => {
expect(() => eimsConfigFactory()).toThrow(
/EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
);
},
);
});
it("is satisfied by any single one of the three key options", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
expect(() => eimsConfigFactory()).not.toThrow();
},
);
});
});

View File

@@ -30,6 +30,23 @@ export interface EimsConfig {
privateKeyPath: string;
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
certificatePath: string;
/**
* Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a
* container that can't be given a host bind mount can still receive it as a plain env var.
* Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64`
* > `privateKeyPath`.
*/
privateKeyBase64: string;
/** Inline alternative to `certificatePath`, same precedence rule as the key. */
certificateBase64: string;
/**
* The PEM key pasted directly into the env var, no encoding step at all — the most direct of the
* three inline forms, and the hardest for a broken transport step to mangle since there's no
* decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set.
*/
privateKeyPem: string;
/** Inline alternative to `certificateBase64`, same precedence rule. */
certificatePem: string;
httpTimeoutMs: number;
/** Re-authenticate this many ms before the access token actually expires. */
tokenSkewMs: number;
@@ -80,7 +97,19 @@ export interface EimsInvoiceConfig {
paymentMode: string;
paymentTerm: string;
unitDefault: string;
/**
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
*/
buyerCountryCode: string | null;
/**
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
* this is not validated against a fixed digit pattern, only looked up by name.
*/
buyerCountryCodes: Record<string, string>;
/**
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
@@ -89,6 +118,14 @@ export interface EimsInvoiceConfig {
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
* mapping.
*/
buyerCityCodes: Record<string, string>;
/**
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
@@ -115,14 +152,14 @@ export interface EimsInvoiceConfig {
buyerIdNumber: string | null;
}
const REQUIRED_VARS = [
"EIMS_CLIENT_ID",
"EIMS_CLIENT_SECRET",
"EIMS_API_KEY",
"EIMS_TIN",
"EIMS_PRIVATE_KEY_PATH",
"EIMS_CERTIFICATE_PATH",
] as const;
const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
// from REQUIRED_VARS since it's "at least one of", not "this exact var".
const REQUIRED_ANY_OF: string[][] = [
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
];
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
if (raw === undefined || raw === "") return fallback;
@@ -143,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record<string, string> => {
return map;
};
// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline
// and expect the caller to write "\n" as two characters instead. If the raw value already has a
// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses.
const normalizePem = (raw: string | undefined): string => {
if (!raw) return "";
return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n");
};
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
const optionalNumber = (raw: string | undefined, name: string): number | null => {
if (raw === undefined || raw === "") return null;
@@ -169,6 +214,10 @@ export default registerAs("eims", (): EimsConfig => {
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "",
privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY),
certificatePem: normalizePem(process.env.EIMS_CERTIFICATE),
certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
httpTimeoutMs,
tokenSkewMs,
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
@@ -208,8 +257,10 @@ export default registerAs("eims", (): EimsConfig => {
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES),
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
@@ -223,7 +274,10 @@ export default registerAs("eims", (): EimsConfig => {
if (!enabled) return base;
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
for (const vars of REQUIRED_ANY_OF) {
if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
}
if (missing.length > 0) {
throw new Error(
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,

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,7 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
@@ -11,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto";
import {
ForgotPasswordService,
RESET_LINK_TTL_MS,
type ResetTicket,
} from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service";
import { isDomesticPhone, type OtpTarget } from "../otp/otp.service";
/** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget {
@@ -116,6 +116,22 @@ export class CustomerResetService {
channel: ResetChannel,
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink | null> {
const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options);
return sent[0] ?? null;
}
/**
* One ticket, several channels. Minting retires every earlier ticket for the
* user (`mintResetTicket`), so sending email and SMS as two separate mints
* makes the first link dead on arrival — the same link must go to both.
* Returns one entry per channel that was actually sent (unreachable channels
* are skipped, not errors).
*/
async sendResetLinkToUserOnChannels(
userId: string,
channels: ResetChannel[],
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink[]> {
const user = options?.allowWithoutCredential
? await this.forgotPasswordService.resolveActivatableUserById(userId)
: await this.forgotPasswordService.resolveActiveUserById(userId);
@@ -128,51 +144,58 @@ export class CustomerResetService {
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
}`,
);
return null;
return [];
}
return this.deliverResetLink(user, user.id, channel, options?.scope);
// Mint once, before any send: a failed send leaves an unused ticket that
// simply expires, whereas sending a link before the ticket exists would
// hand the customer a URL that is dead on arrival.
let ticket: ResetTicket | null = null;
const sent: SentResetLink[] = [];
for (const channel of channels) {
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) continue;
// The gateway silently drops foreign numbers — treat like a missing phone
// rather than reporting "link sent" for a message that will never arrive.
// The backoffice disables the channel up front via `phoneIsDomestic`; this
// guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
continue;
}
ticket ??= await this.forgotPasswordService.mintResetTicket(
user.id,
RESET_LINK_TTL_MS,
);
const result = await this.deliverResetLink(
target,
user.id,
channel,
ticket,
options?.scope,
);
if (result) sent.push(result);
}
return sent;
}
/**
* Shared tail: target selection → SMS reachability → mint → send → report.
* Callers have already resolved `user` to an active account.
* Shared tail: send the already-minted ticket to a resolved target → report.
*/
private async deliverResetLink(
user: User,
target: OtpTarget,
userId: string,
channel: ResetChannel,
ticket: ResetTicket,
scope?: string,
): Promise<SentResetLink | null> {
this.logger.log(
`Staff-triggered shipping line ${"link"}`,
);
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
// A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message
// that will never arrive. The backoffice disables the channel up front via
// `phoneIsDomestic`; this guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
return null;
}
// Mint first, send second: a failed send leaves an unused ticket that simply
// expires, whereas sending a link before the ticket exists would hand the
// customer a URL that is dead on arrival.
const ticket = await this.forgotPasswordService.mintResetTicket(
userId,
RESET_LINK_TTL_MS,
);
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
this.logger.log(
`Staff-triggered shipping line ${link}`,
);
const { queued } = target.email
? await this.emailClient.sendEmail({

View File

@@ -212,7 +212,9 @@ export class ForgotPasswordService {
* is the proof).
*/
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
const code = randomBytes(24).toString("base64url");
// Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has
// no "_" — gateways substitute a space and the link arrives broken.
const code = randomBytes(24).toString("hex");
const verificationCode = await hashPassword(code);
await this.dataSource.transaction(async (manager) => {

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 {
@@ -63,6 +66,23 @@ export class BillingController {
});
}
@Get("invoices/summary")
@ApiOperation({
summary:
"Total collected (paidAmount) across every filtered invoice, grouped by currency",
})
async collectedSummary(
@Query() query: FilterInvoiceDto,
@CurrentUser() user: TCurrentUser,
) {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.billingService.collectedSummary({
...query,
tradeDirections: allowed ?? undefined,
});
}
@Get("invoices/:id")
@ApiOperation({ summary: "Get an invoice with its line items" })
findById(@Param("id", ParseUUIDPipe) id: string) {
@@ -72,7 +92,7 @@ export class BillingController {
@Get("offline-usd")
@ApiOperation({
summary:
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
"Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context",
})
findOfflineUsd(@Query() query: FilterInvoiceDto) {
return this.billingService.findOfflineUsdPaginated(query);
@@ -84,7 +104,7 @@ export class BillingController {
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
"Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance",
})
confirmOffline(
@Param("id", ParseUUIDPipe) id: string,
@@ -99,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

@@ -10,14 +10,16 @@ import {
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.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 +27,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";
@@ -46,10 +49,18 @@ export interface PayInvoiceOptions {
export interface OfflineUsdBookingInfo {
id: string;
reference: string;
tradeDirection: string | null;
paymentDeadline: Date | null;
paymentStatus: string;
}
/** Row shape of the manual-payments worklist. */
export type OfflineUsdInvoiceRow = Invoice & {
booking: OfflineUsdBookingInfo | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
};
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
@@ -145,6 +156,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;
@@ -192,6 +215,40 @@ export class BillingService {
* company (customer detail "Invoices" tab) and/or status/search (global
* invoices page).
*/
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
private applyInvoiceFilters(
qb: SelectQueryBuilder<Invoice>,
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
},
) {
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
return qb;
}
async findAllPaginated(
filter: {
companyId?: string;
@@ -215,50 +272,100 @@ export class BillingService {
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
this.applyInvoiceFilters(qb, filter);
const [items, total] = await qb.getManyAndCount();
return { items, total };
return { items: await this.attachShippingLineCompanies(items), total };
}
/**
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway), open ones by default or a single status when
* filtered. Booking-sourced rows carry the booking's reference and pay-window
* deadline so the UI can show the countdown and link to the booking.
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
* entity's doc comment — so this is a second query keyed off the ids
* already loaded, same shape as `company`.
*/
private async attachShippingLineCompanies<T extends Invoice>(
invoices: T[],
): Promise<T[]> {
const ids = [
...new Set(
invoices
.map((i) => i.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return invoices;
const lines = await this.dataSource
.getRepository(ShippingLineCompany)
.find({ where: { id: In(ids) } });
const byId = new Map(lines.map((l) => [l.id, l]));
return invoices.map((invoice) => {
const line = invoice.shippingLineCompanyId
? byId.get(invoice.shippingLineCompanyId)
: undefined;
return line
? ({
...invoice,
shippingLineCompany: {
id: line.id,
name: line.name,
email: line.email,
phoneNumber: line.phoneNumber,
},
} as T)
: invoice;
});
}
/**
* Total collected (`paidAmount`) across every invoice matching the same
* filters as `findAllPaginated`, grouped by currency — unpaginated, so the
* invoices summary card reflects the whole filtered set, not just the
* visible page.
*/
async collectedSummary(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
} = {},
): Promise<Record<string, number>> {
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.paidAmount)", "collected")
.groupBy("invoice.currency");
this.applyInvoiceFilters(qb, filter);
const rows: { currency: string; collected: string }[] =
await qb.getRawMany();
return Object.fromEntries(
rows.map((row) => [row.currency, Number(row.collected) || 0]),
);
}
/**
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway) and ETB invoices Finance settles by hand (bank
* transfer / counter) instead of the customer paying online. Open ones by
* default or a single status when filtered; both currencies unless
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
* trade direction and pay-window deadline so the UI can show the countdown
* and link to the booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
currency?: "USD" | "ETB";
page?: number;
pageSize?: number;
} = {},
): Promise<{
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
total: number;
}> {
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -267,11 +374,16 @@ export class BillingService {
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) = 'USD'")
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.currency) {
qb.andWhere("UPPER(invoice.currency) = :currency", {
currency: filter.currency,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
@@ -284,7 +396,8 @@ export class BillingService {
);
}
const [items, total] = await qb.getManyAndCount();
const [rawItems, total] = await qb.getManyAndCount();
const items = await this.attachShippingLineCompanies(rawItems);
const bookingIds = items
.filter((i) => i.source === "booking")
@@ -292,11 +405,43 @@ export class BillingService {
const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
select: [
"id",
"reference",
"tradeDirection",
"paymentDeadline",
"paymentStatus",
],
})
: [];
const byId = new Map(bookings.map((b) => [b.id, b]));
// Shipping-line credit invoices bill many bookings at once; each credit
// keeps its own booking link, so collect them per invoice.
const creditInvoiceIds = items
.filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit)
.map((i) => i.id);
const credits = creditInvoiceIds.length
? await this.dataSource.getRepository(ShippingLineCredit).find({
where: { invoiceId: In(creditInvoiceIds) },
relations: { booking: true },
})
: [];
const bookingsByInvoice = new Map<
string,
OfflineUsdInvoiceRow["bookings"]
>();
for (const c of credits) {
if (!c.invoiceId || !c.booking) continue;
const list = bookingsByInvoice.get(c.invoiceId) ?? [];
list.push({
id: c.booking.id,
reference: c.booking.reference,
tradeDirection: c.booking.tradeDirection ?? null,
});
bookingsByInvoice.set(c.invoiceId, list);
}
return {
items: items.map((inv) => {
const b = byId.get(inv.sourceId);
@@ -306,19 +451,22 @@ export class BillingService {
? {
id: b.id,
reference: b.reference,
tradeDirection: b.tradeDirection ?? null,
paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus,
}
: null,
} as Invoice & { booking: OfflineUsdBookingInfo | null };
bookings: bookingsByInvoice.get(inv.id) ?? [],
} as OfflineUsdInvoiceRow;
}),
total,
};
}
/**
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
* against the invoice and settles the FULL outstanding balance through
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
* or counter payment: stores the slip against the invoice and settles the
* FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so
* the booking advances exactly as if it had been paid through the gateway.
@@ -337,11 +485,6 @@ export class BillingService {
): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.currency?.toUpperCase() !== "USD") {
throw new BadRequestException(
"Offline confirmation is only for USD invoices — this invoice is paid online.",
);
}
if (!file) {
throw new BadRequestException("The bank payment slip file is required.");
}
@@ -388,21 +531,30 @@ export class BillingService {
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
order: { createdAt: "ASC" },
});
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
/**
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
* boundary check, not a business rule.
*/
async document(
id: string,
format: "a4" | "thermal" = "a4",
): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
await this.toDocumentModel(invoice, "INVOICE"),
);
const model = await this.toDocumentModel(invoice, "INVOICE");
return format === "thermal"
? this.invoiceDocuments.renderThermal(model)
: this.invoiceDocuments.render(model);
}
/** Sealed PDF receipt; available once any payment has been recorded. */
@@ -418,15 +570,6 @@ export class BillingService {
);
}
/**
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
*/
private renderEimsQr(signedQr: string): string {
return `data:image/png;base64,${signedQr}`;
}
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
private async bookingSummaryRows(
invoice: Invoice,
@@ -542,7 +685,7 @@ export class BillingService {
currency: l.currency,
})),
totals,
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
};
}
@@ -709,11 +852,16 @@ export class BillingService {
// ── Generation ───────────────────────────────────────────────────────────────
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
/**
* `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code`
* defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent
* daily sequence (different prefix hashes to a different advisory lock, see
* `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers.
*/
private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise<string> {
return nextDailyInvoiceNumber(mg, {
table: "freight.invoices",
code: "INV",
code,
});
}
@@ -736,9 +884,123 @@ export class BillingService {
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed
* DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`,
* `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice`
* unchanged: it has no side effects (no events, no notifications, no payment records — every
* event in this service fires from `runTransition` on a *transition*, not on create), so a memo
* is just an ordinary invoice with three extra columns set.
*
* `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId`
* (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by
* `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the
* newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own
* `id` is never a value those lookups are ever queried with, so this isolates a memo from all
* of them regardless of its status — no `type`-based exclusion needed anywhere else.
*
* A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so
* leaving it payable would only add a phantom receivable that no payment flow will ever close.
* A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary
* invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is
* findable and collectible through the normal invoice list/detail/payment tooling, safe from
* the CBE/booking-linked lookups above for the `sourceId` reason just given.
*/
async issueMemo(
originalId: string,
input: IssueMemoInput,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const reason = input.reason?.trim();
if (!reason) {
throw new BadRequestException("A memo requires a reason.");
}
const original = await this.findById(originalId);
if (!original.eimsIrn) {
throw new BadRequestException({
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`,
});
}
if (original.eimsDocumentType && original.eimsDocumentType !== "INV") {
throw new BadRequestException(
`Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`,
);
}
if (original.eimsStatus === EimsInvoiceStatus.Cancelled) {
throw new BadRequestException(
`Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`,
);
}
const sourceLines = input.lines?.length ? input.lines : original.lines;
const lines: InvoiceLineInput[] = sourceLines.map((l) => ({
chargeType: l.chargeType,
description: l.description,
quantity: Number(l.quantity),
unitRate: Number(l.unitRate),
amount: Number(l.amount),
currency: l.currency,
metadata: l.metadata ?? null,
}));
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
if (!(total > 0)) {
throw new BadRequestException("A memo must have a positive total.");
}
// Only a credit note is bounded by the original — it can only give back what was charged. A
// debit note is an additional charge, not a refund, so no such ceiling applies to it (do not
// assume the credit-note ceiling is correct for DEB).
if (input.type === "CRE" && total > Number(original.totalAmount)) {
throw new BadRequestException(
`Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`,
);
}
const code = input.type === "CRE" ? "CRE" : "DEB";
const settled = input.type === "CRE";
return this.dataSource.transaction(async (mg) => {
const memo = await this.createInvoice(
{
source: original.source as Freight.InvoiceSource,
sourceId: original.id,
type: input.type === "CRE" ? "credit_note" : "debit_note",
companyId: original.companyId,
companyProfileId: original.companyProfileId,
shippingLineCompanyId: original.shippingLineCompanyId,
lines,
currency: original.currency,
subtotalAmount: total,
taxAmount: 0,
totalAmount: total,
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
},
mg,
code,
);
const patch: Record<string, unknown> = {
eimsDocumentType: input.type,
eimsReason: reason,
relatedInvoiceId: original.id,
...(settled
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
: {}),
};
await mg.update(Invoice, memo.id, patch);
this.logger.log(
`Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`,
);
return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] };
});
}
private async createInvoice(
input: GenerateInvoiceInput,
mg: EntityManager,
code = "INV",
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
@@ -786,7 +1048,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,37 @@ 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;
// Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight
// is defined as the LARGER of the content's height and the viewport's own height, so a
// receipt shorter than the viewport would otherwise report the viewport height back, not
// its true content height — a real page-length trailing blank space bug, not theoretical
// (confirmed by actually rendering one). A short viewport forces content to overflow it,
// so scrollHeight always reflects the content, never the viewport.
await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 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 +127,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 +149,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, Math.round(contentMm * 100) / 100);
}
private injectPdfPrintStyles(html: string): string {
if (html.includes("edr-pdf-print-fix")) return html;
if (html.includes("</head>")) {

View File

@@ -39,4 +39,11 @@ export class FilterInvoiceDto {
@IsOptional()
@IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus;
/** Manual-payments worklist only: restrict to one currency. */
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["USD", "ETB"])
currency?: "USD" | "ETB";
}

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

@@ -60,8 +60,11 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
buyerCountryCodes: {},
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
buyerCityCodes: {},
...over,
});
@@ -92,6 +95,9 @@ describe("toEimsInvoice", () => {
expect(doc.BuyerDetails).toEqual({
City: null,
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
// buyerCountryCode fallback, not null, per resolveCountryCode.
Country: "231",
Email: "buyer@abc.et",
HouseNumber: "NEW",
IdNumber: null,
@@ -100,7 +106,6 @@ describe("toEimsInvoice", () => {
LegalName: "ABC Trading PLC",
Phone: "0912345678",
Region: "13",
Country: null,
Zone: "SHA",
Kebele: "03",
VatNumber: "123475885858",
@@ -212,6 +217,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/,
@@ -282,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => {
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("derives City from the buyer's zone via the city code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
seller,
context({ buyerCityCodes: { Kirkos: "101" } }),
);
expect(doc.BuyerDetails.City).toBe("101");
});
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
seller,
context({ buyerCityCodes: {} }),
);
expect(doc.BuyerDetails.City).toBeNull();
});
it("maps a buyer country name to its code via the country code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
seller,
context({ buyerCountryCodes: { Djibouti: "071" } }),
);
expect(doc.BuyerDetails.Country).toBe("071");
});
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
);
expect(doc.BuyerDetails.Country).toBe("231");
});
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
),
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");

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,10 +221,26 @@ 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. */
/**
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
*/
buyerCountryCode?: string | null;
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
buyerCountryCodes: Record<string, string>;
/**
* Region name → MoR numeric code, for buyers whose stored region is free text.
*
@@ -232,9 +257,15 @@ export interface EimsMapperContext {
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
* the mapping.
*/
buyerCityCodes: Record<string, string>;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
/** Required when the invoice currency is not ETB. */
exchangeRate?: number | null;
invoiceDiscount?: number | null;
@@ -279,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string =>
* exchange rate.
*/
/**
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
* a guessed code onto a tax document is worse than refusing to file.
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
* numeric, otherwise looked up by name (case- and space-insensitive).
*
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
* document is worse than refusing to file. City is optional (`required: false`, City's own
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
* null instead of blocking the invoice.
*/
function resolveLocationCode(
field: "Region" | "Wereda",
field: "Region" | "Wereda" | "City",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
): string {
opts: { required?: boolean } = {},
): string | null {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
@@ -299,12 +335,61 @@ function resolveLocationCode(
)?.[1];
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
if (opts.required === false) return null;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
);
}
/**
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
*/
function resolveCountryCode(
country: string | null | undefined,
codes: Record<string, string>,
domesticFallback: string | null,
invoiceNumber: string,
): string | null {
const raw = (country ?? "").trim();
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped) return mapped;
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
);
}
/**
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
* name lookup, `undefined` on no match — the caller falls back to static config either way.
*/
export function resolveOptionalCode(
value: string | null | undefined,
codes: Record<string, string>,
): string | undefined {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
}
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
@@ -326,6 +411,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`);
@@ -400,7 +505,16 @@ export function toEimsInvoice(
return {
BuyerDetails: {
City: context.buyerCity ?? null,
// No dedicated city column on Company — Zone is the closest match; optional (see
// resolveLocationCode's City comment).
City: resolveLocationCode(
"City",
company.zone,
context.buyerCityCodes,
"EIMS_BUYER_CITY_CODES",
invoice.invoiceNumber,
{ required: false },
),
Email: company.email ?? null,
HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null,
@@ -415,7 +529,12 @@ export function toEimsInvoice(
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: context.buyerCountryCode ?? null,
Country: resolveCountryCode(
company.country,
context.buyerCountryCodes,
context.buyerCountryCode ?? null,
invoice.invoiceNumber,
),
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
@@ -430,7 +549,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

@@ -0,0 +1,42 @@
import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
* cut is allowed and takes the exact cargo total; over-cut is rejected; a
* partial cut stays proportional.
*/
describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
const svc = Object.create(BookingWagonCancellationService.prototype) as {
resolveRequestedCut(booking: unknown, dto: unknown): Promise<{
wagons: number;
weightTons: number;
quantities: { bulkTons?: number };
}>;
};
const booking = {
id: 'b1',
freightType: 'BULK',
wagonsRequired: 4,
cargoTotalWeightVgm: 250.5,
bulkTotalWeightTons: null,
};
it('cancels every wagon with the exact total tonnage', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
});
it('rejects more wagons than the booking has', async () => {
await expect(svc.resolveRequestedCut(booking, { wagons: 5 })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('sizes a partial cut proportionally', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 1 });
expect(cut.wagons).toBe(1);
expect(cut.weightTons).toBeCloseTo(62.625, 3);
});
});

View File

@@ -7,8 +7,9 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In } from 'typeorm';
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
import { BillingService } from '../billing/billing.service';
import { ContractBookingService } from '../contracts/contract-booking.service';
@@ -18,9 +19,11 @@ import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.en
import { FirstMileService } from '../first-mile/first-mile.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
@@ -46,10 +49,15 @@ import {
/**
* rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
* configure it in the normal rates UI; the wagon flow requires the PER_WAGON
* unit so the fee scales with the cancelled wagon count.
* configure it in the normal rates UI, one PER_WAGON rate per trade direction
* + cargo kind + type (20ft / 40ft container type, or bulk commodity), so the
* fee scales with the cancelled wagon count and differs by what was booked.
*/
export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
/** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */
const sizeFtOf = (size: string | number | null | undefined): number =>
parseInt(String(size ?? ''), 10);
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
@@ -62,8 +70,20 @@ interface RequestedCut {
quantities: CancelledQuantities;
}
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
interface PricedFee {
amount: number;
currency: string;
/** Effective per-wagon fee (amount / wagons) — one number for the customer. */
perWagon: number;
/** Rate rows used; the first is recorded on the ledger row. */
rates: Rate[];
}
/**
* Partial wagon cancellation on a PAID booking, with a rebooking credit.
* Wagon cancellation on a PAID booking (partial or whole), with a rebooking
* credit. Cutting every wagon ends the source booking CANCELLED at T2; the
* credit then rebooks as a fresh booking under the same contract.
*
* Lifecycle (one ledger row per cycle, see BookingWagonCancellation):
* T1 request — validate + price the fee, open the fee invoice. Nothing else
@@ -91,6 +111,7 @@ export class BookingWagonCancellationService {
private readonly repo: BookingWagonCancellationsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly billing: BillingService,
private readonly exchangeService: ExchangeService,
@Inject(forwardRef(() => ContractBookingService))
private readonly contractBooking: ContractBookingService,
@Inject(forwardRef(() => ClearanceMilestoneService))
@@ -120,14 +141,13 @@ export class BookingWagonCancellationService {
}> {
const booking = await this.loadCancellableBooking(bookingId);
const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
const fee = await this.priceFee(booking, cut);
return {
wagons: cut.wagons,
weightTons: cut.weightTons,
feePerWagon: Number(rate.rateValue),
feeAmount,
feeCurrency: rate.currency,
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, cut.wagons),
};
}
@@ -146,8 +166,8 @@ export class BookingWagonCancellationService {
}
const cut = await this.resolveRequestedCut(booking, dto);
const rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
const fee = await this.priceFee(booking, cut);
const feeAmount = fee.amount;
const creditAmount = this.creditFor(booking, cut.wagons);
const row = await this.repo.create({
@@ -156,9 +176,11 @@ export class BookingWagonCancellationService {
weightTons: cut.weightTons,
cancelledQuantities: cut.quantities,
creditAmount,
feeRateId: rate.id,
// ponytail: one FK for a mixed-size container cut records the first
// size's rate; the invoice line carries the effective per-wagon fee.
feeRateId: fee.rates[0].id,
feeAmount,
feeCurrency: rate.currency,
feeCurrency: fee.currency,
status: 'FEE_PENDING',
reason: dto.reason ?? null,
requestedByUserId: userId ?? null,
@@ -173,15 +195,15 @@ export class BookingWagonCancellationService {
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: rate.currency,
currency: fee.currency,
lines: [
{
chargeType: 'CANCELLATION_FEE',
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
quantity: cut.wagons,
unitRate: Number(rate.rateValue),
unitRate: fee.perWagon,
amount: feeAmount,
currency: rate.currency,
currency: fee.currency,
metadata: { wagonCancellationId: row.id },
},
],
@@ -343,12 +365,28 @@ export class BookingWagonCancellationService {
const preSplitQuantities =
booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight));
// Whole-booking cut: nothing is left to ship, so the booking ends
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
// train. The credit row still points at it for T3.
const wagonsLeft = round2(
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
const isFull = wagonsLeft <= 0;
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)),
cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)),
wagonsRequired: Math.max(0, wagonsLeft),
cargoTotalWeightVgm: Math.max(
0,
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
),
totalAmount: Math.max(
0,
round2(Number(booking.totalAmount) - Number(row.creditAmount)),
),
isSplit: true,
preSplitQuantities,
...(isFull
? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null }
: {}),
} as never);
await manager.getRepository(BookingWagonCancellation).update(row.id, {
@@ -360,11 +398,15 @@ export class BookingWagonCancellationService {
});
const booking = await this.bookingsRepository.findById(row.bookingId);
if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking);
if (booking) {
const whole = booking.status === 'CANCELLED';
this.notifyCustomer(
booking,
'Wagon cancellation confirmed',
`${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
whole
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
);
}
this.logger.log(
@@ -372,6 +414,33 @@ export class BookingWagonCancellationService {
);
}
/**
* Whole-booking cut: take the cancelled booking OFF its train entirely —
* schedule link, leftover wagon slots, window status — via the ops unassign
* path (no "removed from train" notice: the customer cancelled it). A stale
* link would keep showing the booking on the schedule AND poison every later
* auto wagon allocation on that train (the whole-train re-plan rejects a
* CANCELLED booking). Then re-run allocation so bookings held back by it
* (e.g. the rebooked credit) get their wagons.
*/
private async detachFromSchedule(booking: Booking): Promise<void> {
const links = await this.dataSource
.getRepository(TrainScheduleBooking)
.find({ where: { bookingId: booking.id } });
for (const link of links) {
try {
await this.trainScheduling.unassignBooking(link.trainScheduleId, booking.id, undefined, {
notifyCustomer: false,
});
await this.trainScheduling.tryAutoWagonAllocation(link.trainScheduleId);
} catch (err) {
this.logger.error(
`Detach of cancelled booking ${booking.reference} from schedule ${link.trainScheduleId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// ── T3: rebook ──────────────────────────────────────────────────────────────
async rebook(
@@ -401,6 +470,8 @@ export class BookingWagonCancellationService {
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
const created = await this.contractBooking.createUnderContract(
source.contractId,
createDto,
@@ -413,9 +484,13 @@ export class BookingWagonCancellationService {
// The freight is already paid (credit) — mark PAID and let the existing
// paid-booking machinery place it. No invoice is generated for it.
// Its price IS the credit (already paid, in the source currency) — not a
// fresh live-rate quote; a later cut of the rebooked booking credits from it.
await this.dataSource.getRepository(Booking).update(newBookingId, {
paymentStatus: 'PAID',
status: 'PAID',
totalAmount: Number(row.creditAmount),
paymentCurrency: source.paymentCurrency,
});
await this.copyClearanceState(source, newBookingId);
@@ -532,16 +607,16 @@ export class BookingWagonCancellationService {
const live = liveBySize.get(cut.containerSize) ?? 0;
if (cut.quantity > live) {
throw new BadRequestException(
`Cannot cancel ${cut.quantity} × ${cut.containerSize}ft — the booking only has ${live}.`,
`Cannot cancel ${cut.quantity} × ${sizeFtOf(cut.containerSize)}ft — the booking only has ${live}.`,
);
}
bySize[cut.containerSize] = cut.quantity;
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
wagons += cut.quantity * wagonsPerUnitForSize(sizeFtOf(cut.containerSize));
}
wagons = round2(wagons);
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
// Snapshot the LIFO-picked physical units up front (read-only — cargo is
@@ -577,9 +652,11 @@ export class BookingWagonCancellationService {
}
}
}
const weightShare = round3(
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
);
// Whole-booking cut takes the exact total, no ratio rounding.
const weightShare =
wagons >= totalWagons
? round3(Number(booking.cargoTotalWeightVgm))
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return {
wagons,
weightTons: weightShare,
@@ -594,17 +671,19 @@ export class BookingWagonCancellationService {
if (!wagons || wagons <= 0) {
throw new BadRequestException('Specify how many wagons to cancel.');
}
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
// Whole-booking cut: all cargo, exactly. Otherwise proportional sizing.
// ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item
// rounding happens here too; switch to items_per_wagon_map sizing if bulk
// PER_ITEM cancels ever need to be exact per item.
let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons);
const isFull = wagons >= totalWagons;
let tons = Number(booking.cargoTotalWeightVgm) * (isFull ? 1 : wagons / totalWagons);
const isPerItem = booking.bulkTotalWeightTons != null;
tons = isPerItem ? Math.floor(tons) : round3(tons);
tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons);
if (tons <= 0) {
throw new BadRequestException('The requested cut is too small to release cargo.');
}
@@ -641,19 +720,23 @@ export class BookingWagonCancellationService {
}
const wagons = allocations.length;
if (wagons >= totalWagons) {
if (wagons > totalWagons) {
throw new BadRequestException(
'That would cancel the whole bookinguse booking cancellation instead of a partial wagon cancel.',
`Cannot cancel ${wagons} wagon(s)the booking only has ${totalWagons}.`,
);
}
const isFull = wagons >= totalWagons;
if (booking.freightType !== 'CONTAINER') {
const allocated = allocations.reduce(
(s, a) => s + Number(a.allocatedWeightTons || 0),
0,
);
const tons =
allocated > 0
// Whole-booking cut takes the exact total; partial takes the wagons'
// allocated tonnage (ratio fallback when nothing is allocated yet).
const tons = isFull
? round3(Number(booking.cargoTotalWeightVgm))
: allocated > 0
? round3(allocated)
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return {
@@ -714,21 +797,83 @@ export class BookingWagonCancellationService {
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
}
private async feeRate(): Promise<Rate> {
const rate = await this.dataSource.getRepository(Rate).findOne({
/**
* Price the cut off the LIVE per-wagon cancellation rates for the booking's
* trade direction. Bulk bills the rate scoped to the booking's commodity ×
* cancelled wagons; a container cut bills each size at its own container
* type's rate × the wagons that size occupies (two 20ft share one). A
* booking owned by a shipping line prices off that line's rates only —
* standard rates are never a fallback, matching booking pricing.
*/
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
const raw = await this.priceFeeInRateCurrency(booking, cut);
// Bill in the booking's own currency (rates are configured in USD; ETB
// bookings pay ETB) — same USD→ETB conversion booking pricing applies.
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
const from = raw.currency === 'ETB' ? 'ETB' : 'USD';
if (from === target) return raw;
const fx = await this.exchangeService.getRate(from, target);
return {
...raw,
amount: round2(raw.amount * fx),
perWagon: round2(raw.perWagon * fx),
currency: target,
};
}
private async priceFeeInRateCurrency(
booking: Booking,
cut: RequestedCut,
): Promise<PricedFee> {
const rates = await this.dataSource.getRepository(Rate).find({
where: {
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
rateUnit: 'PER_WAGON',
status: 'LIVE',
tradeDirection: booking.tradeDirection,
shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(),
},
order: { createdAt: 'DESC' },
});
if (!rate) {
throw new BadRequestException(
'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).',
const missing = (scope: string): BadRequestException =>
new BadRequestException(
`No LIVE per-wagon cancellation fee is configured for ${scope} on ${booking.tradeDirection} — ask EDR to set it in the rate engine (surcharge: Cancellation).`,
);
if (booking.freightType !== 'CONTAINER') {
const rate = rates.find(
(r) => !r.containerTypeId && !!r.cargoTypeId && r.cargoTypeId === booking.cargoTypeId,
);
if (!rate) throw missing(`bulk cargo type ${booking.cargoType?.cargoTypeName ?? booking.cargoTypeId ?? '?'}`);
const amount = round2(Number(rate.rateValue) * cut.wagons);
return { amount, currency: rate.currency, perWagon: Number(rate.rateValue), rates: [rate] };
}
return rate;
// Container: split the cancelled wagons across sizes in proportion to the
// wagon-space each size's units occupy, so the total always equals
// cut.wagons (whole wagons on an allocation cut, fractional on a quantity cut).
const bySize = Object.entries(cut.quantities.bySize ?? {}).filter(([, qty]) => qty > 0);
const spaceOf = ([size, qty]: [string, number]) => qty * wagonsPerUnitForSize(sizeFtOf(size));
const totalSpace = bySize.reduce((s, e) => s + spaceOf(e), 0);
if (!bySize.length || totalSpace <= 0) throw missing('containers');
const containerTypes = await this.dataSource.getRepository(ContainerType).find();
const used: Rate[] = [];
let amount = 0;
let currency = '';
for (const entry of bySize) {
const [size] = entry;
const sizeFt = sizeFtOf(size);
const typeIds = new Set(
containerTypes.filter((ct) => Number(ct.sizeFt) === sizeFt).map((ct) => ct.id),
);
const rate = rates.find((r) => !!r.containerTypeId && typeIds.has(r.containerTypeId));
if (!rate) throw missing(`${sizeFt || '?'}ft containers`);
currency = rate.currency;
used.push(rate);
amount += Number(rate.rateValue) * cut.wagons * (spaceOf(entry) / totalSpace);
}
amount = round2(amount);
return { amount, currency, perWagon: round2(amount / cut.wagons), rates: used };
}
/**
@@ -751,7 +896,7 @@ export class BookingWagonCancellationService {
const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0);
if (live < toDrop) {
throw new BadRequestException(
`Booking changed since the request: only ${live} × ${size}ft left, cannot cancel ${toDrop}.`,
`Booking changed since the request: only ${live} × ${sizeFtOf(size)}ft left, cannot cancel ${toDrop}.`,
);
}
for (const line of lines) {
@@ -795,7 +940,7 @@ export class BookingWagonCancellationService {
});
await manager.getRepository(BookingContainer).update(line.id, {
quantity: qty - drop,
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))),
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(sizeFtOf(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm),
hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length,
reeferQuantity: keptUnits.filter((u) => u.isReefer).length,
@@ -883,7 +1028,7 @@ export class BookingWagonCancellationService {
const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await manager.getRepository(BookingContainer).update(line.id, {
quantity: kept.length,
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))),
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(sizeFtOf(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm),
hazardousQuantity: kept.filter((u) => u.isHazardous).length,
reeferQuantity: kept.filter((u) => u.isReefer).length,
@@ -902,9 +1047,9 @@ export class BookingWagonCancellationService {
booking: Booking,
tons: number,
): Promise<void> {
if (tons >= Number(booking.cargoTotalWeightVgm)) {
if (tons > Number(booking.cargoTotalWeightVgm)) {
throw new BadRequestException(
'Booking changed since the request: the cut no longer leaves any cargo.',
'Booking changed since the request: the cut exceeds the cargo left on the booking.',
);
}
if (booking.bulkTotalWeightTons != null) {

View File

@@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsService,
BookingsRepository,
BookingPricingService,
ContainerValidationService,
BookingInvoiceService,
BookingLifecycleNotifierService,
BookingTransitionService,

View File

@@ -16,6 +16,7 @@ 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';
import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
@@ -80,6 +81,8 @@ export interface BookingListFilterOptions {
originYardId?: string;
destinationYardId?: string;
isGovernment?: 'true' | 'false';
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
consolidationPaired?: string;
}
@@ -781,16 +784,20 @@ export class BookingsRepository extends BaseRepository<Booking> {
// by TypeORM and crashes).
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference')
// Shipping-line owner name for search only (no relation, see entity) —
// the list rows get `shippingLineCompany` hydrated by the service.
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = booking.shipping_line_company_id')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
// Free-text search spans joined columns (company, contract) that only this
// list query joins — so it lives here, not in applyListFilters (shared
// with getListSummaryMetrics, whose query builder has no joins).
// Free-text search spans joined columns (company, shipping line, contract)
// that only this list query joins — so it lives here, not in
// applyListFilters (shared with getListSummaryMetrics, whose query builder
// has no joins).
if (options.search) {
qb.andWhere(
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
'(booking.reference ILIKE :search OR company.name ILIKE :search OR slc.name ILIKE :search OR contract.reference ILIKE :search)',
{ search: `%${options.search}%` },
);
}
@@ -1043,6 +1050,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE');
}
if (options.customerKind === 'SHIPPING_LINE') {
qb.andWhere('booking.shipping_line_company_id IS NOT NULL');
} else if (options.customerKind === 'CUSTOMER') {
qb.andWhere('booking.shipping_line_company_id IS NULL');
}
if (omit !== 'tradeDirection' && options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,

View File

@@ -1766,6 +1766,38 @@ export class BookingsService {
pending.has(b.id);
}
this.attachPaymentDrainEnds(bookings);
await this.attachShippingLineCompanies(bookings);
}
/**
* Batched name lookup for shipping-line-owned bookings (`companyId` null,
* `shippingLineCompanyId` set). No relation on the entity — the shipping-line
* module sits above bookings — so a raw query keyed off the loaded ids fills
* `shippingLineCompany` the way `company` is filled for customers.
*/
private async attachShippingLineCompanies(bookings: Booking[]): Promise<void> {
const ids = [
...new Set(
bookings
.map((b) => b.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return;
const rows: Array<{ id: string; name: string; email: string | null; phoneNumber: string | null }> =
await this.dataSource.query(
`SELECT id, name, email, phone_number AS "phoneNumber"
FROM freight.shipping_line_companies
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r]));
for (const b of bookings) {
const line = b.shippingLineCompanyId ? byId.get(b.shippingLineCompanyId) : undefined;
if (line) {
(b as Booking & { shippingLineCompany?: typeof line }).shippingLineCompany = line;
}
}
}
/**
@@ -1822,6 +1854,7 @@ export class BookingsService {
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired,
// DTO carries 'true'/'false' strings (query params); the repo option is a
// real boolean — convert, preserving "not filtered" when absent.
@@ -2048,6 +2081,7 @@ export class BookingsService {
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired,
};
@@ -2134,6 +2168,8 @@ export class BookingsService {
{ path: "booking" },
);
await this.attachShippingLineCompanies([booking]);
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {

View File

@@ -67,9 +67,16 @@ export class ContainerValidationService {
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
if (!has20ft) return [];
const units = await this.load20ftUnits(booking);
if (units.length < 2) return [];
return this.validate20ftPairingUnits(await this.load20ftUnits(booking));
}
/**
* Same rule over units that are not (yet) persisted — a completion payload
* being previewed or submitted. Shipping-line completion uses this: its
* cargo only hits the DB after the check passes.
*/
async validate20ftPairingUnits(units: Container20ftUnit[]): Promise<PairingViolation[]> {
if (units.length < 2) return [];
const maxDiff = await this.maxPairDiffTons();
return validate20ftWeightPairing(units, maxDiff);
}

View File

@@ -111,6 +111,14 @@ export class FilterBookingDto {
@IsIn(['true', 'false'])
isGovernment?: 'true' | 'false';
@ApiPropertyOptional({
enum: ['SHIPPING_LINE', 'CUSTOMER'],
description: 'Who booked: a shipping line (owned by shipping_line_company_id) or an ordinary customer company',
})
@IsOptional()
@IsIn(['SHIPPING_LINE', 'CUSTOMER'])
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
@ApiPropertyOptional({
enum: ['true', 'false'],
description: 'Filter customs vs self-clearance (non-customs) bookings',
@@ -140,7 +148,7 @@ export class FilterBookingDto {
@ApiPropertyOptional({
description:
'Free-text search across booking reference, company name, and contract reference.',
'Free-text search across booking reference, customer / shipping-line company name, and contract reference.',
})
@IsOptional()
@Transform(({ value }) =>

View File

@@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
// Consumed by NotificationInboxModule for portal recipient targeting.
ExternalProfileRepository,
CompanyProfileRepository,
// Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup
// already used for every customer company at onboarding, reused for EDR's own TIN.
ETradeService,
],
})
export class CompaniesModule { }

View File

@@ -110,6 +110,7 @@ function makeService(overrides?: {
.fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
);
return {

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import {
ContractDocPhase,
isDeliveryOrderFileCode,
@@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -155,6 +157,7 @@ export class BookingClearanceService {
private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -988,7 +991,39 @@ export class BookingClearanceService {
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
return filtered;
return this.attachContractSummary(filtered);
}
/**
* Queue rows show the parent contract's reference and lane. Booking has no
* contract relation, and a bare initiated instance may not carry yards yet —
* so batch-load the contracts (with routes) and fill in what's missing:
* `contractReference` always, origin/destination yards only when the booking
* lacks them (its own route wins).
*/
private async attachContractSummary(bookings: Booking[]): Promise<Booking[]> {
const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[];
if (!ids.length) return bookings;
const contracts = await this.contractsRepository.findAll({
where: { id: In(ids) },
relations: { routes: { originYard: true, destinationYard: true } },
});
const byId = new Map(contracts.map((c) => [c.id, c]));
for (const b of bookings) {
const contract = b.contractId ? byId.get(b.contractId) : undefined;
if (!contract) continue;
const row = b as Booking & { contractReference?: string | null };
row.contractReference = contract.reference ?? null;
if (b.originYard && b.destinationYard) continue;
const routes = contract.routes ?? [];
const route =
routes.find((r) => r.id === b.contractRouteId) ??
(routes.length === 1 ? routes[0] : undefined);
if (!route) continue;
b.originYard = b.originYard ?? route.originYard;
b.destinationYard = b.destinationYard ?? route.destinationYard;
}
return bookings;
}
async djQueue(): Promise<Booking[]> {
@@ -1008,6 +1043,6 @@ export class BookingClearanceService {
filtered.push(b);
}
}
return filtered;
return this.attachContractSummary(filtered);
}
}

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

@@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { EimsConfigException } from "./eims.errors";
const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/;
/**
* A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the
* first line. Never the actual key material — PEM headers aren't secret, the base64 body is.
*/
const describeBytes = (bytes: Buffer): string => {
const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?");
return `${bytes.length} bytes, starts with "${preview}"`;
};
/**
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
*
@@ -24,25 +35,61 @@ export class EimsCredentialsProvider {
return this.config.get<EimsConfig>("eims")!;
}
/** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */
/**
* RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM
* text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a
* literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if
* none is usable.
*/
getPrivateKey(): KeyObject {
if (this.privateKey) return this.privateKey;
const path = this.cfg.privateKeyPath;
if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg;
const source = privateKeyPem
? "EIMS_PRIVATE_KEY"
: privateKeyBase64
? "EIMS_PRIVATE_KEY_BASE64"
: `EIMS_PRIVATE_KEY_PATH (${path})`;
if (!privateKeyPem && !privateKeyBase64 && !path) {
throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
}
let bytes: Buffer;
try {
bytes = privateKeyPem
? Buffer.from(privateKeyPem, "utf8")
: privateKeyBase64
? Buffer.from(privateKeyBase64, "base64")
: readFileSync(path);
} catch (err) {
throw new EimsConfigException(
`EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
);
}
// Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own
// error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a
// genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes.
if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) {
throw new EimsConfigException(
`EIMS private key from ${source} does not look like a PEM key after decoding ` +
`(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` +
`or truncation, and not base64 applied twice.`,
);
}
let key: KeyObject;
try {
key = createPrivateKey(readFileSync(path));
key = createPrivateKey(bytes);
} catch (err) {
// The path is operational information, not a secret; the key material never appears.
// The source is operational information, not a secret; the key material never appears.
throw new EimsConfigException(
`EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`,
`EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
);
}
if (key.asymmetricKeyType !== "rsa") {
throw new EimsConfigException(
`EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
`EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
);
}
@@ -51,11 +98,26 @@ export class EimsCredentialsProvider {
return key;
}
/** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */
/**
* Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued.
* `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the
* pasted text respectively); otherwise read from `certificatePath`.
*/
getCertificateBase64(): string {
if (this.certificateBase64) return this.certificateBase64;
const path = this.cfg.certificatePath;
const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg;
if (pem) {
this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64");
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`);
return this.certificateBase64;
}
if (inline) {
this.certificateBase64 = inline;
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`);
return this.certificateBase64;
}
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
let bytes: Buffer;

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 {
@@ -200,11 +206,16 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerCountryCodes: invoice.buyerCountryCodes,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
buyerCityCodes: invoice.buyerCityCodes,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,
exchangeRate: input.exchangeRate ?? null,
documentType: input.documentType,
reason: input.reason ?? null,
relatedDocument: input.relatedDocument ?? null,
};
}

View File

@@ -10,8 +10,10 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
@@ -172,6 +174,9 @@ const build = (
} as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService,
{ directSend } as unknown as NotificationsService,
// Same static-config seller the real EimsSellerCacheService falls back to when it has never
// successfully fetched e-Trade — matches prior behavior for every test in this file.
{ getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService,
);
/**
@@ -295,6 +300,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());
@@ -422,6 +479,59 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest
.fn()
.mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed"));
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsConfigException,
);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Failed,
eimsIrn: null,
eimsLastError: expect.objectContaining({ kind: "CONFIG" }),
});
expect(db.state).toMatchObject({
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 7,
});
});
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
]);
const postSigned = jest.fn();
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
// known exception types.
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR country code mapping/,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Failed,
eimsLastError: expect.objectContaining({ kind: "LOCAL" }),
});
expect(db.state).toMatchObject({
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 7,
});
});
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });

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,
@@ -25,13 +26,10 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import {
assertEimsInvoiceConfig,
buildEimsContext,
buildEimsSeller,
} from "./eims-invoice-context";
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import {
EimsInvoiceError,
EimsInvoiceStatus,
@@ -82,6 +80,7 @@ export class EimsInvoiceRegistrationService {
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
private readonly sellerCache: EimsSellerCacheService,
) {}
private get cfg(): EimsConfig {
@@ -96,6 +95,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();
@@ -103,24 +123,29 @@ export class EimsInvoiceRegistrationService {
const reservation = await this.reserve(invoiceId, session.systemNumber);
if (!reservation) return this.getEimsStatus(invoiceId);
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
const request = toEimsInvoice(
invoice,
buildEimsSeller(cfg),
buildEimsContext(cfg, {
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
}),
);
let irn: string;
let ackDate: string | undefined;
let signedQR: string | undefined;
try {
// The request can only be built now: InvoiceCounter and PreviousIrn come from the
// reservation. Building it — and everything after — stays inside this try: a reservation is
// held from here on, and *any* failure past this point, mapper or wire, must release it
// through settleFailure rather than leave it orphaned as a permanent system-wide block.
const request = toEimsInvoice(
invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
documentType,
reason: invoice.eimsReason,
relatedDocument,
}),
);
// Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request);
irn = result.irn;
@@ -446,6 +471,15 @@ export class EimsInvoiceRegistrationService {
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
*
* Any error that is *not* an `EimsApiException` is also deterministic, on a different basis:
* every error that actually touches the wire is normalized to `EimsApiException` before it gets
* here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call
* threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` —
* pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by
* touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer
* country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before
* any HTTP call went out, and releasing the reservation is always safe, never a guess.
*/
private async settleFailure(
invoiceId: string,
@@ -453,10 +487,12 @@ export class EimsInvoiceRegistrationService {
err: unknown,
): Promise<void> {
const api = err instanceof EimsApiException ? err : null;
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
// Never touched the wire (see the doc comment above) — always safe to release, whatever it is.
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
const lastError: EimsInvoiceError = {
kind: api?.kind ?? "UNKNOWN",
kind: api?.kind ?? localKind,
message: (err as Error)?.message ?? "unknown error",
httpStatus: api?.httpStatus,
details: api?.details,
@@ -563,10 +599,14 @@ export class EimsInvoiceRegistrationService {
type: NotificationType.GENERIC,
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
title: deterministic
? "EIMS rejected an invoice"
? error.kind === "CONFIG" || error.kind === "LOCAL"
? "EIMS filing failed before reaching MoR"
: "EIMS rejected an invoice"
: "EIMS filing unresolved — all further filing is blocked",
body: deterministic
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
? error.kind === "CONFIG" || error.kind === "LOCAL"
? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.`
: `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
@@ -639,7 +679,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

@@ -0,0 +1,155 @@
import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
mobilePhone: "0911000000",
regularPhone: "",
...over,
});
const build = (cfg: EimsConfig = eimsConfig()) => {
const resolveCompanyData = jest.fn();
const extractRegistrationData = jest.fn().mockReturnValue(registrationData());
const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService;
const config = { get: () => cfg } as unknown as ConfigService;
const service = new EimsSellerCacheService(etrade, config);
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
companyInfo: {},
businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked
});
await service.refresh();
const seller = service.getSellerDetails(cfg);
// The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's
// differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)".
expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C.");
});
it("e-Trade fills a field only when the static value is blank", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({
sellerLegalName: "",
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await service.refresh();
const seller = service.getSellerDetails(cfg);
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await service.refresh();
const seller = service.getSellerDetails(cfg);
expect(seller.VatNumber).toBe("0000000000");
expect(seller.Email).toBe("finance@example.et");
});
it("falls back to the static config entirely when e-Trade has never been reachable", () => {
const cfg = eimsConfig();
const { service } = build(cfg);
// No refresh() ever called/succeeded — cached stays null.
const seller = service.getSellerDetails(cfg);
expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName);
expect(seller.Region).toBe(cfg.invoice.sellerRegion);
});
it("does no I/O at all — filing never triggers an e-Trade request", () => {
const { service, resolveCompanyData, cfg } = build();
service.getSellerDetails(cfg);
service.getSellerDetails(cfg);
expect(resolveCompanyData).not.toHaveBeenCalled();
});
});
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down"));
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
});
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves
const refreshing = service.refresh();
await jest.advanceTimersByTimeAsync(10_000);
await refreshing;
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
} finally {
jest.useRealTimers();
}
});
it("does not start a second e-Trade request while one is already in flight", async () => {
const { service, resolveCompanyData } = build();
let resolveCall: (value: unknown) => void = () => {};
resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve)));
const first = service.refresh();
const second = service.refresh();
resolveCall({ companyInfo: {}, businessInfo: {} });
await Promise.all([first, second]);
expect(resolveCompanyData).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,147 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
import { buildEimsSeller } from "./eims-invoice-context";
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
/**
* EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same
* e-Trade business-registry lookup already used for every customer company at onboarding — instead
* of the whole thing being hand-maintained `EIMS_SELLER_*` config.
*
* **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates
* `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested
* with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a
* value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the
* e-Trade-derived value when the static one is blank; a static value, once set, is never replaced.
* This also means the durable fallback is the static config, not this cache — the in-memory
* snapshot disappearing on a process restart is harmless, not a reliability gap: every field it
* could supply already has a working static value today, so filing is unaffected either way.
*
* `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response
* shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field
* exists anywhere in what e-Trade returns. They stay on static config permanently, same as
* `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch.
*
* Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/
* position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain
* field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a
* refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate
* deviations from that precedent, both because `ETradeService` has no request timeout configured
* at all (confirmed by reading it) and is a third-party dependency, unlike the DB:
* - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot;
* - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight
* returns the same in-flight promise instead of starting a duplicate request.
*
* `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration
* never depends on e-Trade being reachable at that moment, whether or not it ever has been.
*/
@Injectable()
export class EimsSellerCacheService implements OnModuleInit {
private readonly logger = new Logger(EimsSellerCacheService.name);
/** Only the e-Trade-derived fields, used solely to fill a blank static value. */
private cached: Partial<EimsSellerDetails> | null = null;
/** Concurrency guard — a second `refresh()` call while one is running joins it. */
private refreshing: Promise<void> | null = null;
// ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a
// filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever
// matters; EDR's own business registration changes rarely enough that this is a generous
// ceiling, not a real one.
private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Bounded locally since `ETradeService` itself sets none — see the class comment. */
private static readonly REFRESH_TIMEOUT_MS = 10_000;
constructor(
private readonly etrade: ETradeService,
private readonly config: ConfigService,
) {}
onModuleInit(): void {
void this.refresh();
const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS);
timer.unref?.();
}
/**
* Static config wins whenever it's non-blank — that's the value already confirmed against MoR.
* e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on
* every registration.
*/
getSellerDetails(cfg: EimsConfig): EimsSellerDetails {
const fallback = buildEimsSeller(cfg);
const e = this.cached;
return {
...fallback,
LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName),
Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone),
Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region),
Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda),
City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City),
};
}
/** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */
async refresh(): Promise<void> {
if (this.refreshing) return this.refreshing;
this.refreshing = this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(): Promise<void> {
try {
const cfg = this.config.get<EimsConfig>("eims")!;
const { companyInfo, businessInfo } = await this.withTimeout(
this.etrade.resolveCompanyData(cfg.tin),
EimsSellerCacheService.REFRESH_TIMEOUT_MS,
);
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
const codes = cfg.invoice;
this.cached = {
LegalName: data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
// getSellerDetails' static-config fallback.
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
};
} catch (err) {
this.logger.warn(
`EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`,
);
}
}
/**
* `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only
* stops *waiting* on the request — nothing cancels the underlying HTTP call (no
* `AbortController` wired into `ETradeService`), so a timed-out request may still complete in
* the background; its result is simply never read.
*/
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
},
);
});
}
}

View File

@@ -97,8 +97,14 @@ describe("EimsSignerService", () => {
});
describe("EimsCredentialsProvider", () => {
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
const providerFor = (cfg: {
privateKeyPath?: string;
certificatePath?: string;
privateKeyBase64?: string;
certificateBase64?: string;
privateKeyPem?: string;
certificatePem?: string;
}) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService);
it("fails clearly when the key path is unset", () => {
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
@@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => {
writeFileSync(emptyPath, "");
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
});
it("loads the key from inline base64, no file involved", () => {
const keyBase64 = readFileSync(keyPath).toString("base64");
const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("prefers inline base64 over the path when both are set", () => {
const keyBase64 = readFileSync(keyPath).toString("base64");
// A path that would fail if it were ever actually read.
const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("loads the certificate from inline base64 as-is, no re-encoding", () => {
const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64");
expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64);
});
it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => {
// Simulates the real failure this guards against: a truncated/mangled env var still decodes
// as *some* bytes, but not a key — OpenSSL's own error here gives no hint why.
const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64");
expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow(
/does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s,
);
});
it("loads the key from the raw PEM env var directly, no encoding step", () => {
const pem = readFileSync(keyPath).toString("utf8");
const key = providerFor({ privateKeyPem: pem }).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("prefers the raw PEM var over base64 and path when all three are set", () => {
const pem = readFileSync(keyPath).toString("utf8");
const key = providerFor({
privateKeyPem: pem,
privateKeyBase64: Buffer.from("garbage").toString("base64"),
privateKeyPath: join(dir, "nope.key"),
}).getPrivateKey();
expect(key.asymmetricKeyType).toBe("rsa");
});
it("loads the certificate from the raw PEM env var, re-encoded to base64", () => {
const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64();
expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"));
});
});

View File

@@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},
@@ -57,6 +59,10 @@ export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
systemType: EIMS_SYSTEM_TYPE,
privateKeyPath: "/dev/null",
certificatePath: "/dev/null",
privateKeyBase64: "",
certificateBase64: "",
privateKeyPem: "",
certificatePem: "",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
autoSubmit: false,

View File

@@ -10,7 +10,9 @@ export type EimsFailureKind =
| "FORBIDDEN"
| "RULE_VALIDATION"
| "SERVER"
| "UNKNOWN";
| "UNKNOWN"
| "CONFIG"
| "LOCAL";
/** Raised when EIMS is disabled or its credential files are unusable. */
export class EimsConfigException extends ServiceUnavailableException {

View File

@@ -3,6 +3,8 @@ 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 { CompaniesModule } from "../companies/companies.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service";
@@ -13,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsInvoiceController } from "./eims-invoice.controller";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSignerService } from "./eims-signer.service";
import { EimsReceipt } from "./entities/eims-receipt.entity";
import { EimsSystemState } from "./entities/eims-system-state.entity";
@@ -29,6 +32,13 @@ 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,
// For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with
// ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule,
// so this stays a plain one-directional import, not a new cycle.
CompaniesModule,
],
controllers: [EimsInvoiceController],
providers: [
@@ -40,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
EimsAutoSubmitService,
EimsCancellationService,
EimsReceiptService,
EimsSellerCacheService,
],
exports: [
EimsAuthService,

View File

@@ -53,7 +53,7 @@ export class CreateRateDto {
@ApiPropertyOptional({
enum: CARGO_KINDS,
description:
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
'Whether a customs clearance / cancellation rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or CANCELLATION. Not stored — container fees carry a containerTypeId, bulk fees a cargoTypeId.',
})
@IsOptional()
@IsIn([...CARGO_KINDS])

View File

@@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => {
).toEqual(["PER_TON"]);
});
it("bills the wagon cancellation fee per wagon only, whatever the cargo kind", () => {
for (const cargoKind of ["CONTAINER", "BULK"] as const) {
expect(
allowedRateUnits({ appliesTo: "OTHER", trigger: "CANCELLATION", cargoKind }),
).toEqual(["PER_WAGON"]);
}
expect(
allowedRateUnits({
appliesTo: "OTHER",
trigger: "CANCELLATION",
cargoKind: "BULK",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_WAGON"]);
});
it("treats per-ton and per-item as the same booking quantity", () => {
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);

View File

@@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
* Which rate units make sense for a given rate shape. The weighting basis is
* driven by the *type* of thing being billed — a container leg bills per
* container, bulk freight per ton, an intercity move can be per-km, a
* cancellation is a flat/per-invoice fee, and overweight is always per excess
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* cancellation is a per-wagon fee, and overweight is always per excess ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* only pick a unit the pricing engine knows how to apply.
*
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
@@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
@@ -64,7 +63,9 @@ function unitsForShape(input: {
// wagon the empties ride back on, or a flat fee.
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
// Wagon cancellation fee — scales with the cancelled wagon count, so
// per wagon is the only unit the wagon-cancel flow can apply.
return ['PER_WAGON'];
case 'CUSTOMS_CLEARANCE':
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.

View File

@@ -25,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
/**
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
*/
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'CANCELLATION',
'WITH_RETURN',
'LASHING',
'FUEL',
];
/** The yard pair a rate scopes to, already validated against its direction. */
interface YardScope {
@@ -152,7 +165,11 @@ export class RatesService {
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
): boolean {
return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING';
return (
this.isRouteScoped(appliesTo, trigger) ||
trigger === 'LASHING' ||
trigger === 'CANCELLATION'
);
}
/**
@@ -244,10 +261,13 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') {
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
// Both fees are sold per direction + cargo kind + type: customs clearance
// per lane, the wagon cancellation fee per direction only.
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
`A ${fee} rate must say whether it covers IMPORT or EXPORT.`,
);
}
// Sold per cargo kind: a container fee names the container type it covers
@@ -255,29 +275,29 @@ export class RatesService {
// that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers containers or bulk.',
`A ${fee} rate must say whether it covers containers or bulk.`,
);
}
if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException(
'A container customs clearance rate must name the container type it covers.',
`A container ${fee} rate must name the container type it covers.`,
);
}
if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate cannot be scoped to a container type.',
`A bulk ${fee} rate cannot be scoped to a container type.`,
);
}
// The bulk customs fee names the commodity it covers (sugar and
// fertilizer clear differently).
// The bulk fee names the commodity it covers (sugar and fertilizer
// clear — and cancel — differently).
if (cargoKind === 'BULK' && !cargoTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate must name the bulk cargo type it covers.',
`A bulk ${fee} rate must name the bulk cargo type it covers.`,
);
}
if (cargoKind === 'CONTAINER' && cargoTypeId) {
throw new BadRequestException(
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
`A container ${fee} rate cannot be scoped to a bulk cargo type.`,
);
}
return;
@@ -547,22 +567,21 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance and empty-container return keep direction +
// container type — both are sold per lane (and per container type).
// Exceptions: the directed surcharges (customs clearance, cancellation,
// empty-container return, lashing, fuel) keep direction + cargo scope.
const isSurcharge = trigger !== 'ALWAYS';
const cargoKind =
trigger === 'CUSTOMS_CLEARANCE'
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const containerTypeId =
trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null)
: isSurcharge
? null
: (dto.containerTypeId ?? null);
const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.cargoTypeId ?? null)
@@ -574,10 +593,7 @@ export class RatesService {
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
// nothing about the direction.)
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY'
? null
@@ -758,16 +774,15 @@ export class RatesService {
// A patch that leaves the cargo kind unsaid keeps the one the rate already
// has — read back off its container scope (container fees carry the type).
const cargoKind =
trigger !== 'CUSTOMS_CLEARANCE'
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger)
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const keepsContainerType =
!isSurcharge ||
trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType
? null
: dto.containerTypeId !== undefined
@@ -775,7 +790,7 @@ export class RatesService {
: existing.containerTypeId;
const keepsCargoType =
!isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
trigger === 'LASHING' ||
trigger === 'FUEL';
const cargoTypeId = !keepsCargoType
@@ -784,10 +799,7 @@ export class RatesService {
? dto.cargoTypeId
: existing.cargoTypeId;
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
? dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection

View File

@@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => {
previewTrainSchedule: jest.fn(),
unassignBooking: jest.fn(),
assignBookingsToSchedule: jest.fn(),
windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}),
emitWindowState: jest.fn().mockResolvedValue(undefined),
};
schedulingRescheduleRepository = {
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
@@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => {
});
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
// An OPEN window's close must follow the new departure (this is the
// portal's "closes in" countdown) — the derived fields ride along with the
// date write.
const newCloses = new Date('2099-06-22T08:00:00.000Z');
trainSchedulingService.windowFieldsForNewDeparture.mockResolvedValue({
windowClosesAt: newCloses,
});
const result = await service.maintenanceReschedule(
'sched-1',
@@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => {
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
'sched-1',
'DRAFT',
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
{
scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'),
windowClosesAt: newCloses,
},
txManager,
);
expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith(
schedule,
new Date('2099-06-22T10:00:00.000Z'),
);
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
trigger: 'TRAIN_MAINTENANCE',

View File

@@ -227,17 +227,25 @@ export class SchedulingRescheduleService {
// through this manager without editing TrainSchedulingService. A failure
// between those steps and this block can still leave partial state; a human
// must finish the full cross-service transaction threading.
// The booking window must follow the new departure (an OPEN window's
// "closes in" countdown is capped at departure close offset; PRE_WINDOW /
// DONE re-derive their open/close). Same math as maintenanceReschedule.
const windowFields = newDeparture
? await this.trainSchedulingService.windowFieldsForNewDeparture(
schedule,
newDeparture,
)
: {};
await this.dataSource.transaction(async (manager) => {
if (newDeparture) {
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
// delegate to TrainSchedulingService.updateScheduleDate, which only
// permits a date change while windowPhase === 'PRE_WINDOW' and would
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
// the booking-window fields are NOT re-derived for the new date here.
// Raw write of scheduledDepartureDate: updateScheduleDate only permits a
// date change while windowPhase === 'PRE_WINDOW' and would reject
// reschedules of already-open (SCHEDULED) trains.
await this.trainSchedulesRepository.updateStatus(
scheduleId,
schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: newDeparture },
{ scheduledDepartureDate: newDeparture, ...windowFields },
manager,
);
}
@@ -263,6 +271,7 @@ export class SchedulingRescheduleService {
// `newDeparture` is null when the date was unchanged, so retained customers
// are not falsely told the train was rescheduled.
await this.notifyRescheduleOutcome(dto, newDeparture);
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
return { plan, schedule: assignResult };
}

View File

@@ -10,6 +10,8 @@ import { In, Repository } from "typeorm";
import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service";
import type { Container20ftUnit } from "../bookings/container-pairing.util";
import { ContainerValidationService } from "../bookings/container-validation.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
import { Booking } from "../bookings/entities/booking.entity";
@@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService {
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly creditsService: ShippingLineCreditsService,
private readonly containerValidationService: ContainerValidationService,
) {}
/**
* 20ft weight-pairing check over the completion payload — the same rule the
* customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t):
* two 20ft sharing a wagon must be within the cap. Preview surfaces the
* messages; completion hard-blocks on them. Runs off the DTO so nothing is
* persisted before the check passes.
*/
private async pairingViolationMessages(
dto: CompleteShippingLineBookingDto,
): Promise<string[]> {
const units: Container20ftUnit[] = [];
for (const line of dto.containers ?? []) {
const containerType = await this.resolveContainerType(line);
if (containerType.sizeFt !== 20) continue;
(line.units ?? []).forEach((u, idx) =>
units.push({
label: u.containerNumber || `20ft-${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
}),
);
}
const violations =
await this.containerValidationService.validate20ftPairingUnits(units);
return violations.map((v) => v.message);
}
/** Same session→owner resolution every shipping-line entry point uses. */
private async requireShippingLine(userId: string) {
const shippingLine =
@@ -193,6 +222,17 @@ export class ShippingLineBookingCompletionService {
);
}
// Unbalanced 20ft pairs can never be planned onto wagons — refuse before
// any cargo/credit write below. Same block the contract path applies.
if (booking.freightType === "CONTAINER") {
const pairing = await this.pairingViolationMessages(dto);
if (pairing.length) {
throw new BadRequestException(
`Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`,
);
}
}
// Completion is booking time. A lane with trains DEDICATED to this line
// has no window concept at all: the line books whenever it wants until the
// train's close offset. Only a lane with no dedicated train falls back to
@@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService {
computed.appliedModifiers,
);
// Pairing is reported, not thrown: the confirm modal shows it next to the
// price (as the customer form does) and disables confirm; /complete
// hard-blocks the same payload.
const pairingErrors =
booking.freightType === "CONTAINER"
? await this.pairingViolationMessages(dto)
: [];
return {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
overweightLines: computed.overweightLines,
pairingErrors,
};
}

View File

@@ -132,37 +132,33 @@ export class ShippingLineCompaniesService {
* Email always goes out — it is required at registration and is the only
* channel guaranteed to reach a foreign-registered line. SMS is sent in
* addition when the number is domestic, since the gateway silently drops
* anything else (see `CustomerResetService`). Two links are two independent
* single-use tickets; whichever the line opens first works.
* anything else (see `CustomerResetService`). Both carry the SAME single-use
* ticket: minting retires earlier tickets, so two mints would kill the email
* link the moment the SMS went out.
*
* Reports the email send, as that is the one that is always attempted.
*/
async sendActivationLink(shippingLine: ShippingLineCompany) {
const scope = `shipping line ${shippingLine.id}`;
const channels = [ResetChannel.Email];
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
channels.push(ResetChannel.Phone);
}
const emailed = await this.customerResetService.sendResetLinkToUser(
const sent = await this.customerResetService.sendResetLinkToUserOnChannels(
shippingLine.userId,
ResetChannel.Email,
channels,
{ scope, allowWithoutCredential: true },
);
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
if (!emailed) {
this.logger.error(
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
);
}
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
const texted = await this.customerResetService.sendResetLinkToUser(
shippingLine.userId,
ResetChannel.Phone,
{ scope, allowWithoutCredential: true },
);
if (!texted) {
this.logger.warn(
`Activation SMS not sent for shipping line ${shippingLine.id}`,
);
}
if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) {
this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`);
}
return emailed;

View File

@@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
import {
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res,
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import {
@@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto";
import {
DispatchScheduleDto,
RecordCheckpointDto,
UpdateCheckpointDto,
} from "../dto/record-checkpoint.dto";
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
@@ -516,9 +520,14 @@ export class TrainSchedulingController {
@Post("schedules/:id/dispatch")
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
@ApiOperation({ summary: "Dispatch a scheduled train" })
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
@ApiOperation({
summary: "Dispatch a scheduled train (optional actual departure time, past allowed)",
})
dispatchSchedule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: DispatchScheduleDto,
) {
return this.trainSchedulingService.dispatchSchedule(id, dto);
}
@Get("intercity/bookings")
@@ -956,6 +965,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.recordCheckpoint(id, dto);
}
@Patch("schedules/:id/checkpoints/:sequenceNo")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Edit a logged leg's time/note (no side effects; allowed while dispatched or after arrival)",
})
updateCheckpoint(
@Param("id", ParseUUIDPipe) id: string,
@Param("sequenceNo", ParseIntPipe) sequenceNo: number,
@Body() dto: UpdateCheckpointDto,
) {
return this.trainSchedulingService.updateCheckpoint(id, sequenceNo, dto);
}
@Post("schedules/:id/arrive")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -10,8 +10,6 @@ import {
Min,
} from 'class-validator';
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt()
@@ -24,17 +22,17 @@ export class RecordCheckpointDto {
kind?: TrainCheckpointKind;
/**
* A checkpoint records where the train is as staff observe it, and the final
* one arrives the schedule — so a backdated value rewrites the journey after
* the fact. Only "now" is accepted; omit the field and the service stamps it.
* When the train was actually at the station — staff often log after the
* fact, so a past value is allowed. The service rejects the future and any
* value out of order with the neighbouring legs.
*/
@ApiProperty({
required: false,
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
description:
'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.',
})
@IsOptional()
@IsISO8601()
@IsNotBackdated()
occurredAt?: string;
@ApiProperty({ required: false })
@@ -43,3 +41,30 @@ export class RecordCheckpointDto {
@MaxLength(500)
note?: string;
}
/** Edit an already-logged leg's time/note — no side effects (no unload, no arrival). */
export class UpdateCheckpointDto {
@ApiProperty({
required: false,
description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.',
})
@IsOptional()
@IsISO8601()
occurredAt?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string | null;
}
export class DispatchScheduleDto {
@ApiProperty({
required: false,
description: 'Actual departure time; defaults to now. Past allowed, future rejected.',
})
@IsOptional()
@IsISO8601()
actualDepartureAt?: string;
}

View File

@@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => {
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
beforeEach(() => {
// findGroupSiblings runs a query builder off dataSource.manager; default it
@@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => {
findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
update: jest.fn(),
maxReferenceSequence: jest.fn().mockResolvedValue(0),
};
trainScheduleBookingsRepository = {
@@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => {
findAll: jest.fn().mockResolvedValue([]),
};
const trainCheckpointEventsRepository = {
trainCheckpointEventsRepository = {
findBySchedule: jest.fn().mockResolvedValue([]),
findAll: jest.fn().mockResolvedValue([]),
create: jest.fn(),
@@ -1638,6 +1640,61 @@ describe('TrainSchedulingService', () => {
});
});
describe('updateCheckpoint — leg time correction', () => {
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
const schedule = {
id: 'sch-track',
status: 'ARRIVED',
routeId: null,
originStationId: 'y0',
destinationStationId: 'y1',
actualDepartureAt: t(8),
};
const events = () => [
{ id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) },
{ id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) },
];
beforeEach(() => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events());
});
it('rejects a leg time earlier than the previous leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }),
).rejects.toThrow(/cannot be earlier than/);
expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled();
});
it('rejects a leg time later than the next leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }),
).rejects.toThrow(/cannot be later than/);
});
it('rejects a future time', async () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: future }),
).rejects.toThrow(/future/);
});
it('accepts an in-order past time and re-stamps arrival for the final leg', async () => {
await service.updateCheckpoint('sch-track', 1, {
occurredAt: t(11).toISOString(),
note: 'late log',
});
expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', {
occurredAt: t(11),
note: 'late log',
});
expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', {
actualArrivalAt: t(11),
});
});
});
describe('effectiveWagonsRequired', () => {
const effective = (booking: unknown): number =>
(service as never as { effectiveWagonsRequired(b: unknown): number })
@@ -1764,5 +1821,31 @@ describe('TrainSchedulingService', () => {
expect(written.windowPhase).toBeUndefined();
expect(written.windowOpensAt).toBeUndefined();
});
it('moves an OPEN export close to the new departure but keeps the open', async () => {
const opensAt = new Date('2027-06-19T03:00:00.000Z');
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
doneExportSchedule({
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
windowOpensAt: opensAt,
windowClosesAt: new Date('2027-06-20T03:00:00.000Z'),
}),
);
// Departure pushed 3 days later → close = new departure 120min; the
// open customers already booked against stays untouched.
await service.maintenanceReschedule('sch-done', {
newDepartureDate: '2027-06-23T05:00:00.000Z',
} as never);
const written = scheduleUpdate.mock.calls[0][1];
expect(written.scheduledDepartureDate).toEqual(
new Date('2027-06-23T05:00:00.000Z'),
);
expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z'));
expect(written.windowOpensAt).toBeUndefined();
expect(written.windowPhase).toBeUndefined();
});
});
});

View File

@@ -164,6 +164,8 @@ import {
} from '../booking-batch.constants';
import { orderConsistWagons } from '../consist-order.util';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
@@ -174,7 +176,11 @@ import {
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
import {
DispatchScheduleDto,
RecordCheckpointDto,
UpdateCheckpointDto,
} from '../dto/record-checkpoint.dto';
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
@@ -469,7 +475,7 @@ export class TrainSchedulingService {
* used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation.
*/
private async emitWindowState(scheduleId: string): Promise<void> {
async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
// Dedicated shipping-line departures are never announced to the portal —
@@ -1155,66 +1161,73 @@ export class TrainSchedulingService {
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
* Booking-window fields that must follow a train's departure moving to
* `departure` (any window phase). Shared by every reschedule path so the
* "closes in" countdown always tracks the real departure.
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
* PRE_WINDOW: the stamped open/close were derived from the old departure
* and the window hasn't opened yet, so re-derive them from the schedule's
* own rule snapshot against the new date (joining the target day's route
* group timeline when one exists, exactly like updateScheduleDate).
*
* Customers of every moved booking are notified (maintenanceMoved).
* DONE: the window already finished (e.g. the close offset hit and then the
* train was moved to a later departure). The window must follow the new
* departure, so it REOPENS: re-derive open/close the same way, reset the
* phase to PRE_WINDOW and clamp a past open into the present so the tick
* opens it immediately. A FULL train stays closed — there is nothing left
* to sell — and so does one whose re-derived window would already be over.
*
* OPEN: customers are already booking against the open they were shown, so
* the open stays put — but the close was capped at the OLD departure's
* cutoff, so it must follow the new one (import: open + duration under
* office hours, capped at the cutoff; export: the cutoff itself). Moving
* the train later extends the "closes in" countdown, moving it earlier
* shortens it (a close now in the past is picked up by the next tick).
*
* DOC_REVIEW/PAYMENT keep their running timeline.
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW: the stamped open/close were derived from the old departure
// and the window hasn't opened yet, so re-derive them from the schedule's
// own rule snapshot against the new date (joining the target day's route
// group timeline when one exists, exactly like updateScheduleDate).
//
// DONE: the window already finished (e.g. the close offset hit and then the
// train was moved to a later departure). The window must follow the new
// departure, so it REOPENS: re-derive open/close the same way, reset the
// phase to PRE_WINDOW and clamp a past open into the present so the tick
// opens it immediately. A FULL train stays closed — there is nothing left
// to sell — and so does one whose re-derived window would already be over.
//
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
async windowFieldsForNewDeparture(
schedule: TrainSchedule,
departure: Date,
): Promise<
Partial<
Pick<
TrainSchedule,
| 'windowOpensAt'
| 'windowClosesAt'
| 'windowPhase'
| 'bookingWindowStatus'
| 'docReviewCompletedAt'
| 'docReviewEndsAt'
| 'paymentPhaseEndsAt'
>
>
> {
const reopenFromDone =
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
const shiftOpenClose =
schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null;
const windowFields =
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
shiftOpenClose
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const opensAt = schedule.windowOpensAt!;
const cutoff = bookingCloseCutoff(departure, schedule.direction, merged);
let closesAt = cutoff;
if (schedule.direction !== 'EXPORT') {
closesAt = clampCloseToOfficeHours(
opensAt,
new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000),
merged,
);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
return { windowClosesAt: closesAt };
})()
: schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
@@ -1262,6 +1275,55 @@ export class TrainSchedulingService {
};
})()
: {};
return windowFields;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); an
* OPEN schedule keeps its open but its close follows the new departure;
* DOC_REVIEW/PAYMENT keep their timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
const windowFields = await this.windowFieldsForNewDeparture(schedule, departure);
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
@@ -1306,7 +1368,7 @@ export class TrainSchedulingService {
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
* past their window are left untouched — customers may have booked against the
* times they were shown, so those stay frozen. Returns the count re-stamped.
*/
*/
async restampPendingWindows(): Promise<number> {
const cfg = await this.getWindowConfig();
const now = new Date();
@@ -2160,7 +2222,15 @@ export class TrainSchedulingService {
return { ...detail, warnings, deferredBookings };
}
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
async unassignBooking(
scheduleId: string,
bookingId: string,
userId?: string,
opts: {
/** false = system detach (e.g. booking cancelled) — no "removed from train, rebook" notice. */
notifyCustomer?: boolean;
} = {},
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -2287,7 +2357,9 @@ export class TrainSchedulingService {
const removedBooking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { company: true } });
if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking);
if (removedBooking && opts.notifyCustomer !== false) {
this.bookingNotifier.removedFromTrain(removedBooking);
}
this.logger.log(
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
);
@@ -2635,7 +2707,7 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
async dispatchSchedule(scheduleId: string) {
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -2643,6 +2715,9 @@ export class TrainSchedulingService {
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
await this.assertImportDjiboutiMayDepart(schedule);
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
// never blocks departure — the dispatch confirm dialog warns and staff decide.
@@ -2667,7 +2742,6 @@ export class TrainSchedulingService {
}
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) {
@@ -4150,6 +4224,7 @@ export class TrainSchedulingService {
? TrainCheckpointKind.Arrived
: TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt);
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({
@@ -4173,8 +4248,14 @@ export class TrainSchedulingService {
});
}
// The origin DEPARTED checkpoint IS the departure — keep the schedule's
// headline timestamp on the same clock the operator just entered.
if (dto.sequenceNo === 0) {
await this.trainSchedulesRepository.update(scheduleId, { actualDepartureAt: occurredAt });
}
if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId);
await this.arriveSchedule(scheduleId, occurredAt);
} else {
// Mid-corridor auto-unload: bookings destined for this yard alight the
// moment the train is recorded here — the yard operator no longer has to
@@ -4210,11 +4291,133 @@ export class TrainSchedulingService {
return this.getScheduleCheckpoints(scheduleId);
}
/**
* Correct an already-logged leg's time/note. Pure edit: no auto-unload, no
* position fix, no arrival — those already happened when the leg was logged.
* Allowed on DISPATCHED and ARRIVED trains (a journey is corrected after the
* fact as often as during it). The origin/final legs also re-stamp the
* schedule's departure/arrival so the headline figures follow the edit.
*/
async updateCheckpoint(scheduleId: string, sequenceNo: number, dto: UpdateCheckpointDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Dispatched &&
schedule.status !== TrainScheduleStatusEnum.Arrived
) {
throw new BadRequestException('Only DISPATCHED or ARRIVED trains have checkpoints to edit');
}
const stations = await this.buildScheduleStations(schedule);
const station = stations.find((s) => s.sequenceNo === sequenceNo);
if (!station) {
throw new BadRequestException(`Station ${sequenceNo} is not on this route`);
}
// Match by yard, like getScheduleCheckpoints — legacy rows may carry an
// older station numbering.
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const existing =
events.find((e) => e.yardId === station.yardId) ??
events.find((e) => e.sequenceNo === sequenceNo);
if (!existing) {
throw new BadRequestException(`Station ${station.label} has not been logged yet`);
}
const patch: Partial<TrainCheckpointEvent> = {};
if (dto.occurredAt) {
const occurredAt = new Date(dto.occurredAt);
await this.assertCheckpointTime(schedule, stations, sequenceNo, occurredAt, existing.id);
patch.occurredAt = occurredAt;
}
if (dto.note !== undefined) patch.note = dto.note;
if (Object.keys(patch).length) {
await this.trainCheckpointEventsRepository.update(existing.id, patch);
}
if (patch.occurredAt) {
const finalSeq = stations[stations.length - 1].sequenceNo;
if (sequenceNo === 0) {
await this.trainSchedulesRepository.update(scheduleId, {
actualDepartureAt: patch.occurredAt,
});
} else if (sequenceNo === finalSeq && schedule.status === TrainScheduleStatusEnum.Arrived) {
await this.trainSchedulesRepository.update(scheduleId, {
actualArrivalAt: patch.occurredAt,
});
}
}
return this.getScheduleCheckpoints(scheduleId);
}
private assertNotFuture(at: Date, what: string) {
if (Number.isNaN(at.getTime())) {
throw new BadRequestException(`${what} is not a valid date`);
}
// Small skew allowance so an honest "now" from a client clock passes.
if (at.getTime() > Date.now() + 60_000) {
throw new BadRequestException(`${what} cannot be in the future`);
}
}
/**
* A leg's time must not be in the future and must sit in corridor order:
* no earlier than every logged leg before it (and the dispatch time, for
* legs after the origin), no later than every logged leg after it.
* `ignoreEventId` excludes the row being edited from its own bounds.
*/
private async assertCheckpointTime(
schedule: TrainSchedule,
stations: { sequenceNo: number; yardId: string; label: string }[],
sequenceNo: number,
occurredAt: Date,
ignoreEventId?: string,
) {
this.assertNotFuture(occurredAt, 'Checkpoint time');
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const labelBySeq = new Map(stations.map((s) => [s.sequenceNo, s.label]));
const events = (await this.trainCheckpointEventsRepository.findBySchedule(schedule.id)).filter(
(e) => e.id !== ignoreEventId,
);
const seqOf = (e: TrainCheckpointEvent) => seqByYard.get(e.yardId) ?? e.sequenceNo;
const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
let floor: { at: Date; label: string } | null = null;
let ceil: { at: Date; label: string } | null = null;
for (const e of events) {
const s = seqOf(e);
if (s < sequenceNo && (!floor || e.occurredAt > floor.at)) {
floor = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
}
if (s > sequenceNo && (!ceil || e.occurredAt < ceil.at)) {
ceil = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
}
}
// The origin leg rewrites the departure itself; every later leg must
// follow it.
if (sequenceNo > 0 && schedule.actualDepartureAt && (!floor || schedule.actualDepartureAt > floor.at)) {
floor = { at: schedule.actualDepartureAt, label: 'departure' };
}
if (floor && occurredAt < floor.at) {
throw new BadRequestException(
`Checkpoint time cannot be earlier than ${floor.label} (${fmt(floor.at)})`,
);
}
if (ceil && occurredAt > ceil.at) {
throw new BadRequestException(
`Checkpoint time cannot be later than ${ceil.label} (${fmt(ceil.at)})`,
);
}
}
/**
* Mark a dispatched train arrived: close out the schedule, move the locomotive
* and wagons to the destination yard, and free the assets for re-use.
*/
async arriveSchedule(scheduleId: string) {
async arriveSchedule(scheduleId: string, arrivedAt?: Date) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -4223,7 +4426,9 @@ export class TrainSchedulingService {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
const now = new Date();
// The arrival clock: the operator's entered time when arriving via the final
// checkpoint (already order/future-checked there), else now.
const now = arrivedAt ?? new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
@@ -4409,6 +4614,7 @@ export class TrainSchedulingService {
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
shippingLineCompany: true,
},
order: { [sortBy]: sortOrder } as never,
skip,
@@ -6107,6 +6313,10 @@ export class TrainSchedulingService {
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
// Dedicated shipping-line departure (hidden from customers) — the list
// highlights these rows so staff can tell them apart at a glance.
shippingLineCompanyId: schedule.shippingLineCompanyId ?? null,
shippingLineCompanyName: schedule.shippingLineCompany?.name ?? null,
// Built train (Train Builder) behind this departure, when scheduled by train.
train: schedule.trainSet?.train
? {
@@ -8997,40 +9207,101 @@ export class TrainSchedulingService {
}
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
// Cargo type → allowed wagon types rides along: for bulk, the commodity's
// own wagon-type list (the planner's rule) decides, not only the wagon
// type's generic supportedLoadTypes.
const loadAllocations = (trainSetWagonId: string) =>
allocRepo.find({ where: { trainSetWagonId } });
allocRepo.find({
where: { trainSetWagonId },
relations: { booking: { cargoType: { wagonTypes: true } } },
});
const sourceAllocs = await loadAllocations(source.id);
if (!sourceAllocs.length) {
throw new BadRequestException('Source wagon has no load to move');
}
// Target: a slot of this train set, or an empty consist-only wagon of the
// built train (physical wagon with no slot row yet).
// Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing —
// Gelan→Adama and Adama→Doraleh loads ride the same wagon in two slots), so
// "the slot on that wagon" only means the one whose leg overlaps the moving
// load's leg. Null board/alight = the schedule's own endpoints.
const stops = await this.stopYardsForSchedule(schedule);
const spanOf = (slot: {
boardYardId?: string | null;
alightYardId?: string | null;
}): [number, number] => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const overlaps = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1];
const sourceSpan = spanOf(source);
// Target: a slot of this train set, or a physical wagon of this train —
// coupled-but-empty consist wagon (built train), or a wagon already pinned
// by another slot of this set (then: the overlapping-leg slot, or a fresh
// slot for a free leg).
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
const wagonForTarget = slotById
? null
: schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).findOne({
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
})
: null;
let wagonForTarget: Wagon | null = null;
if (!slotById) {
const wagon = await this.dataSource.getRepository(Wagon).findOne({
where: { id: dto.targetWagonId },
relations: { wagonType: true },
});
const onThisTrain =
!!wagon &&
((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) ||
slots.some((w) => w.physicalWagonId === wagon.id));
wagonForTarget = onThisTrain ? wagon : null;
}
if (!slotById && !wagonForTarget) {
throw new NotFoundException('Target wagon is not part of this schedule');
}
// A physical wagon holds at most one slot. When the caller addressed the
// wagon directly but a slot is already pinned to it, move into that slot
// rather than minting a second one on the same wagon.
const targetSlot =
slotById ??
(wagonForTarget
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null)
? (slots.find(
(w) =>
w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan),
) ?? null)
: null);
const consistWagon = targetSlot ? null : wagonForTarget;
// Leg clash guard: after the move, no two slots on one physical wagon may
// ride the same edge. Source load → target wagon; on a swap, target load →
// source wagon.
const targetPhysicalId = targetSlot?.physicalWagonId ?? consistWagon?.id ?? null;
const clashOn = (
physicalWagonId: string | null,
excludeSlotId: string | null,
span: [number, number],
) =>
!!physicalWagonId &&
slots.some(
(w) =>
w.physicalWagonId === physicalWagonId &&
w.id !== excludeSlotId &&
w.id !== source.id &&
(w.allocations?.length ?? 0) > 0 &&
overlaps(spanOf(w), span),
);
if (clashOn(targetPhysicalId, targetSlot?.id ?? null, sourceSpan)) {
throw new BadRequestException(
'That wagon already carries another load on the same leg — pick a wagon free on that leg.',
);
}
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
if (targetSlot && targetSlot.id === source.id) {
return this.getTrainScheduleById(scheduleId);
}
if (
targetSlot &&
targetAllocs.length &&
clashOn(source.physicalWagonId ?? null, source.id, spanOf(targetSlot))
) {
throw new BadRequestException(
'Swap refused: the source wagon already carries another load on the incoming loads leg.',
);
}
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
@@ -9043,10 +9314,25 @@ export class TrainSchedulingService {
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
// Bulk is allowed on a wagon type when every bulk load's cargo type lists
// it (cargo-type ↔ wagon-type config, same rule the wagon planner uses).
const bulkCargoAllows = (allocs: WagonBookingAllocation[], wagonTypeId?: string) => {
const bulk = allocs.filter((a) => (a.loadType ?? 'CONTAINER').toUpperCase() === 'BULK');
return (
!!wagonTypeId &&
bulk.length > 0 &&
bulk.every((a) =>
(a.booking?.cargoType?.wagonTypes ?? []).some((wt) => wt.id === wagonTypeId),
)
);
};
const checkReceives = (
allocs: WagonBookingAllocation[],
label: string,
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
wagonType:
| { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean }
| null
| undefined,
capacityTons: number,
) => {
const incoming = loadTypesOf(allocs);
@@ -9057,6 +9343,7 @@ export class TrainSchedulingService {
const ok =
supported.includes(loadType) ||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
(loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) ||
supported.length === 0;
if (!ok) {
throw new BadRequestException(

View File

@@ -121,8 +121,46 @@ export class WagonsService {
* (yard workspace, coupling pickers) walk the pages client-side — see
* `wagonService.listAll` in the backoffice.
*/
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
await this.attachStatusDates(page.items);
return page;
}
/**
* Latest status-flip dates from the audit log, for the wagons desk columns:
* when the wagon last went to MAINTENANCE and when it last became AVAILABLE.
* One grouped query per page; null when the log has no such flip.
*/
private async attachStatusDates(wagons: Wagon[]): Promise<void> {
if (!wagons.length) return;
const rows: Array<{
wagonId: string;
lastMaintenanceAt: Date | null;
lastAvailableAt: Date | null;
}> = await this.dataSource
.getRepository(WagonStatusLog)
.createQueryBuilder('l')
.select('l.wagon_id', 'wagonId')
.addSelect(
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`,
'lastMaintenanceAt',
)
.addSelect(
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`,
'lastAvailableAt',
)
.where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
.groupBy('l.wagon_id')
.getRawMany();
const byId = new Map(rows.map((r) => [r.wagonId, r]));
for (const w of wagons) {
const r = byId.get(w.id);
Object.assign(w, {
lastMaintenanceAt: r?.lastMaintenanceAt ?? null,
lastAvailableAt: r?.lastAvailableAt ?? null,
});
}
}
async findById(id: string): Promise<Wagon> {

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: {
@@ -2405,10 +2414,14 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
// Manual settlement (bank transfer / counter) of USD and ETB invoices.
FREIGHT_PERMS.invoices.confirmOffline,
// 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
@@ -2525,6 +2538,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.

View File

@@ -315,7 +315,7 @@ const App = () => {
}
/>
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
?tab=invoices|payments|usd-payments (default invoices). Access is
?tab=invoices|payments|manual-payments (default invoices). Access is
OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be
routed on. */}
@@ -352,7 +352,7 @@ const App = () => {
/>
<Route
path="usd-payments"
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
element={<Navigate to="/dashboard/invoices?tab=manual-payments" replace />}
/>
<Route
path="invoices/:id"

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
import { SimpleGrid, Divider, Box, Group, Table, Text, Badge } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
);
const isBulk = booking.freightType === "BULK";
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
// the freight kind, with the shipper's own description alongside.
const cargoHeadline = isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo")
: "Containers";
const cargoDescription = booking.cargoFreeText?.trim() || null;
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
<Group gap="sm" align="center" mb="md" wrap="wrap">
<Text fw={800} fz={22} lh={1.1}>
{cargoHeadline}
</Text>
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
{isBulk ? "Bulk" : "Container"}
</Badge>
{cargoDescription ? (
<Text size="sm" c="dimmed">
{cargoDescription}
</Text>
) : null}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
label={isBulk ? "Commodity" : "Cargo type"}
value={
isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
: (cargoDescription ?? booking.freightType)
}
/>
<MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />}

View File

@@ -1,11 +1,30 @@
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
NumberInput,
Radio,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { EimsInvoiceStatus } from "@/types/eims";
import { eimsService } from "@/services/eims.service";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims";
import { useToast } from "@/hooks/use-toast";
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
REGISTERED: "edr-green",
FAILED: "red",
UNKNOWN: "orange",
CANCELLED: "gray",
};
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
REGISTERED: "Filed",
FAILED: "Rejected",
UNKNOWN: "Unacknowledged",
CANCELLED: "Cancelled",
};
function Field({ label, value }: { label: string; value?: string | number | null }) {
@@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null
);
}
/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
const CANCEL_REASON_CODES = [
{ value: "1", label: "1 — Duplicate" },
{ value: "2", label: "2 — Buyer request" },
{ value: "3", label: "3 — Data entry error" },
{ value: "6", label: "6 — Calculation error" },
];
function CancelModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [reasonCode, setReasonCode] = useState<string | null>(null);
const [remark, setRemark] = useState("");
const cancel = useMutation(
api.invoices.eimsCancel.mutationOptions({
onSuccess: () => {
onClose();
toast({ title: "Cancelled with MoR" });
},
onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="Cancel EIMS registration" centered>
<Stack gap="md">
<Text size="sm" c="dimmed">
Cancels this invoice&apos;s registered document at MoR. Irreversible an already-cancelled
invoice refuses a second attempt.
</Text>
<Select
label="Reason code"
withAsterisk
data={CANCEL_REASON_CODES}
value={reasonCode}
onChange={setReasonCode}
placeholder="Select a reason"
/>
<Textarea
label="Remark"
placeholder="Optional note"
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
autosize
minRows={2}
/>
<Button
color="red"
leftSection={<Ban size={16} />}
loading={cancel.isPending}
disabled={!reasonCode}
onClick={() => cancel.mutate({ id: invoiceId, reasonCode: reasonCode!, remark: remark.trim() || undefined })}
>
Cancel with MoR
</Button>
</Stack>
</Modal>
);
}
function SalesReceiptModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [modeOfPayment, setModeOfPayment] = useState<EimsModeOfPayment | null>(null);
const [collectedAmount, setCollectedAmount] = useState<number | "">("");
const [reason, setReason] = useState("");
const register = useMutation(
api.invoices.eimsRegisterSalesReceipt.mutationOptions({
onSuccess: (receipt) => {
onClose();
toast({ title: "Sales receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
},
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="File sales receipt" centered>
<Stack gap="md">
<Select
label="Mode of payment"
withAsterisk
data={EIMS_MODE_OF_PAYMENT.map((v) => ({ value: v, label: v }))}
value={modeOfPayment}
onChange={(v) => setModeOfPayment(v as EimsModeOfPayment)}
placeholder="Select"
/>
<NumberInput
label="Collected amount"
placeholder="Defaults to the invoice's paid amount"
min={0}
decimalScale={2}
value={collectedAmount}
onChange={(v) => setCollectedAmount(v === "" ? "" : Number(v))}
/>
<TextInput
label="Reason"
placeholder='Defaults to "Payment received"'
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={register.isPending}
disabled={!modeOfPayment}
onClick={() =>
register.mutate({
id: invoiceId,
modeOfPayment: modeOfPayment!,
collectedAmount: collectedAmount === "" ? undefined : collectedAmount,
reason: reason.trim() || undefined,
})
}
>
File with MoR
</Button>
</Stack>
</Modal>
);
}
function WithholdingReceiptModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [type, setType] = useState("TWHT");
const [preTaxAmount, setPreTaxAmount] = useState<number | "">("");
const [withholdingAmount, setWithholdingAmount] = useState<number | "">("");
const [reason, setReason] = useState("");
const register = useMutation(
api.invoices.eimsRegisterWithholdingReceipt.mutationOptions({
onSuccess: (receipt) => {
onClose();
toast({ title: "Withholding receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
},
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
}),
);
const valid = preTaxAmount !== "" && withholdingAmount !== "";
return (
<Modal opened={opened} onClose={onClose} title="File withholding receipt" centered>
<Stack gap="md">
<TextInput label="Type" withAsterisk value={type} onChange={(e) => setType(e.currentTarget.value)} />
<NumberInput
label="Pre-tax amount"
withAsterisk
min={0}
decimalScale={2}
value={preTaxAmount}
onChange={(v) => setPreTaxAmount(v === "" ? "" : Number(v))}
/>
<NumberInput
label="Withholding amount"
withAsterisk
min={0}
decimalScale={2}
value={withholdingAmount}
onChange={(v) => setWithholdingAmount(v === "" ? "" : Number(v))}
/>
<TextInput
label="Reason"
placeholder='Defaults to "Withholding"'
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={register.isPending}
disabled={!valid}
onClick={() =>
register.mutate({
id: invoiceId,
type,
preTaxAmount: preTaxAmount as number,
withholdingAmount: withholdingAmount as number,
reason: reason.trim() || undefined,
})
}
>
File with MoR
</Button>
</Stack>
</Modal>
);
}
function MemoModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [type, setType] = useState<"CRE" | "DEB">("CRE");
const [reason, setReason] = useState("");
const issue = useMutation(
api.invoices.issueMemo.mutationOptions({
onSuccess: (memo) => {
onClose();
toast({ title: "Memo issued", description: `${memo.invoiceNumber} — file it with MoR separately` });
},
onError: (error) => toast({ title: "Could not issue memo", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="Issue credit/debit memo" centered>
<Stack gap="md">
<Text size="sm" c="dimmed">
Creates a new invoice linked to this one, with every line copied verbatim. Filing it with
MoR is a separate step it does not happen automatically here.
</Text>
<Radio.Group value={type} onChange={(v) => setType(v as "CRE" | "DEB")} label="Type">
<Stack gap="xs" mt="xs">
<Radio value="CRE" label="Credit memo" description="Reduces what the buyer owes; created settled." />
<Radio value="DEB" label="Debit memo" description="An additional charge; created as a new open invoice." />
</Stack>
</Radio.Group>
<Textarea
label="Reason"
withAsterisk
placeholder="Why this memo is being issued"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
/>
<Button
color="edr-green"
loading={issue.isPending}
disabled={!reason.trim()}
onClick={() => issue.mutate({ id: invoiceId, type, reason: reason.trim() })}
>
Issue memo
</Button>
</Stack>
</Modal>
);
}
function ReceiptsSection({ invoiceId, canFile }: { invoiceId: string; canFile: boolean }) {
const { toast } = useToast();
const { data: receipts } = useQuery(api.invoices.eimsReceipts.queryOptions({ input: { id: invoiceId } }));
const [salesOpen, setSalesOpen] = useState(false);
const [withholdingOpen, setWithholdingOpen] = useState(false);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const download = async (receiptId: string, receiptNumber: string) => {
setDownloadingId(receiptId);
try {
const { data } = await eimsService.downloadReceiptDocument(invoiceId, receiptId);
openPdfBlob(data, `${receiptNumber}.pdf`);
} catch (error) {
toast({
title: "Could not download receipt",
description: error instanceof Error ? error.message : undefined,
variant: "destructive",
});
} finally {
setDownloadingId(null);
}
};
return (
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600} size="sm" c="edr-text">
Receipts
</Text>
{canFile && (
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => setSalesOpen(true)}>
File sales receipt
</Button>
<Button size="xs" variant="light" onClick={() => setWithholdingOpen(true)}>
File withholding receipt
</Button>
</Group>
)}
</Group>
{receipts && receipts.length > 0 ? (
<Table striped withTableBorder={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Kind</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>RRN</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{receipts.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.kind}</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[r.status] ?? "gray"} variant="light" size="sm">
{STATUS_LABEL[r.status] ?? r.status}
</Badge>
</Table.Td>
<Table.Td style={{ fontFamily: "monospace" }}>{r.rrn ?? "—"}</Table.Td>
<Table.Td>
{r.status === "REGISTERED" && (
<Button
size="xs"
variant="subtle"
leftSection={<Download size={14} />}
loading={downloadingId === r.id}
onClick={() => void download(r.id, r.receiptNumber)}
>
PDF
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text size="sm" c="dimmed">
No receipts filed yet.
</Text>
)}
<SalesReceiptModal invoiceId={invoiceId} opened={salesOpen} onClose={() => setSalesOpen(false)} />
<WithholdingReceiptModal invoiceId={invoiceId} opened={withholdingOpen} onClose={() => setWithholdingOpen(false)} />
</Stack>
);
}
/**
* MoR EIMS filing state for one invoice, with the manual actions.
*
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
const { user } = useAuth();
const { toast } = useToast();
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
const canCancel = hasPermission(user, FREIGHT_PERMS.invoices.eimsCancel);
const canFileReceipt = hasPermission(user, FREIGHT_PERMS.invoices.eimsReceiptRegister);
const canIssueMemo = hasPermission(user, FREIGHT_PERMS.invoices.memoIssue);
const [cancelOpen, setCancelOpen] = useState(false);
const [memoOpen, setMemoOpen] = useState(false);
const { data: eims, isLoading } = useQuery(
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
</Alert>
)}
{canFile && (
<Group gap="sm">
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && (
<Button
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</Group>
{status === "CANCELLED" && (
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
{eims.eimsCancellationRemark}
</Alert>
)}
<Group gap="sm">
{canFile && (
<>
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && status !== "CANCELLED" && (
<Button
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</>
)}
{canCancel && eims.eimsIrn && status !== "CANCELLED" && (
<Button
size="xs"
variant="light"
color="red"
radius="md"
leftSection={<Ban size={14} />}
onClick={() => setCancelOpen(true)}
>
Cancel with MoR
</Button>
)}
{canIssueMemo && status === "REGISTERED" && (
<Button
size="xs"
variant="light"
radius="md"
leftSection={<FileText size={14} />}
onClick={() => setMemoOpen(true)}
>
Issue credit/debit memo
</Button>
)}
</Group>
{eims.eimsIrn && <ReceiptsSection invoiceId={invoiceId} canFile={canFileReceipt} />}
</Stack>
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
</Card>
);
}

View File

@@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({
next.containerTypeId = "";
next.cargoTypeId = "";
}
// Cargo kind (customs / lashing) decides both the container-type scope
// Cargo kind (customs / cancellation) decides both the container-type scope
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
if (name === "cargoKind") {
next.containerTypeId = "";

View File

@@ -0,0 +1,103 @@
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useEffect, useState } from "react";
/**
* Time + note for one leg of a train's journey — used both to log a pass
* (defaults to now) and to correct an already-logged leg (prefilled). Past
* times are allowed (staff record after the fact); the future is not, and the
* server additionally keeps legs in corridor order.
*/
export function CheckpointTimeModal({
opened,
onClose,
title,
icon,
description,
initialOccurredAt,
initialNote,
submitLabel,
submitColor = "edr-green",
loading,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
title: string;
icon?: React.ReactNode;
description?: string;
/** ISO; omit to default to now. */
initialOccurredAt?: string | null;
initialNote?: string | null;
submitLabel: string;
submitColor?: string;
loading: boolean;
onSubmit: (values: { occurredAt: string; note: string }) => void;
}) {
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
useEffect(() => {
if (!opened) return;
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
setNote(initialNote ?? "");
}, [opened, initialOccurredAt, initialNote]);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
title={
<Group gap={8}>
{icon}
<Text fw={700}>{title}</Text>
</Group>
}
>
<Stack gap="md">
{description ? (
<Text size="sm" c="dimmed">
{description}
</Text>
) : null}
<DateTimePicker
label="Time"
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Textarea
label="Note"
placeholder="Optional"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
maxRows={4}
maxLength={500}
radius="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
color={submitColor}
loading={loading}
disabled={!at}
onClick={() =>
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,423 @@
import { Fragment, useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
Alert,
Badge,
Button,
Group,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Stop = { yardId: string; label: string };
type Span = [number, number];
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
interface WagonRow {
key: string;
physicalWagonId: string | null;
label: string;
position: number;
typeCode: string | null;
capacityTons: number;
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
/**
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
* Loads move by click: pick a load, then click a wagon that is free on that
* load's legs (move) or another load (swap). Same API as the consist strip.
*/
export function LegLoadBoardPanel({
schedule,
onChanged,
}: {
schedule: TrainScheduleDetail;
onChanged?: () => void;
}) {
const { toast } = useToast();
const stops: Stop[] = schedule.stops ?? [];
const legs = useMemo(
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
[stops],
);
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
const spanOf = (slot: Slot): Span => {
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
const to = slot.alightYardId
? stops.findIndex((s) => s.yardId === slot.alightYardId)
: stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const rows: WagonRow[] = useMemo(() => {
const byKey = new Map<string, WagonRow>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
let row = byKey.get(key);
if (!row) {
row = {
key,
physicalWagonId: slot.physicalWagonId ?? null,
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
position: slot.position ?? slot.sequenceNo,
typeCode: slot.wagonType?.code ?? null,
capacityTons: slot.capacityTons ?? 0,
slots: [],
};
byKey.set(key, row);
}
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
if (!slot.consistOnly) {
row.slots.push({
slot,
span: spanOf(slot),
loaded: (slot.allocations?.length ?? 0) > 0,
});
}
}
return [...byKey.values()].sort((a, b) => a.position - b.position);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule.trainSet?.wagons, stops]);
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
null,
);
useEffect(() => {
if (!picked) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [picked]);
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
const doMove = async (targetWagonId: string, swap: boolean) => {
if (!picked || moveMutation.isPending) return;
try {
await moveMutation.mutateAsync({
scheduleId: schedule.id,
wagonId: picked.slotId,
targetWagonId,
});
toast({ title: swap ? "Loads swapped" : "Load moved" });
setPicked(null);
onChanged?.();
} catch (error) {
const message = isAxiosError(error)
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
null)
: null;
toast({
title: "Could not move the load",
description: Array.isArray(message)
? message.join(", ")
: (message ?? "The move was rejected — check wagon type, payload and leg."),
variant: "destructive",
});
}
};
if (stops.length < 2) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
This schedule has no corridor stops yet the leg board needs a route with at least
two stops.
</Alert>
);
}
if (!rows.length) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
No wagons on this train yet.
</Alert>
);
}
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fw={700} size="sm">
Loads per wagon per leg
</Text>
<Text size="xs" c="dimmed">
One row per physical wagon, one column per leg. A wagon reused on different legs
shows one load per leg.{" "}
{canRearrange
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
: "Read-only — the train has departed."}
</Text>
</Stack>
<Group gap="xs">
{sharedRows > 0 ? (
<Badge variant="light" color="violet" radius="sm">
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
</Badge>
) : null}
{picked ? (
<Button
size="xs"
variant="default"
leftSection={<X size={14} />}
onClick={() => setPicked(null)}
>
Cancel move (Esc)
</Button>
) : null}
</Group>
</Group>
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
Wagon
</Table.Th>
{legs.map((leg) => (
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
<Group gap={4} wrap="nowrap">
<Text size="xs" fw={700} truncate>
{leg.from.label}
</Text>
<MoveRight size={12} />
<Text size="xs" fw={700} truncate>
{leg.to.label}
</Text>
</Group>
</Table.Th>
))}
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const cargoTons = row.slots.reduce(
(s, x) =>
s +
((x.slot.allocations ?? []).reduce(
(a, al) => a + (al.allocatedWeightTons ?? 0),
0,
) || x.slot.assignedWeightTons || 0),
0,
);
const isPickedRow = picked?.rowKey === row.key;
// A row can take the picked load when nothing loaded on it rides
// any of the picked load's legs.
const rowFreeForPicked =
!!picked &&
!isPickedRow &&
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
// Where a "move here" lands: an existing empty slot on those legs,
// else the physical wagon itself (the API mints the slot).
const emptyTargetSlot = picked
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
: undefined;
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
// Lay slots into leg columns; uncovered legs render as empty cells.
const cells: React.ReactNode[] = [];
let col = 0;
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
// Empty cell = uncovered leg (target: the physical wagon) or an
// empty slot (target: that slot). Both take the picked load when
// the row is free on its legs.
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
!!picked && overlaps([from, to], picked.span);
return (
<Table.Td
key={`e-${from}`}
colSpan={Math.max(1, to - from)}
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
style={{
cursor: droppable ? "pointer" : "default",
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
outlineOffset: -3,
borderRadius: 6,
}}
>
{droppable ? (
<Text size="xs" c="teal.7" fw={600} ta="center">
Move here
</Text>
) : (
<Text size="xs" c="dimmed" ta="center">
</Text>
)}
</Table.Td>
);
};
for (const s of sorted) {
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
if (!s.loaded) {
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
col = Math.max(col, s.span[1]);
continue;
}
const isPicked = picked?.slotId === s.slot.id;
const swappable =
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
const allocs = s.slot.allocations ?? [];
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
const containers = allocs.flatMap((a) => a.containerItems ?? []);
cells.push(
<Table.Td
key={s.slot.id}
colSpan={Math.max(1, s.span[1] - s.span[0])}
onClick={
!canRearrange
? undefined
: s.loaded && !picked
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
: swappable
? () => void doMove(s.slot.id, true)
: isPicked
? () => setPicked(null)
: undefined
}
style={{
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
padding: 4,
}}
>
{s.loaded ? (
<Paper
radius="sm"
px={8}
py={6}
style={{
background: bulk
? "var(--mantine-color-orange-0)"
: "var(--mantine-color-cyan-0)",
borderLeft: `4px solid ${
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
}`,
outline: isPicked
? "2px solid var(--mantine-color-edr-green-6)"
: swappable
? "1px dashed var(--mantine-color-orange-6)"
: undefined,
outlineOffset: 1,
}}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
<Text size="xs" fw={700} truncate>
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{round1(
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
)}{" "}
t
</Text>
</Group>
<Group gap={4} mt={2} wrap="wrap">
{bulk
? allocs.map((a) =>
a.bulkLoad ? (
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
</Badge>
) : null,
)
: containers.map((c) => (
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
{c.containerNumber ?? "no number"}
</Badge>
))}
{swappable ? (
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
swap
</Badge>
) : null}
</Group>
</Paper>
) : (
<Text size="xs" c="dimmed" ta="center">
empty
</Text>
)}
</Table.Td>,
);
col = Math.max(col, s.span[1]);
}
if (col < legs.length) cells.push(emptyCell(col, legs.length));
return (
<Table.Tr
key={row.key}
style={{
background: isPickedRow
? "var(--mantine-color-green-0)"
: rowFreeForPicked
? undefined
: picked
? "var(--mantine-color-gray-0)"
: undefined,
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
}}
>
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
<Group gap={6} wrap="nowrap">
<Badge variant="outline" color="gray" radius="sm" size="sm">
#{row.position}
</Badge>
<Stack gap={0}>
<Text size="sm" fw={700}>
{row.label}
</Text>
<Text size="xs" c="dimmed">
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
</Text>
</Stack>
{row.slots.filter((s) => s.loaded).length > 1 ? (
<Tooltip label="This wagon carries different loads on different legs">
<Badge size="xs" color="violet" variant="light" radius="sm">
shared
</Badge>
</Tooltip>
) : null}
</Group>
</Table.Td>
{cells.map((c, i) => (
<Fragment key={i}>{c}</Fragment>
))}
<Table.Td>
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
{round1(cargoTons)} / {round1(row.capacityTons)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Paper>
</Stack>
);
}

View File

@@ -12,6 +12,7 @@ import {
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
}) {
const { toast } = useToast();
const [justLogged, setJustLogged] = useState(false);
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
useEffect(() => {
setJustLogged(false);
setPassAt(new Date());
}, [station?.sequenceNo, opened]);
const logged = alreadyLogged || justLogged;
const yardWorkQuery = useQuery(
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
const doLogPass = () => {
if (!station) return;
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
{
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
},
},
{
onSuccess: () => {
setJustLogged(true);
@@ -373,6 +385,20 @@ export function LogPassYardWorkModal({
</>
)}
{!logged ? (
<DateTimePicker
label={isFinal ? "Arrival time" : "Time at station"}
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={passAt}
onChange={(v) => setPassAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={320}
/>
) : null}
<Group justify="space-between" mt="xs">
<Text size="xs" c="dimmed">
{logged && pendingBoarders.length > 0

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { Check, Flag, MapPin, Train } from "lucide-react";
import { Check, Flag, MapPin, Pencil, Train } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
canLog: boolean;
loggingSeq?: number | null;
onLogCheckpoint?: (sequenceNo: number) => void;
/** Present when logged legs may be corrected (dispatched or arrived). */
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
}
const COLUMN_WIDTH = 150;
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
canLog,
loggingSeq,
onLogCheckpoint,
onEditCheckpoint,
}: RouteCorridorTrackProps) {
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
const lastIndex = stations.length - 1;
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
{/* checkpoint time or action */}
{checkpoint ? (
<Text size="10px" c="dimmed" ta="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
<Stack gap={2} align="center">
<Text size="10px" c="dimmed" ta="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
{onEditCheckpoint ? (
<Button
size="compact-xs"
radius="md"
variant="subtle"
color="gray"
leftSection={<Pencil size={11} />}
onClick={() => onEditCheckpoint(checkpoint)}
>
Edit time
</Button>
) : null}
</Stack>
) : isNext ? (
<Button
size="compact-xs"

View File

@@ -95,7 +95,10 @@ export const QUERY_KEYS = {
byId: (id: string) => ["invoices", "detail", id] as const,
offlineUsd: (filter?: InvoiceListFilter) =>
["invoices", "offline-usd", filter ?? {}] as const,
summary: (filter?: Omit<InvoiceListFilter, "page" | "pageSize">) =>
["invoices", "summary", filter ?? {}] as const,
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const,
},
BOOKINGS: {

View File

@@ -134,8 +134,10 @@ export const URL_CONSTANTS = {
BILLING: {
INVOICES: "/billing/invoices",
INVOICES_SUMMARY: "/billing/invoices/summary",
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`,
OFFLINE_USD: "/billing/offline-usd",
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
},
@@ -146,6 +148,12 @@ export const URL_CONSTANTS = {
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
CANCEL: (id: string) => `/invoices/${id}/eims/cancel`,
RECEIPT_SALES: (id: string) => `/invoices/${id}/eims/receipt/sales`,
RECEIPT_WITHHOLDING: (id: string) => `/invoices/${id}/eims/receipt/withholding`,
RECEIPTS: (id: string) => `/invoices/${id}/eims/receipts`,
RECEIPT_DOCUMENT: (id: string, receiptId: string) =>
`/invoices/${id}/eims/receipts/${receiptId}/document`,
},
CUSTOMERS_API: {
@@ -482,6 +490,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
CHECKPOINTS: (id: string) =>
`/train-scheduling/schedules/${id}/checkpoints`,
CHECKPOINT: (id: string, sequenceNo: number) =>
`/train-scheduling/schedules/${id}/checkpoints/${sequenceNo}`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`,

View File

@@ -20,9 +20,13 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
reference: booking.reference,
contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
// Shipping-line bookings have no customer company — the line IS the customer.
customerLabel: booking.shippingLineCompany
? booking.shippingLineCompany.name
: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
isShippingLine: Boolean(booking.shippingLineCompany ?? booking.shippingLineCompanyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status,
scheduledDate: booking.scheduledDate,

View File

@@ -145,10 +145,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:invoices:view",
export: "edr_freight_app:invoices:export",
confirmOffline: "edr_freight_app:invoices:confirm_offline",
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
// irreversible at the tax authority, and resolving clears a system-wide filing block.
// Filing with MoR EIMS. Off the general Finance role — automatic filing needs no permission
// at all (the cron sweep runs as the system); these are the manual, exceptional-operations
// actions, granted to the `chief` position (maker-checker, same as shipping-line credit
// mark-paid/cancel approval) rather than every Finance user.
eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve",
eimsCancel: "edr_freight_app:invoices:eims_cancel",
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
// Issuing a credit/debit memo is filing-equivalent — same restricted grant as the eims_* keys.
memoIssue: "edr_freight_app:invoices:memo_issue",
},
firstMile: {
view: "edr_freight_app:first_mile:view",

View File

@@ -13,12 +13,14 @@ import {
MoreHorizontal,
Package,
RefreshCw,
Ship,
Truck,
Wallet,
Weight,
} from "lucide-react";
import {
ActionIcon,
Badge,
Box,
Button,
Center,
@@ -174,7 +176,14 @@ export default function BookingRequestDetailPage() {
};
const company = booking.company;
const shippingLine = booking.shippingLineCompany ?? null;
const customerName = toBookingListRow(booking).customerLabel;
// What is being shipped, in words: bulk → the commodity (Wheat, Steel…);
// containers → the shipper's own description when given.
const cargoLabel =
booking.freightType === "BULK"
? (booking.cargoType?.label ?? booking.cargoType?.name ?? null)
: (booking.cargoFreeText?.trim() || null);
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
@@ -237,10 +246,27 @@ export default function BookingRequestDetailPage() {
}
subtitle={
<Group gap={6} wrap="wrap">
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
{shippingLine ? (
<Group gap={6} wrap="nowrap">
<Ship size={14} />
<Text size="sm" fw={600}>
{shippingLine.name}
</Text>
<Badge size="xs" radius="sm" variant="light" color="teal">
Shipping line
</Badge>
</Group>
) : (
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
)}
{cargoLabel ? (
<Text size="sm" c="dimmed">
· {cargoLabel}
</Text>
) : null}
<Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate}
</Text>

View File

@@ -18,6 +18,7 @@ import {
Package,
Plus,
RefreshCw,
Ship,
User,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -62,6 +63,12 @@ const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
{ value: "GENERAL_CONTRACT", label: "General booking" },
];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
/** Status options for the filter select — built from the shared status styles. */
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
([value, { label }]) => ({ value, label }),
@@ -132,6 +139,7 @@ export default function BookingRequestsPage() {
// split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
@@ -301,8 +309,17 @@ export default function BookingRequestsPage() {
</Badge>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{b.isShippingLine ? (
<Ship className="size-3 shrink-0 opacity-70" />
) : (
<User className="size-3 shrink-0 opacity-70" />
)}
{b.customerLabel}
{b.isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p>
</div>
</div>
@@ -483,7 +500,7 @@ export default function BookingRequestsPage() {
<FilterBar
defs={bookingFilterDefs}
controls={controls}
searchPlaceholder="Search booking, contract or customer…"
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests"
/>
</Box>

View File

@@ -8,7 +8,7 @@ import {
ThemeIcon,
} from "@mantine/core";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, User } from "lucide-react";
import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
@@ -67,6 +67,12 @@ const OWNERSHIP_OPTIONS = [
{ value: "false", label: "Private" },
];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const { filterOptions } = useMyTradeAccess();
@@ -91,6 +97,7 @@ export default function ClearanceDocumentsPage() {
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS },
{
key: "created",
@@ -131,17 +138,29 @@ export default function ClearanceDocumentsPage() {
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const b = row.original;
const customer = b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "");
const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId);
const customer = isShippingLine
? (b.shippingLineCompany?.name ?? "Shipping line")
: b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—");
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
{isShippingLine ? (
<Ship className="size-4" strokeWidth={1.75} />
) : (
<User className="size-4" strokeWidth={1.75} />
)}
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">
<p className="flex items-center gap-1.5 font-medium text-foreground">
{customer}
{isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
@@ -245,7 +264,7 @@ export default function ClearanceDocumentsPage() {
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder="Search booking, contract or customer…"
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="clearance-documents"
/>
</Box>

View File

@@ -52,6 +52,47 @@ import {
summarizeRequestedCargo,
} from "@/features/clearance/requestedCargo";
import { contractsService } from "@/services/contracts.service";
import "./contract-clearance-table.css";
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null,
): string {
if (!yard) return "—";
return yard.label ?? yard.name ?? yard.code ?? "—";
}
/**
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
* text wraps normally (the table's cells are otherwise nowrap) so a long
* lane never spills into the next column.
*/
function RouteLabel({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
return (
<Text
size="sm"
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
);
}
function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? (
@@ -118,8 +159,8 @@ export default function ContractClearanceListPage() {
id: b.id,
reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
originLabel: b.originYard?.name ?? "—",
destinationLabel: b.destinationYard?.name ?? "—",
originLabel: yardLabel(b.originYard),
destinationLabel: yardLabel(b.destinationYard),
tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—",
status: b.status,
@@ -430,11 +471,10 @@ function ShipmentBookingsTable({
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Text size="sm">{row.original.originLabel}</Text>
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm">{row.original.destinationLabel}</Text>
</Group>
<RouteLabel
origin={row.original.originLabel}
destination={row.original.destinationLabel}
/>
),
},
{
@@ -600,13 +640,13 @@ function ShipmentBookingsTable({
}
return (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<DataTable<ShipmentBookingRow, unknown>
columns={columns}
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
/>
</Box>
);

View File

@@ -45,6 +45,7 @@ import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import "./contract-clearance-table.css";
const prettyStatus = (s?: string | null) =>
(s ?? "")
@@ -214,15 +215,24 @@ function RouteCell({
}) {
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500}>
{origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500}>
{destination}
</Text>
</Group>
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
normally (cells are otherwise nowrap) so it never spills over. */}
<Text
size="sm"
fw={500}
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<Group gap={8} align="center">
<DirectionIcon direction={direction} />
<Badge size="xs" variant="default" radius="sm">
@@ -676,7 +686,7 @@ export default function GlDjiboutiClearanceListPage() {
) : null}
</Stack>
) : (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<DataTable<ShipmentRow, unknown>
columns={shipmentColumns}
data={pagedShipmentRows}
@@ -694,7 +704,7 @@ export default function GlDjiboutiClearanceListPage() {
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={DataTableFooter}
/>
</Box>

View File

@@ -0,0 +1,94 @@
/*
* Scoped to .edr-clearance-table — the DataTable container div on the
* Document Clearance hubs (GL Ethiopia + GL Djibouti). Mirrors the portal's /bookings table
* (bookings-table.css): content-sized columns with a 100px floor, no
* truncation, horizontal scroll when the table outgrows the card, sticky
* header row and a sticky shadowed action column.
*/
.edr-clearance-table {
overflow-x: auto;
max-width: 100%;
min-width: 0;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-clearance-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
/* 100px floor, no ceiling: cells grow to fit their text, nothing is clipped. */
.edr-clearance-table th,
.edr-clearance-table td:not([colspan]) {
min-width: 100px;
max-width: none;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-clearance-table .mantine-Badge-root {
max-width: none;
}
/*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses
* the badges/text in the Type, Route and Status columns to nothing. Let group
* children size to their content; the column grows and the container scrolls.
*/
.edr-clearance-table .mantine-Group-root > * {
max-width: none;
flex-shrink: 0;
}
/* Sticky header row. */
.edr-clearance-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's column size — hence !important.
* `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-clearance-table th:last-child,
.edr-clearance-table td:last-child:not([colspan]) {
width: 1% !important;
min-width: 0;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-clearance-table td:last-child:not([colspan]) {
background: #f5f8fb;
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-clearance-table th:last-child {
background: #f4f7fa;
z-index: 3;
}

View File

@@ -353,6 +353,10 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
// From the status-flip log: last time the wagon went to maintenance, and
// last time it became available again (dash = never logged).
{ id: "lastMaintenanceAt", header: "Last to maintenance", accessorKey: "lastMaintenanceAt", format: "date" },
{ id: "lastAvailableAt", header: "Available since", accessorKey: "lastAvailableAt", format: "date" },
],
formFields: [
// Run numbers are optional — a wagon sits in the fleet unassigned to any

View File

@@ -1,5 +1,5 @@
import { Tabs } from "@mantine/core";
import { Landmark, Receipt, Wallet } from "lucide-react";
import { Landmark, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
@@ -8,13 +8,15 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import InvoicesPanel from "./InvoicesPage";
import UsdPaymentsPanel from "./UsdPaymentsPage";
import PaymentsPanel from "../payments/PaymentsPage";
/**
* Invoices, Payments, and USD Payments used to be three separate routes/pages
* with near-identical chrome. They're merged here as URL-linkable tabs
* (`?tab=`) on one page — each tab keeps the permission it was individually
* gated on before, and just doesn't render if the user lacks it.
* Invoices and USD Payments used to be separate routes/pages with
* near-identical chrome. They're merged here as URL-linkable tabs (`?tab=`)
* on one page — each tab keeps the permission it was individually gated on
* before, and just doesn't render if the user lacks it.
*
* The Payments tab was removed; its summary (total collected, ETB/USD) now
* lives as a card at the top of the Invoices tab instead.
*/
const TABS = [
{
@@ -27,21 +29,13 @@ const TABS = [
Panel: InvoicesPanel,
},
{
key: "payments",
label: "Payments",
icon: Wallet,
permission: FREIGHT_PERMS.payments.view,
subtitle: "View and reconcile booking payment transactions.",
Panel: PaymentsPanel,
},
{
key: "usd-payments",
label: "USD Payments",
key: "manual-payments",
label: "Manual Payments",
icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.",
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel,
},
] as const;

View File

@@ -8,16 +8,18 @@ import {
Grid,
Group,
Loader,
Menu,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
import { useToast } from "@/hooks/use-toast";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -77,9 +79,31 @@ function InfoField({
);
}
/** Billed-to company, with its contact/registration details as quick-info rows. */
/**
* Billed-to party: a customer company, or — for shipping-line credit invoices
* (`companyId` null) — the shipping line itself. The two payers are mutually
* exclusive (DB-enforced), so exactly one branch has data.
*/
function RecipientCard({ invoice }: { invoice: Invoice }) {
const company = invoice.company;
const shippingLine = invoice.shippingLineCompany;
if (!company && shippingLine) {
const rows: FieldRowProps[] = [
{ label: "Phone", value: shippingLine.phoneNumber },
{ label: "Email", value: shippingLine.email },
];
return (
<LinkedEntityCard
icon={Building2}
title="Billed to"
name={shippingLine.name}
rows={rows}
emptyMessage="No additional shipping line details available."
/>
);
}
const rows: FieldRowProps[] = [
{ label: "Profile", value: invoice.companyProfile?.reference },
{ label: "TIN", value: company?.tin },
@@ -91,7 +115,7 @@ function RecipientCard({ invoice }: { invoice: Invoice }) {
return (
<LinkedEntityCard
icon={Building2}
title="Recipient"
title="Billed to"
name={company?.name ?? "Unnamed company"}
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
rows={rows}
@@ -145,6 +169,7 @@ export default function InvoiceDetailPage() {
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const [downloading, setDownloading] = useState(false);
const { data: invoice, isLoading } = useQuery(
@@ -154,12 +179,27 @@ export default function InvoiceDetailPage() {
}),
);
const downloadDocument = async () => {
const downloadDocument = async (format?: "a4" | "thermal") => {
if (!id) return;
setDownloading(true);
try {
const { data } = await invoicesService.downloadDocument(id);
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
const { data } = await invoicesService.downloadDocument(id, format);
const suffix = format === "thermal" ? "-thermal" : "";
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`);
} catch (error) {
// Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped,
// QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than
// let it become a silent unhandled rejection with just a spinner stopping.
toast({
title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice",
description:
format === "thermal"
? "Thermal rendering requires Chromium on the server. The A4 PDF is still available."
: error instanceof Error
? error.message
: undefined,
variant: "destructive",
});
} finally {
setDownloading(false);
}
@@ -202,17 +242,34 @@ export default function InvoiceDetailPage() {
subtitle={humanize(invoice.source)}
meta={<InvoiceStatusBadge status={invoice.status} />}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Download invoice"
disabled={!canExport}
loading={downloading}
onClick={() => void downloadDocument()}
>
<Download size={16} />
</ActionIcon>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Download invoice"
disabled={!canExport}
loading={downloading}
>
<Download size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Download size={14} />}
onClick={() => void downloadDocument("a4")}
>
Download PDF (A4)
</Menu.Item>
<Menu.Item
leftSection={<Printer size={14} />}
onClick={() => void downloadDocument("thermal")}
>
Download thermal invoice (80mm)
</Menu.Item>
</Menu.Dropdown>
</Menu>
}
/>

View File

@@ -11,7 +11,14 @@ import {
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Search, X } from "lucide-react";
import {
Banknote,
CircleDollarSign,
Landmark,
RefreshCw,
Search,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -21,7 +28,9 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
@@ -79,6 +88,21 @@ export default function InvoicesPanel() {
[pendingActions],
);
// Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page.
const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({
input: {
filter: { search: debouncedQuery, status: statusFilter || undefined },
},
}),
);
const { data: exchangeSettings } = useExchangeSettingsQuery();
const etbCollected = summary?.ETB ?? 0;
const usdCollected = summary?.USD ?? 0;
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate;
const etbFromUsd = rate ? usdCollected * rate : null;
const columns: ColumnDef<Invoice>[] = useMemo(
() => [
{
@@ -95,7 +119,9 @@ export default function InvoicesPanel() {
header: "Billed to",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"}
{row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text>
),
},
@@ -170,7 +196,33 @@ export default function InvoicesPanel() {
);
return (
<Card p={0}>
<Stack gap="md">
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total collected",
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Collected in ETB",
value: formatMoney(etbCollected, "ETB"),
icon: Banknote,
color: "blue",
},
{
label: "Collected in USD",
value: formatMoney(usdCollected, "USD"),
icon: Landmark,
color: "violet",
},
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
@@ -264,6 +316,7 @@ export default function InvoicesPanel() {
</Box>
</Box>
</Stack>
</Card>
</Card>
</Stack>
);
}

View File

@@ -11,6 +11,7 @@ import {
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -55,14 +56,19 @@ function formatRemaining(deadlineMs: number, now: number): string | null {
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
/** Ticks once a second while a deadline is set, so window state updates live. */
function useNow(deadline: string | null): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!deadline) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [deadline]);
return now;
}
function PayWindowCell({ deadline }: { deadline: string | null }) {
const now = useNow(deadline);
if (!deadline) {
return (
@@ -88,13 +94,56 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
);
}
/** True once the pay window has closed — the API refuses confirmation then. */
function windowClosed(row: OfflineUsdInvoice): boolean {
const deadline = row.booking?.paymentDeadline;
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
/**
* "Confirm paid" for one row. Booking invoices are only confirmable while the
* booking's pay window is open (the API refuses otherwise): no window yet →
* no button; window closed → button disabled with the reason, and it flips
* live the second the countdown hits zero. Non-booking invoices (warehouse,
* clearance…) have no window and stay confirmable.
*/
function ConfirmCell({
row,
onConfirm,
}: {
row: OfflineUsdInvoice;
onConfirm: (row: OfflineUsdInvoice) => void;
}) {
const deadline = row.booking?.paymentDeadline ?? null;
const now = useNow(deadline);
if (row.booking && !deadline) return null;
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
return (
<Tooltip
label="Pay window closed — the booking can no longer be confirmed as paid."
disabled={!closed}
withArrow
>
<span>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={closed}
onClick={(e) => {
e.stopPropagation();
onConfirm(row);
}}
>
Confirm paid
</Button>
</span>
</Tooltip>
);
}
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
/**
* Manual Payments tab body of `FinanceHubPage` — page chrome lives in the
* parent. Lists open USD and ETB invoices (import and export alike) that
* Finance settles by hand; confirming records the payment the same way an
* online payment would, so the booking advances identically.
*/
export default function UsdPaymentsPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -103,6 +152,7 @@ export default function UsdPaymentsPanel() {
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(null);
const [reference, setReference] = useState("");
@@ -119,8 +169,15 @@ export default function UsdPaymentsPanel() {
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
currency: currency || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
[
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
statusFilter,
currency,
],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
@@ -170,7 +227,9 @@ export default function UsdPaymentsPanel() {
header: "Customer",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"}
{row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text>
),
},
@@ -179,6 +238,28 @@ export default function UsdPaymentsPanel() {
header: "Booking",
cell: ({ row }) => {
const booking = row.original.booking;
const bookings = row.original.bookings ?? [];
if (!booking && bookings.length) {
// Shipping-line credit invoice: one link per billed booking.
return (
<Group gap={4} wrap="wrap" maw={280}>
{bookings.map((b) => (
<Button
key={b.id}
variant="subtle"
size="compact-xs"
rightSection={<ExternalLink size={11} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${b.id}`);
}}
>
{b.reference}
</Button>
))}
</Group>
);
}
if (!booking) {
return (
<Text size="sm" c="dimmed">
@@ -187,20 +268,41 @@ export default function UsdPaymentsPanel() {
);
}
return (
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
<Group gap={6} wrap="nowrap">
<Button
variant="subtle"
size="compact-sm"
rightSection={<ExternalLink size={13} />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/booking-requests/${booking.id}`);
}}
>
{booking.reference}
</Button>
{booking.tradeDirection && (
<Badge size="xs" variant="light" radius="sm" color="gray">
{humanize(booking.tradeDirection)}
</Badge>
)}
</Group>
);
},
},
{
id: "currency",
header: "Currency",
cell: ({ row }) => (
<Badge
size="sm"
variant="light"
radius="sm"
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
>
{row.original.currency}
</Badge>
),
},
{
id: "status",
header: "Status",
@@ -239,22 +341,8 @@ export default function UsdPaymentsPanel() {
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => {
const paid = row.original.status === "PAID";
if (paid || !canConfirm) return null;
return (
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
disabled={windowClosed(row.original)}
onClick={(e) => {
e.stopPropagation();
setConfirming(row.original);
}}
>
Confirm paid
</Button>
);
if (row.original.status === "PAID" || !canConfirm) return null;
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
},
},
],
@@ -288,6 +376,20 @@ export default function UsdPaymentsPanel() {
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={currency || "all"}
onChange={(v) => {
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
]}
/>
<SegmentedControl
size="sm"
radius="md"
@@ -318,7 +420,7 @@ export default function UsdPaymentsPanel() {
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}>
<Box miw={1160}>
<DataTable
columns={columns}
data={rows}
@@ -326,13 +428,13 @@ export default function UsdPaymentsPanel() {
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No USD invoices match your search."
: "No USD invoices awaiting confirmation."
? "No invoices match your search."
: "No invoices awaiting manual payment confirmation."
}
error={
isError
? {
message: "Failed to load USD invoices.",
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
@@ -361,7 +463,7 @@ export default function UsdPaymentsPanel() {
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm bank transfer payment</Text>
<Text fw={700}>Confirm manual payment</Text>
}
radius="md"
size="md"
@@ -371,20 +473,21 @@ export default function UsdPaymentsPanel() {
<Text size="sm" c="dimmed">
Confirming settles {confirming.invoiceNumber} in full (
{formatMoney(confirming.balanceAmount, confirming.currency)}) and
marks the booking as paid. Upload the customer&apos;s bank slip
first this cannot be undone.
marks the booking as paid exactly as if the customer had paid
online. Upload the customer&apos;s bank slip or receipt first
this cannot be undone.
</Text>
<PhasedFileDropzone
label="Bank payment slip"
description="PDF or image of the customer's transfer slip."
label="Payment slip / receipt"
description="PDF or image of the customer's bank transfer slip or payment receipt."
value={slip}
onChange={setSlip}
/>
<TextInput
label="Bank reference"
description="Optional — the transfer reference from the slip."
label="Payment reference"
description="Optional — the transfer or receipt reference from the slip."
placeholder="e.g. FT24091234567"
value={reference}
onChange={(e) => setReference(e.target.value)}

View File

@@ -580,14 +580,10 @@ const RuleEngineResourcePage = () => {
config={config}
layout="row"
readOnly={!canUpdateControls}
onEdit={
config.slug === "container-types"
? undefined
: (record) => {
setEditing(record);
setFormOpen(true);
}
}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules"
@@ -958,9 +954,7 @@ const RuleEngineResourcePage = () => {
totalCount={totalCount}
onPaginationChange={setPagination}
readOnly={!canUpdate && !canDelete}
onEdit={
canUpdate && config.slug !== "container-types" ? openEdit : undefined
}
onEdit={canUpdate ? openEdit : undefined}
onDelete={canDelete ? setDeleteTarget : undefined}
onViewChain={
config.slug === "approval-rules"

View File

@@ -187,7 +187,7 @@ const RATE_TRIGGERS = [
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Penalty", value: "CONSOLIDATION" },
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" },
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
@@ -265,6 +265,13 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
/**
* Surcharges sold per cargo kind: the admin says container or bulk, then names
* the container type or bulk commodity the fee covers.
*/
const isCargoKindTrigger = (values: Record<string, unknown>) =>
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
@@ -304,7 +311,8 @@ const unitsForShape = (
// Container-only service — per returned container, per wagon, or flat.
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
// Wagon cancellation fee — scales with the cancelled wagons only.
return ["PER_WAGON"];
case "CUSTOMS_CLEARANCE":
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK"
@@ -1054,9 +1062,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showIf: (v) =>
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
},
// ── Trade direction — Bulk & Container base freight, plus the route-
// scoped surcharges (customs clearance; empty-container return, which is
// import-only for now so export is not offered) ────────────────────────
// ── Trade direction — Bulk & Container base freight, plus the directed
// surcharges (customs clearance, cancellation, lashing, fuel; empty-
// container return, which is import-only for now so export is not
// offered) ─────────────────────────────────────────────────────────────
{
name: "tradeDirection",
label: "Trade direction",
@@ -1074,9 +1083,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
!isShippingLineRate(v) &&
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
String(v.trigger ?? ""),
))),
[
"CUSTOMS_CLEARANCE",
"CANCELLATION",
"WITH_RETURN",
"LASHING",
"FUEL",
].includes(String(v.trigger ?? "")))),
},
// Shipping lines only ever ship import — the export leg is sold through
// the customer's contract — so the direction is stated, not asked. Shown
@@ -1097,8 +1110,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
computeValue: () => "IMPORT",
showIf: hasShippingLine,
},
// ── Cargo kind — customs clearance is priced separately for containers
// (one rate per container type) and bulk ───────────────────────────────
// ── Cargo kind — customs clearance and the cancellation fee are priced
// separately for containers (one rate per container type) and bulk (one
// rate per commodity) ──────────────────────────────────────────────────
{
name: "cargoKind",
label: "Cargo kind",
@@ -1107,11 +1121,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: INTERCITY_KINDS,
placeholder: "Is this fee for containers or bulk?",
description:
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
showIf: (v) =>
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
"Container fees are set per container type; bulk fees per commodity. Customs: container per box or wagon, bulk per ton or wagon. Cancellation: per wagon.",
showIf: (v) => v.appliesTo === "OTHER" && isCargoKindTrigger(v),
// Not a stored column: a container fee carries its containerTypeId, a
// bulk fee carries none.
// bulk fee its cargoTypeId.
getInitialValue: (record) =>
record.containerTypeId ? "CONTAINER" : "BULK",
},
@@ -1124,10 +1137,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Which container type this fee covers",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "CUSTOMS_CLEARANCE" &&
isCargoKindTrigger(v) &&
v.cargoKind === "CONTAINER",
},
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
// ── Bulk cargo type — the bulk fee names its commodity ────────────────
{
name: "cargoTypeId",
label: "Bulk cargo type",
@@ -1136,7 +1149,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Which bulk commodity this fee covers",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "CUSTOMS_CLEARANCE" &&
isCargoKindTrigger(v) &&
v.cargoKind === "BULK",
},
// ── Cargo type — a fuel rate names the commodity it covers (different

View File

@@ -10,6 +10,7 @@ import {
MapPin,
Navigation,
PackageCheck,
Pencil,
Train,
} from "lucide-react";
import {
@@ -29,9 +30,10 @@ import {
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import type { TrackStation } from "@/types/trainScheduling";
import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
import {
RouteCorridor,
StatusPill,
@@ -156,6 +158,16 @@ export default function TrainScheduleTrackPage() {
const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
const updateCheckpoint = useMutation(
api.trainScheduling.updateCheckpoint.mutationOptions(),
);
// Time-entry dialogs: logging a pass at a yard with no work (the yard-work
// modal carries its own picker), and correcting an already-logged leg.
const [logModal, setLogModal] = useState<{
station: TrackStation;
isFinal: boolean;
} | null>(null);
const [editModal, setEditModal] = useState<TrainCheckpoint | null>(null);
// Yard work drives the log-pass modal: which bookings board/alight per stop.
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
@@ -241,15 +253,30 @@ export default function TrainScheduleTrackPage() {
const handleLog = (sequenceNo: number) => {
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
if (!station) return;
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
if (station && stationHasWork(station)) {
if (stationHasWork(station)) {
setYardModal({ station, isFinal, alreadyLogged: false });
return;
}
setLogModal({ station, isFinal });
};
const submitLog = (values: { occurredAt: string; note: string }) => {
if (!logModal) return;
const { station, isFinal } = logModal;
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo } },
{
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
occurredAt: values.occurredAt,
...(values.note ? { note: values.note } : {}),
},
},
{
onSuccess: () => {
setLogModal(null);
toast({
title: isFinal
? "Train arrived — assets freed, moved to destination yard"
@@ -266,6 +293,32 @@ export default function TrainScheduleTrackPage() {
);
};
const submitEdit = (values: { occurredAt: string; note: string }) => {
if (!editModal) return;
updateCheckpoint.mutate(
{
id: scheduleId,
sequenceNo: editModal.sequenceNo,
payload: { occurredAt: values.occurredAt, note: values.note || null },
},
{
onSuccess: () => {
setEditModal(null);
toast({ title: "Checkpoint updated" });
},
onError: (err) =>
toast({
title: "Could not update checkpoint",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
// Legs stay correctable for as long as the journey exists — while rolling
// and after arrival.
const canEdit = track.status === "DISPATCHED" || track.status === "ARRIVED";
// "Forgot to load" catch: while the train sits at the current station, any
// boarder there that is still unloaded can be loaded until the next pass.
const currentStationObj = track.stations.find(
@@ -502,6 +555,7 @@ export default function TrainScheduleTrackPage() {
: null
}
onLogCheckpoint={handleLog}
onEditCheckpoint={canEdit ? setEditModal : undefined}
/>
{/* Cargo the operator forgot: boarders at the CURRENT station stay
@@ -595,24 +649,38 @@ export default function TrainScheduleTrackPage() {
)
}
title={
<Group gap="sm">
<Text fw={700} size="sm">
{cp.label ?? `Station ${cp.sequenceNo}`}
</Text>
<Badge
size="xs"
radius="sm"
variant="light"
color={
cp.kind === "ARRIVED"
? "teal"
: cp.kind === "DEPARTED"
? "blue"
: "edr-green"
}
>
{cp.kind}
</Badge>
<Group gap="sm" justify="space-between" wrap="nowrap">
<Group gap="sm">
<Text fw={700} size="sm">
{cp.label ?? `Station ${cp.sequenceNo}`}
</Text>
<Badge
size="xs"
radius="sm"
variant="light"
color={
cp.kind === "ARRIVED"
? "teal"
: cp.kind === "DEPARTED"
? "blue"
: "edr-green"
}
>
{cp.kind}
</Badge>
</Group>
{canEdit ? (
<Button
size="compact-xs"
radius="md"
variant="light"
color="gray"
leftSection={<Pencil size={12} />}
onClick={() => setEditModal(cp)}
>
Edit
</Button>
) : null}
</Group>
}
>
@@ -630,6 +698,39 @@ export default function TrainScheduleTrackPage() {
)}
</Paper>
<CheckpointTimeModal
opened={logModal !== null}
onClose={() => setLogModal(null)}
title={
logModal?.isFinal
? `Mark arrived at ${logModal.station.label}`
: `Log pass at ${logModal?.station.label ?? "station"}`
}
icon={logModal?.isFinal ? <Flag size={18} /> : <MapPin size={18} />}
description={
logModal?.isFinal
? "Marks the train arrived: remaining bookings arrive, assets are freed."
: undefined
}
submitLabel={logModal?.isFinal ? "Mark arrived" : "Log pass"}
submitColor={logModal?.isFinal ? "teal" : "edr-green"}
loading={recordCheckpoint.isPending}
onSubmit={submitLog}
/>
<CheckpointTimeModal
opened={editModal !== null}
onClose={() => setEditModal(null)}
title={`Edit ${editModal?.label ?? "checkpoint"}`}
icon={<Pencil size={18} />}
description="Corrects this leg's time and note only — nothing else changes."
initialOccurredAt={editModal?.occurredAt}
initialNote={editModal?.note}
submitLabel="Save"
loading={updateCheckpoint.isPending}
onSubmit={submitEdit}
/>
<LogPassYardWorkModal
opened={yardModal !== null}
onClose={() => setYardModal(null)}

View File

@@ -34,6 +34,7 @@ import {
Navigation,
Package,
PackageCheck,
Grid3x3,
Route as RouteIcon,
Ruler,
Send,
@@ -41,6 +42,7 @@ import {
Weight,
Workflow as WorkflowIcon,
} from "lucide-react";
import { DateTimePicker } from "@mantine/dates";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -57,6 +59,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
@@ -125,6 +128,13 @@ export default function TrainScheduleV2DetailPage() {
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
const [gatepassNotes, setGatepassNotes] = useState("");
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
// Actual departure — staff often dispatch on paper first and record it later,
// so the time is picked (defaults to now when the dialog opens).
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
const openDispatchConfirm = () => {
setDispatchAt(new Date());
setDispatchConfirmOpen(true);
};
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
@@ -477,7 +487,10 @@ export default function TrainScheduleV2DetailPage() {
const runDispatch = async () => {
setDispatchConfirmOpen(false);
try {
await dispatch.mutateAsync(scheduleId);
await dispatch.mutateAsync({
id: scheduleId,
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
});
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
@@ -873,7 +886,7 @@ export default function TrainScheduleV2DetailPage() {
radius="md"
leftSection={<Send size={18} />}
loading={dispatch.isPending}
onClick={() => setDispatchConfirmOpen(true)}
onClick={openDispatchConfirm}
>
Dispatch train
</Button>
@@ -1271,6 +1284,9 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
Leg capacity
</Tabs.Tab>
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
Leg board
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History
</Tabs.Tab>
@@ -1359,6 +1375,13 @@ export default function TrainScheduleV2DetailPage() {
<LegCapacityPanel schedule={schedule} />
</Tabs.Panel>
<Tabs.Panel value="leg-board">
<LegLoadBoardPanel
schedule={schedule}
onChanged={() => void detailQuery.refetch()}
/>
</Tabs.Panel>
<Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel>
@@ -1444,6 +1467,17 @@ export default function TrainScheduleV2DetailPage() {
undone.
</Text>
<DateTimePicker
label="Actual departure"
description="When the train left — defaults to now; a past time is fine."
value={dispatchAt}
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
{hasDispatchWarnings ? (
<Alert
color="orange"

View File

@@ -18,6 +18,7 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios";
import {
@@ -134,6 +135,8 @@ export default function TrainScheduleV2ListPage() {
// confirmation.
const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null);
// Actual departure — defaults to now when the dialog opens; past is fine.
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] =
useState<TrainScheduleListItem | null>(null);
@@ -362,6 +365,7 @@ export default function TrainScheduleV2ListPage() {
{row.original.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={row.original} />
</Group>
<Box maw={220}>
<RouteCorridor
@@ -507,7 +511,10 @@ export default function TrainScheduleV2ListPage() {
{canDispatch && schedule.status === "SCHEDULED" ? (
<Menu.Item
leftSection={<Play size={15} />}
onClick={() => setDispatchTarget(schedule)}
onClick={() => {
setDispatchAt(new Date());
setDispatchTarget(schedule);
}}
>
Start (dispatch) train
</Menu.Item>
@@ -742,7 +749,11 @@ export default function TrainScheduleV2ListPage() {
onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
rowStyle={(schedule) =>
schedule.shippingLineCompanyId
? SHIPPING_LINE_ROW_STYLE
: directionRowStyle(schedule.direction)
}
error={
schedulesQuery.isError
? {
@@ -954,6 +965,16 @@ export default function TrainScheduleV2ListPage() {
wagons or cargo not yet marked loaded those warnings are shown
there, not here.
</Text>
<DateTimePicker
label="Actual departure"
description="When the train left — defaults to now; a past time is fine."
value={dispatchAt}
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDispatchTarget(null)}>
Cancel
@@ -965,7 +986,12 @@ export default function TrainScheduleV2ListPage() {
onClick={async () => {
if (!dispatchTarget) return;
try {
await dispatchSchedule.mutateAsync(dispatchTarget.id);
await dispatchSchedule.mutateAsync({
id: dispatchTarget.id,
payload: dispatchAt
? { actualDepartureAt: dispatchAt.toISOString() }
: {},
});
toast({ title: "Train dispatched" });
setDispatchTarget(null);
void schedulesQuery.refetch();
@@ -1052,6 +1078,20 @@ export default function TrainScheduleV2ListPage() {
* bookings that have not paid yet — that space is claimed, so it is not
* bookable.
*/
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
const SHIPPING_LINE_ROW_STYLE = {
backgroundColor: "var(--mantine-color-edr-green-0)",
} as const;
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
if (!schedule.shippingLineCompanyId) return null;
return (
<Badge size="xs" variant="light" color="edr-green">
{schedule.shippingLineCompanyName ?? "Shipping line"}
</Badge>
);
}
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
// renders rather than reading 0 used on every train.
@@ -1133,6 +1173,7 @@ function ScheduleCard({
withBorder
onClick={onOpen}
className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
style={schedule.shippingLineCompanyId ? SHIPPING_LINE_ROW_STYLE : undefined}
>
<Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
@@ -1182,6 +1223,7 @@ function ScheduleCard({
{schedule.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={schedule} />
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />

View File

@@ -961,6 +961,7 @@ interface StandaloneReturnModalProps {
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>("");
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
@@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
containers: [
{
containerNumber,
containerSize: containerSize ?? undefined,
returnDate,
warehouse: selectedWarehouse?.name || warehouse,
yard: selectedYard?.name,
@@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
});
setContainerNumber("");
setContainerSize(null);
setReturnedBy(null);
setReturnDate(new Date().toISOString().split("T")[0]);
setWarehouse(null);
@@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
required
/>
<Select
label="Container Type"
placeholder="Select container size"
value={containerSize}
onChange={(val) => setContainerSize(val as EmptyContainerSize | null)}
data={[
{ value: "20", label: "20 ft" },
{ value: "40", label: "40 ft" },
]}
/>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"

View File

@@ -39,6 +39,7 @@ import type {
} from "@/types/fileUploadSettings";
import type {
Invoice,
InvoiceCollectedSummary,
InvoiceListFilter,
PaginatedInvoices,
PaginatedOfflineUsdInvoices,
@@ -80,6 +81,8 @@ import type {
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow,
ScheduleMergePreview,
TrainScheduleDetail,
@@ -173,7 +176,7 @@ import { customersService } from "./customers.service";
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
import { shippingLineCreditsService } from "./shippingLineCredits.service";
import { eimsService } from "./eims.service";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVerifyResult } from "@/types/eims";
import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -796,10 +799,13 @@ export const api = {
],
),
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
dispatchSchedule: endpoint<
{ id: string; payload?: DispatchSchedulePayload },
TrainScheduleDetail
>(
"train-scheduling",
"dispatch-schedule",
(id) => trainSchedulingService.dispatchSchedule(id),
({ id, payload }) => trainSchedulingService.dispatchSchedule(id, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
@@ -917,6 +923,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateCheckpoint: endpoint<
{ id: string; sequenceNo: number; payload: UpdateCheckpointPayload },
TrainTrackResponse
>(
"train-scheduling",
"update-checkpoint",
({ id, sequenceNo, payload }) =>
trainSchedulingService.updateCheckpoint(id, sequenceNo, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
arriveSchedule: endpoint<string, TrainScheduleDetail>(
"train-scheduling",
"arrive-schedule",
@@ -3137,6 +3155,16 @@ export const api = {
({ id }) => QUERY_KEYS.INVOICES.byId(id),
),
collectedSummary: endpoint<
{ filter: Omit<InvoiceListFilter, "page" | "pageSize"> },
InvoiceCollectedSummary
>(
"invoices",
"collectedSummary",
({ filter }) => invoicesService.collectedSummary(filter),
({ filter }) => QUERY_KEYS.INVOICES.summary(filter),
),
listOfflineUsd: endpoint<
{ filter: InvoiceListFilter },
PaginatedOfflineUsdInvoices
@@ -3189,6 +3217,51 @@ export const api = {
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
eimsCancel: endpoint<{ id: string; reasonCode: string; remark?: string }, EimsInvoiceStatusView>(
"invoices",
"eimsCancel",
({ id, reasonCode, remark }) => eimsService.cancel(id, { reasonCode, remark }),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
eimsReceipts: endpoint<{ id: string }, EimsReceiptView[]>(
"invoices",
"eimsReceipts",
({ id }) => eimsService.listReceipts(id),
({ id }) => QUERY_KEYS.INVOICES.eimsReceipts(id),
),
eimsRegisterSalesReceipt: endpoint<
{ id: string; modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
EimsReceiptView
>(
"invoices",
"eimsRegisterSalesReceipt",
({ id, ...input }) => eimsService.registerSalesReceipt(id, input),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
),
eimsRegisterWithholdingReceipt: endpoint<
{ id: string; type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
EimsReceiptView
>(
"invoices",
"eimsRegisterWithholdingReceipt",
({ id, ...input }) => eimsService.registerWithholdingReceipt(id, input),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsReceipts(id)],
),
issueMemo: endpoint<{ id: string; type: "CRE" | "DEB"; reason: string }, Invoice>(
"invoices",
"issueMemo",
({ id, ...input }) => invoicesService.issueMemo(id, input),
undefined,
() => [QUERY_KEYS.INVOICES.ROOT],
),
},
overview: {

View File

@@ -36,6 +36,8 @@ export interface BookingListFilter {
scheduledTo?: string;
originYardId?: string;
destinationYardId?: string;
/** SHIPPING_LINE = booked by a shipping line; CUSTOMER = ordinary customer company. */
customerKind?: "SHIPPING_LINE" | "CUSTOMER";
/** "true" = government bookings only, "false" = private only. */
isGovernment?: "true" | "false";
/** Free-text search: booking reference, customer name, contract reference (server-side). */
@@ -151,6 +153,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
}
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
params,
@@ -184,6 +187,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
if (filter.customsClearingEnabled)
params.customsClearingEnabled = filter.customsClearingEnabled;
if (filter.search) params.search = filter.search;

View File

@@ -1,6 +1,11 @@
import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
import type {
EimsInvoiceStatusView,
EimsModeOfPayment,
EimsReceiptView,
EimsVerifyResult,
} from "@/types/eims";
/**
* MoR EIMS filing actions on an invoice.
@@ -36,4 +41,45 @@ export const eimsService = {
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
.then((r) => r.data);
},
/** Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled one. */
cancel(
invoiceId: string,
input: { reasonCode: string; remark?: string },
): Promise<EimsInvoiceStatusView> {
return apiClient
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.CANCEL(invoiceId), input)
.then((r) => r.data);
},
registerSalesReceipt(
invoiceId: string,
input: { modeOfPayment: EimsModeOfPayment; reason?: string; collectedAmount?: number },
): Promise<EimsReceiptView> {
return apiClient
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_SALES(invoiceId), input)
.then((r) => r.data);
},
registerWithholdingReceipt(
invoiceId: string,
input: { type: string; preTaxAmount: number; withholdingAmount: number; reason?: string },
): Promise<EimsReceiptView> {
return apiClient
.post<EimsReceiptView>(URL_CONSTANTS.EIMS.RECEIPT_WITHHOLDING(invoiceId), input)
.then((r) => r.data);
},
listReceipts(invoiceId: string): Promise<EimsReceiptView[]> {
return apiClient
.get<EimsReceiptView[]>(URL_CONSTANTS.EIMS.RECEIPTS(invoiceId))
.then((r) => r.data);
},
/** Blob download — same pattern as `invoicesService.downloadDocument`. */
downloadReceiptDocument(invoiceId: string, receiptId: string) {
return apiClient.get<Blob>(URL_CONSTANTS.EIMS.RECEIPT_DOCUMENT(invoiceId, receiptId), {
responseType: "blob",
});
},
};

View File

@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
Invoice,
InvoiceCollectedSummary,
InvoiceListFilter,
PaginatedInvoices,
PaginatedOfflineUsdInvoices,
@@ -23,19 +24,42 @@ export const invoicesService = {
.then((r) => r.data);
},
/** Total collected (paidAmount) across every filtered invoice, by currency. */
collectedSummary(
filter: Omit<InvoiceListFilter, "page" | "pageSize">,
): Promise<InvoiceCollectedSummary> {
return apiClient
.get<InvoiceCollectedSummary>(URL_CONSTANTS.BILLING.INVOICES_SUMMARY, {
params: cleanParams(filter),
})
.then((r) => r.data);
},
getById(id: string): Promise<Invoice> {
return apiClient
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
.then((r) => r.data);
},
downloadDocument(id: string) {
/** `format` omitted or "a4" → standard A4 PDF; "thermal" → 80mm thermal layout (ADD-P001). */
downloadDocument(id: string, format?: "a4" | "thermal") {
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
responseType: "blob",
params: format ? { format } : undefined,
});
},
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
/** Issue a credit/debit memo against a registered invoice (MoR DEB/CRE) — filing-equivalent. */
issueMemo(
id: string,
input: { type: "CRE" | "DEB"; reason: string },
): Promise<Invoice> {
return apiClient
.post<Invoice>(URL_CONSTANTS.BILLING.INVOICE_MEMO(id), input)
.then((r) => r.data);
},
/** Finance worklist: USD and ETB invoices awaiting manual payment confirmation. */
listOfflineUsd(
filter: InvoiceListFilter,
): Promise<PaginatedOfflineUsdInvoices> {
@@ -46,7 +70,7 @@ export const invoicesService = {
.then((r) => r.data);
},
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */
/** Confirm an invoice (USD or ETB) paid manually — the slip file is required. */
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
const body = new FormData();
body.append("file", file);

View File

@@ -28,6 +28,8 @@ import type {
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow,
ScheduleMergePreview,
TrainScheduleDetail,
@@ -524,10 +526,11 @@ export const trainSchedulingService = {
dispatchSchedule: async (
scheduleId: string,
payload: DispatchSchedulePayload = {},
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
{},
payload,
);
return unwrap(response.data);
},
@@ -696,6 +699,18 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
updateCheckpoint: async (
scheduleId: string,
sequenceNo: number,
payload: UpdateCheckpointPayload,
): Promise<TrainTrackResponse> => {
const response = await client.patch<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINT(scheduleId, sequenceNo),
payload,
);
return unwrap(response.data);
},
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),

View File

@@ -26,6 +26,9 @@ export interface Wagon {
lengthMeters?: number;
} | null;
status: Freight.WagonStatus;
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
lastMaintenanceAt?: string | null;
lastAvailableAt?: string | null;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */

View File

@@ -251,6 +251,10 @@ export interface BookingDetail {
updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef & Partial<BookingCompany>;
/** Set when booked by a shipping line (then `companyId`/`company` are null). */
shippingLineCompanyId?: string | null;
/** Owner when booked by a shipping line (then `company` is absent). Hydrated server-side. */
shippingLineCompany?: { id: string; name: string; email?: string | null; phoneNumber?: string | null };
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
@@ -273,6 +277,8 @@ export interface BookingListRow {
/** Needed to link the reference to the contract's detail page. */
contractId?: string | null;
customerLabel: string;
/** True when the booking is owned by a shipping line rather than a customer company. */
isShippingLine?: boolean;
status: BookingStatus;
scheduledDate: string;
totalAmount: number;

View File

@@ -65,3 +65,16 @@ export interface EimsVerifyResult {
[section: string]: unknown;
};
}
/** MoR `ModeOfPayment` enum — confirmed by a live schema error, verbatim spelling/casing. */
export const EIMS_MODE_OF_PAYMENT = [
"CASH",
"CHEQUE",
"CPO",
"Local Bank Transfer",
"SWIFT",
"Wire Transfer",
"Letter of Credit",
"Card",
] as const;
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];

View File

@@ -13,6 +13,8 @@ export interface InvoiceListFilter {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
/** Manual-payments worklist only. */
currency?: "USD" | "ETB";
}
/** Standard paginated list envelope (matches the customers/bookings service shape). */
@@ -22,20 +24,27 @@ export interface PaginatedInvoices {
}
/**
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
* carry the shipment's pay-window deadline so the list can show the same
* countdown the customer sees — Finance must confirm before it closes.
* A USD or ETB invoice on Finance's manual-settlement worklist. Booking-sourced
* rows carry the shipment's trade direction and pay-window deadline so the list
* can show the same countdown the customer sees — Finance must confirm before
* it closes.
*/
export interface OfflineUsdInvoice extends Invoice {
booking: {
id: string;
reference: string;
tradeDirection: string | null;
paymentDeadline: string | null;
paymentStatus: string;
} | null;
/** Shipping-line credit invoices span many bookings — one entry per credit. */
bookings: { id: string; reference: string; tradeDirection: string | null }[];
}
export interface PaginatedOfflineUsdInvoices {
items: OfflineUsdInvoice[];
total: number;
}
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
export type InvoiceCollectedSummary = Record<string, number>;

View File

@@ -196,6 +196,9 @@ export interface TrainScheduleListItem {
origin: string | null;
destination: string | null;
freightType?: FreightType | null;
/** Set when the departure is dedicated to one shipping line (hidden from customers). */
shippingLineCompanyId?: string | null;
shippingLineCompanyName?: string | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train?: {
id: string;
@@ -907,10 +910,22 @@ export interface TrainTrackResponse {
export interface RecordCheckpointPayload {
sequenceNo: number;
kind?: TrainCheckpointKind;
/** When the train was at the station; defaults to now. Past OK, future rejected. */
occurredAt?: string;
note?: string;
}
/** Edit an already-logged leg — pure correction, no side effects. */
export interface UpdateCheckpointPayload {
occurredAt?: string;
note?: string | null;
}
export interface DispatchSchedulePayload {
/** Actual departure; defaults to now. Past OK, future rejected. */
actualDepartureAt?: string;
}
export interface TrainScheduleFilters {
originStationId?: string;
destinationStationId?: string;

View File

@@ -1,18 +1,32 @@
/**
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
* VGM column showed a 20-item / 100T booking as "20 tons".
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Container
* bookings never store a total at all — `cargoTotalWeightVgm` stays 0 and the
* weight lives per line (`quantity × vgmPerUnitTons`). Every other booking
* stores tons in `cargoTotalWeightVgm` directly. Rendering the raw VGM column
* showed a 20-item / 100T booking as "20 tons" and every container booking as
* "0 tons".
*/
export function cargoTonsAndItems(booking: {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
bookingContainers?: Array<{
quantity?: number | string | null;
vgmPerUnitTons?: number | string | null;
}> | null;
}): { tons: number; items: number | null } {
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
if (booking.freightType === "BULK" && bulkTons > 0) {
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
}
if (vgm <= 0 && booking.bookingContainers?.length) {
const lineTons = booking.bookingContainers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0) * Number(c.vgmPerUnitTons ?? 0),
0,
);
return { tons: Math.round(lineTons * 1000) / 1000, items: null };
}
return { tons: vgm, items: null };
}

View File

@@ -26,7 +26,7 @@ import {
Menu as MenuIcon,
Plus,
RefreshCw,
Search,
// Search,
Settings,
Upload,
User,
@@ -423,7 +423,7 @@ export function AppLayout({
)}
{/* Search pill */}
<Group
{/* <Group
gap={8}
align="center"
visibleFrom="sm"
@@ -441,7 +441,7 @@ export function AppLayout({
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
Search shipments, bookings…
</Text>
</Group>
</Group> */}
{/* Notifications */}
<NotificationBellContainer />
@@ -549,7 +549,7 @@ export function AppLayout({
navigate("/bookings/new", { state: { fresh: true } })
}
>
New Booking
New Contract
</Menu.Item>
<Divider />
<Menu.Item

View File

@@ -158,7 +158,9 @@ export function ReadonlyBookingView({
// the customer. Hide the customer's rebook everywhere and tell them GL will
// handle it. Non-customs bookings stay self-service.
const isCustoms = Boolean(booking.customsClearingEnabled);
const canSelfRebook = !isCustoms;
// A PAID booking cancelled through wagon cancellation rebooks via its credit
// (WagonCancellationCard), not the fresh-booking rebook link.
const canSelfRebook = !isCustoms && booking.paymentStatus !== "PAID";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
const isClearance = [
"AWAITING_DOCUMENTS",
@@ -219,7 +221,9 @@ export function ReadonlyBookingView({
subtitle={
status === "REJECTED"
? "This booking request has been rejected."
: "This booking process has been terminated."
: booking.paymentStatus === "PAID"
? "All wagons were cancelled. Your paid freight is held as a credit — rebook it from the Wagon Cancellation card below."
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={canSelfRebook ? onRebook : undefined}
@@ -348,6 +352,7 @@ export function ReadonlyBookingView({
<Tabs.Panel value="wagons">
<WagonsTab
bookingId={booking.id}
currency={booking.paymentCurrency}
cancellable={
booking.status === "PAID" &&
booking.paymentStatus === "PAID" &&

View File

@@ -11,7 +11,7 @@ import {
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
import { CheckCircle2, Clock, CreditCard } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link, useNavigate } from "react-router-dom";
@@ -76,7 +76,7 @@ const apiErrorMessage = (error: unknown, fallback: string) => {
const th = { color: "#9AA8B5", fontSize: 11 } as const;
/**
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
* Wagon cancellation (partial or whole) on a PAID contract booking: request a cut (fee
* previewed first), pay the cancellation fee, then rebook the freed credit
* onto another shipment day — plus the booking's cancellation history.
* Wagons leave the schedule at request time; the fee settles the credit.
@@ -90,10 +90,13 @@ export function WagonCancellationCard({
}) {
const navigate = useNavigate();
const status = booking.status as string;
const eligible =
status === "PAID" &&
(booking.paymentStatus as string) === "PAID" &&
!!booking.contractId;
const paidContract =
(booking.paymentStatus as string) === "PAID" && !!booking.contractId;
// New cuts only on a live PAID booking; a booking fully cancelled through
// this flow (status CANCELLED) still shows the card so its credit can be
// paid for / rebooked.
const canRequest = status === "PAID" && paidContract;
const eligible = paidContract && (status === "PAID" || status === "CANCELLED");
const isBulk = booking.freightType === "BULK";
const detail = booking as BookingDetail;
@@ -226,12 +229,13 @@ export function WagonCancellationCard({
});
if (!eligible) return null;
if (!canRequest && !ownRows.length) return null;
return (
<SectionCard>
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{!openRow && !creditRow && (
{/* {canRequest && !openRow && !creditRow && (
<Button
variant="default"
radius="md"
@@ -240,7 +244,7 @@ export function WagonCancellationCard({
>
Cancel wagons
</Button>
)}
)} */}
</Group>
{openRow ? (
@@ -305,9 +309,9 @@ export function WagonCancellationCard({
</Stack>
) : (
<Text fz={13} c="#475569">
Need fewer wagons than you paid for? Cancel part of this booking for
a per-wagon fee the freed freight amount becomes a credit you can
rebook onto another shipment day.
Need fewer wagons than you paid for or none? Cancel part or all of
this booking for a per-wagon fee the freed freight amount becomes a
credit you can rebook onto another shipment day.
</Text>
)}
@@ -401,16 +405,16 @@ export function WagonCancellationCard({
{booking.reference}
</Text>{" "}
to cancel. A per-wagon fee applies; once it&apos;s paid the wagons
are released and the freed amount becomes a rebooking credit. At
least one wagon must remain to cancel everything, cancel the
whole booking instead.
are released and the freed amount becomes a rebooking credit.
Cancelling every wagon cancels the whole booking the full freight
amount becomes your credit.
</Text>
{isBulk ? (
<NumberInput
label="Wagons to cancel"
min={1}
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
max={wagonsRequired > 0 ? wagonsRequired : undefined}
allowDecimal={false}
value={wagons}
onChange={(v) => {

Some files were not shown because too many files have changed in this diff Show More