This commit is contained in:
Marshal
2026-08-08 14:02:58 +00:00
104 changed files with 8507 additions and 962 deletions

View File

@@ -60,6 +60,8 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
...over,
});
@@ -130,7 +132,7 @@ describe("toEimsInvoice", () => {
ExciseTaxValue: 0,
TotalLineAmount: 11500,
Unit: "PCS",
NatureOfSupplies: "Service",
NatureOfSupplies: "service",
HarmonizationCode: null,
});
expect(doc.ItemList[1]).toMatchObject({
@@ -207,6 +209,77 @@ describe("toEimsInvoice", () => {
});
});
describe("toEimsInvoice — MoR field constraints", () => {
it("passes a buyer region through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Region).toBe("13");
});
it("maps a region name to its code, ignoring case and spacing", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
seller,
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
);
expect(doc.BuyerDetails.Region).toBe("13");
});
it("refuses to file a buyer whose region has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
seller,
context(),
),
).toThrow(/not a MoR Region code and has no mapping/);
});
it("refuses a buyer with no region at all rather than guessing one", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: null } }),
seller,
context(),
),
).toThrow(/buyer Region \(unset\)/);
});
it("passes a buyer wereda through when it is already a MoR code", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Wereda).toBe("574");
});
it("maps a wereda name to its code", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: { Yeka: "99" } }),
);
expect(doc.BuyerDetails.Wereda).toBe("99");
});
it("refuses to file a buyer whose wereda has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: {} }),
),
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_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");
});
it("rejects a NatureOfSupplies MoR does not accept", () => {
expect(() =>
toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Services" })),
).toThrow(/must be one of goods, service/);
});
});
describe("formatEimsDate", () => {
it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => {
expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00");

View File

@@ -210,6 +210,22 @@ export interface EimsMapperContext {
relatedDocument?: string | null;
/** MoR numeric country code for the buyer; our DB stores the country name. */
buyerCountryCode?: string | null;
/**
* Region name → MoR numeric code, for buyers whose stored region is free text.
*
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
* tax document is worse than refusing to file.
*/
buyerRegionCodes: Record<string, string>;
/**
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
@@ -220,6 +236,22 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/**
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
* the way it named Region's.
*/
const LOCATION_CODE = /^[0-9]{1,3}$/;
/**
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
*
* Its schema branches on this as a `oneOf` with a `const` per branch, so `"Service"` fails the
* whole `ItemList` — the error reads "must be the constant value 'service'".
*/
const NATURE_OF_SUPPLIES = ["goods", "service"] as const;
const num = (v: number | string): number => {
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`);
@@ -240,6 +272,33 @@ export const formatEimsDate = (issuedAt: Date): string =>
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
* 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.
*/
function resolveLocationCode(
field: "Region" | "Wereda",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
): string {
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];
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
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}.`,
);
}
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
@@ -266,6 +325,14 @@ export function toEimsInvoice(
throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`);
}
const natureOfSupplies = context.natureOfSupplies.trim().toLowerCase();
if (!NATURE_OF_SUPPLIES.includes(natureOfSupplies as (typeof NATURE_OF_SUPPLIES)[number])) {
throw new Error(
`EIMS mapping: NatureOfSupplies must be one of ${NATURE_OF_SUPPLIES.join(", ")}, ` +
`got "${context.natureOfSupplies}"`,
);
}
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
const lineNumber = index + 1;
const tax = context.taxForLine(line, lineNumber);
@@ -285,7 +352,7 @@ export function toEimsInvoice(
Discount: 0,
ExciseTaxValue,
HarmonizationCode: null,
NatureOfSupplies: context.natureOfSupplies,
NatureOfSupplies: natureOfSupplies,
ItemCode: line.chargeType,
ProductDescription: line.description?.trim() || line.chargeType,
PreTaxValue,
@@ -329,12 +396,24 @@ export function toEimsInvoice(
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: company.region ?? null,
Region: resolveLocationCode(
"Region",
company.region,
context.buyerRegionCodes,
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
Wereda: company.woreda ?? null,
Wereda: resolveLocationCode(
"Wereda",
company.woreda,
context.buyerWeredaCodes,
"EIMS_BUYER_WEREDA_CODES",
invoice.invoiceNumber,
),
},
DocumentDetails: {
DocumentNumber: context.documentNumber,

View File

@@ -115,6 +115,10 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
eimsIrn?: string | null;
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
eimsInvoiceCounter?: number | null;

View File

@@ -57,6 +57,37 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
});
}
assertSellerFormats(config.invoice);
}
/**
* MoR's own patterns for the seller fields, checked here rather than at the gateway.
*
* A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a
* consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us.
*/
const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [
{ env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ },
{
env: "EIMS_SELLER_EMAIL",
value: (i) => i.sellerEmail,
pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/,
},
{ env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ },
{ env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ },
];
function assertSellerFormats(invoice: EimsConfig["invoice"]): void {
const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map(
({ env, pattern }) => `${env} (must match ${pattern.source})`,
);
if (bad.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INVALID",
message: `EIMS seller details would be rejected by MoR: ${bad.join("; ")}`,
});
}
}
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
@@ -112,6 +143,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -6,6 +6,7 @@ import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { eimsInvoiceConfig } from "./eims-test-fixtures";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
@@ -91,9 +92,11 @@ class FakeDb {
id: "state-1",
systemNumber: SYSTEM_NUMBER,
nextInvoiceCounter: 7,
nextDocumentNumber: 5,
previousIrn: null,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
...state,
} as EimsSystemState;
@@ -130,7 +133,10 @@ class FakeDb {
return {
manager: this.manager,
getRepository: this.manager.getRepository,
query: async () => LINES,
query: async (sql: string) =>
sql.includes("eims_system_state")
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
: LINES,
transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.();
return body(this.manager);
@@ -147,17 +153,26 @@ const build = (
postSigned: jest.Mock,
cfg: EimsConfig = config(),
postBearer: jest.Mock = jest.fn(),
getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION),
getSessionContext: jest.Mock | undefined = undefined,
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
) =>
new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer } as unknown as EimsClientService,
{ getSessionContext } as unknown as EimsAuthService,
{
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
} as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService,
);
/** Document number the fixtures register under; `/v1/verify` must echo it back. */
const DOCUMENT_NUMBER = "INV-20260807-00042";
/**
* Document number the fixtures register under; `/v1/verify` must echo it back.
*
* A plain integer, not our `invoiceNumber`: MoR validates the field against
* `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above.
*/
const DOCUMENT_NUMBER = "5";
/**
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
@@ -220,7 +235,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.SourceSystem.InvoiceCounter).toBe(42);
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042");
expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER);
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
});
@@ -345,7 +360,9 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 8, // consumed: the attempt reached the gateway
// Returned, not consumed: MoR tracks the sequence and rejects a gap
// ("Invoice counter is not correct. expected : 1").
nextInvoiceCounter: 7,
});
});
@@ -391,7 +408,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("never reuses a counter once an attempt has begun", async () => {
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest
.fn()
@@ -404,8 +421,72 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
// The two numbers move differently, because MoR constrains them differently: the counter must
// not skip (it returns), the document number must not repeat (it is burned).
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
expect(first.SourceSystem.InvoiceCounter).toBe(7);
expect(second.SourceSystem.InvoiceCounter).toBe(7);
expect(first.DocumentDetails.DocumentNumber).toBe("5");
expect(second.DocumentDetails.DocumentNumber).toBe("6");
});
});
describe("EimsInvoiceRegistrationService staff alerting", () => {
it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(EimsApiException);
expect(notify).toHaveBeenCalledTimes(1);
const sent = notify.mock.calls[0][0];
expect(sent.priority).toBe("HIGH");
expect(sent.title).toMatch(/blocked/i);
expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve");
});
it("raises a normal-priority alert for a deterministic rejection", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(EimsApiException);
expect(notify.mock.calls[0][0].priority).toBe("NORMAL");
});
it("does not alert on a successful filing", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn();
await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify)
.registerInvoiceWithEims(INVOICE_ID);
expect(notify).not.toHaveBeenCalled();
});
it("lets the filing outcome stand even if the alert itself fails", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockRejectedValue(new Error("inbox down"));
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/EIMS register failed \(406\)/);
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
});
});
@@ -447,7 +528,15 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const blocked = () =>
new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], {
new FakeDb(
[
invoiceRow({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsInvoiceCounter: 7,
eimsDocumentNumber: DOCUMENT_NUMBER,
}),
],
{
inFlightInvoiceId: INVOICE_ID,
inFlightCounter: 7,
nextInvoiceCounter: 8,
@@ -498,13 +587,13 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue(
verifyResponse({
DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" },
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
}),
);
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/not INV-20260807-00042/);
).rejects.toThrow(/not 5/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
@@ -547,7 +636,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
it("refuses to resolve an invoice that is not the in-flight one", async () => {
const db = blocked();
db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID }));
db.invoices.set(
OTHER_INVOICE_ID,
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
);
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
await expect(

View File

@@ -17,6 +17,9 @@ import {
EimsMapperLine,
toEimsInvoice,
} from "../billing/eims-invoice.mapper";
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
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";
@@ -44,6 +47,8 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
interface Reservation {
stateId: string;
invoiceCounter: number;
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
documentNumber: string;
previousIrn: string;
}
@@ -72,6 +77,7 @@ export class EimsInvoiceRegistrationService {
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
) {}
private get cfg(): EimsConfig {
@@ -98,8 +104,9 @@ export class EimsInvoiceRegistrationService {
invoice,
buildEimsSeller(cfg),
buildEimsContext(cfg, {
// Our own invoice number is the document number; EIMS only requires it to be unique.
documentNumber: invoice.invoiceNumber,
// 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,
@@ -174,8 +181,9 @@ export class EimsInvoiceRegistrationService {
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
* IRN is the one it holds, and that it belongs to this invoice.
*
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
* from our own `invoiceNumber` — the only field tying an IRN back to a row in this database.
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
* allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back
* to a row in this database.
*
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
* chains every later document to a stranger's reference, so both checks are refusals rather
@@ -231,11 +239,34 @@ export class EimsInvoiceRegistrationService {
});
}
// Cheap ownership check before touching the gateway: resolving an invoice that does not hold
// the reservation is a caller mistake, not something to spend a MoR round trip on. The
// authoritative re-check happens under lock in the transaction below.
const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query(
`SELECT in_flight_invoice_id FROM freight.eims_system_state
WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`,
[(await this.auth.getSessionContext()).systemNumber],
);
if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) {
throw new ConflictException({
code: "EIMS_RESOLVE_WRONG_INVOICE",
message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`,
});
}
// Outside the transaction: no lock is held across the wire, and a refused verification must
// leave the block exactly as it was.
if (irn) {
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber);
if (!invoice.eimsDocumentNumber) {
throw new BadRequestException({
code: "EIMS_NO_DOCUMENT_NUMBER",
message:
`Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` +
"returned IRN cannot be tied back to it.",
});
}
await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber);
}
// Same source of truth as registration: the state row is keyed by the token's system number.
@@ -266,6 +297,7 @@ export class EimsInvoiceRegistrationService {
...(irn ? { previousIrn: irn } : {}),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
@@ -311,23 +343,27 @@ export class EimsInvoiceRegistrationService {
if (invoice.eimsIrn) return null;
const invoiceCounter = Number(state.nextInvoiceCounter);
const documentNumber = String(Number(state.nextDocumentNumber));
const previousIrn = state.previousIrn ?? "";
// Counter consumed here, not on success: once an attempt begins it can never be reused,
// whatever happens next. A gap is harmless at MoR; a collision is not.
await manager.update(EimsSystemState, state.id, {
nextInvoiceCounter: invoiceCounter + 1,
nextDocumentNumber: Number(documentNumber) + 1,
inFlightInvoiceId: invoiceId,
inFlightCounter: invoiceCounter,
inFlightDocumentNumber: Number(documentNumber),
});
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Submitting,
eimsInvoiceCounter: invoiceCounter,
eimsDocumentNumber: documentNumber,
eimsSubmittedAt: new Date(),
eimsLastError: null,
});
return { stateId: state.id, invoiceCounter, previousIrn };
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
});
}
@@ -350,15 +386,26 @@ export class EimsInvoiceRegistrationService {
previousIrn: irn,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
}
/**
* TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and
* blocks the system number, because `PreviousIrn` is now unknown for every later document.
* The counter stays consumed either way.
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* The two numbers move differently, because MoR constrains them differently:
*
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
* document MoR definitively refused was never counted there, so ours must not advance either.
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
* unique". It is therefore spent by the attempt itself and never handed back, even for a
* refusal.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
*/
private async settleFailure(
invoiceId: string,
@@ -386,7 +433,15 @@ export class EimsInvoiceRegistrationService {
EimsSystemState,
reservation.stateId,
deterministic
? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null }
? {
// Counter returns (MoR never counted a refused document); the document number does
// not (MoR requires it to be unique, so it is burned by the attempt).
nextInvoiceCounter: reservation.invoiceCounter,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
}
: {
blockedReason:
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
@@ -397,6 +452,41 @@ export class EimsInvoiceRegistrationService {
});
this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`);
await this.alertStaff(invoiceId, status, lastError, deterministic);
}
/**
* Tell the people who can act about a failed filing.
*
* An ambiguous result is the urgent one: it blocks *every* further invoice for this system
* number until a human resolves it, and nothing else in the system would surface that — the
* sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal
* priority. Never throws: an alert that fails must not mask the filing outcome.
*/
private async alertStaff(
invoiceId: string,
status: EimsInvoiceStatus,
error: EimsInvoiceError,
deterministic: boolean,
): Promise<void> {
try {
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
title: deterministic
? "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.`
: `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" },
});
} catch (err) {
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`);
}
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
@@ -488,6 +578,7 @@ export class EimsInvoiceRegistrationService {
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,

View File

@@ -80,6 +80,8 @@ export interface EimsInvoiceStatusView {
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
/** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */
eimsDocumentNumber: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;

View File

@@ -33,6 +33,8 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
cashierName: null,
salesPersonName: null,
...over,

View File

@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { EimsAuthService } from "./eims-auth.service";
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsClientService } from "./eims-client.service";
@@ -22,6 +23,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
imports: [
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
NotificationInboxModule,
],
controllers: [EimsInvoiceController],
providers: [

View File

@@ -18,6 +18,18 @@ export class EimsSystemState extends BaseEntity {
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
nextInvoiceCounter!: number;
/**
* `DocumentDetails.DocumentNumber` for the next registration.
*
* Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against
* `^(0|[1-9][0-9]{0,8})$`, a plain integer.
*/
@Column({ name: "next_document_number", type: "bigint", default: 1 })
nextDocumentNumber!: number;
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
inFlightDocumentNumber?: number | null;
/** IRN of the last successful registration; null until the first one succeeds. */
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
previousIrn?: string | null;

View File

@@ -1,13 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, Min } from 'class-validator';
import { IsNumber, IsOptional, Min } from 'class-validator';
export class ApproveLastMileRequestDto {
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
// but the chief can still override — the typed value is what's invoiced.
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
@Transform(({ value }) => Number(value))
// Omitted = the rule-based last-mile rate estimate is the advance. The chief
// can still override with an explicit amount (required when no rate covers
// the job).
@ApiPropertyOptional({
description:
'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).',
example: 3000,
})
@IsOptional()
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
@IsNumber()
@Min(0.01)
advanceAmount!: number;
advanceAmount?: number;
}

View File

@@ -114,7 +114,7 @@ export class LastMileRequestsController {
@Post(':id/approve')
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' })
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveLastMileRequestDto,

View File

@@ -299,7 +299,11 @@ export class LastMileRequestsService {
return this.findById(id);
}
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> {
async approve(
id: string,
staffId: string | null,
advanceOverride?: number | null,
): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
@@ -307,6 +311,18 @@ export class LastMileRequestsService {
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
// The live last-mile rates are the authority on the advance (km × rate);
// the chief's typed amount is only an override — and the only path when no
// rate covers the job. Snapshotted so the contract and invoice stay immune
// to later rate edits.
const estimate = await this.priceEstimate(id);
const advanceAmount = advanceOverride ?? estimate.total;
if (!advanceAmount || advanceAmount <= 0) {
throw new BadRequestException(
'No live last-mile rate covers this job — enter the advance amount manually.',
);
}
// Idempotent per booking — reuses the record if one already exists.
const lastMile = await this.lastMileService.create({
bookingId: request.bookingId,
@@ -316,10 +332,6 @@ export class LastMileRequestsService {
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
// once the customer has signed the LM contract — doc first, then payment.
// Snapshot the rate estimate now so the contract shows the numbers the
// chief actually approved against, immune to later rate edits.
const estimate = await this.priceEstimate(id);
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Approved,
reviewedByStaffId: staffId,
@@ -364,7 +376,10 @@ export class LastMileRequestsService {
type: 'LAST_MILE_ADVANCE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId || '',
currency: booking.paymentCurrency || 'ETB',
// The advance is priced by the last-mile rate, so it bills in that
// rate's currency (birr for domestic trucking) — the booking's payment
// currency is only the fallback when the amount was a manual override.
currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'LAST_MILE_ADVANCE',

View File

@@ -60,14 +60,38 @@ export class RefundDto {
}
export class ClientActionDto {
// INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so
// must be assignable here, but freight never requests platform=inapp and therefore never
// receives one. Passenger owns that flow — see docs/telebirr-miniapp/.
@ApiProperty({
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
enum: [
"REDIRECT",
"LAUNCH_APP",
"INVOKE_BRIDGE",
"COLLECT_OTP",
"SHOW_BILL_REFERENCE",
],
})
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
type!:
| "REDIRECT"
| "LAUNCH_APP"
| "INVOKE_BRIDGE"
| "COLLECT_OTP"
| "SHOW_BILL_REFERENCE";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
})
bridge?: "TELEBIRR";
@ApiPropertyOptional({
description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight",
})
rawRequest?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string;

View File

@@ -0,0 +1,311 @@
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsObject,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
ValidateNested,
} from "class-validator";
/**
* Markdown bodies are safe on read — the portal renders them with
* `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The
* fields worth validating are these: they land in `href`/`src` attributes and
* bypass markdown entirely, which is where a `javascript:` URL would actually
* execute.
*
* Placeholders survive the check because they sit after the scheme
* (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`).
*/
const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/;
const LINK_MESSAGE =
"$property must start with http(s)://, mailto:, tel: or /";
/**
* A media source is either an uploaded MinIO object key, a same-origin path, or
* an https URL. Anything else — notably `javascript:` — is refused, since this
* value lands in an `<img>`/`<video>` src.
*/
const MEDIA_SRC_PATTERN = new RegExp(
`^(https?:\\/\\/|\\/|${SUPPORT_MEDIA_PREFIX.replace("/", "\\/")})`,
);
const MEDIA_SRC_MESSAGE =
`$property must be an uploaded ${SUPPORT_MEDIA_PREFIX} key, a /path, or an http(s):// URL`;
/* ------------------------------- CONTACT ------------------------------- */
export class PortalSupportContactDto {
@ApiProperty()
@IsString()
@MinLength(3)
@MaxLength(200)
email!: string;
@ApiProperty()
@IsString()
@MinLength(3)
@MaxLength(60)
phone!: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(200)
office!: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(200)
hours!: string;
}
/* ---------------------------- PRIVACY / TERMS --------------------------- */
export class PortalDocSectionDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(20_000)
body!: string;
}
export class PortalLegalContentDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ description: 'Free text, e.g. "6 August 2026"' })
@IsString()
@MaxLength(60)
lastUpdated!: string;
@ApiProperty({ type: [PortalDocSectionDto] })
@IsArray()
@ArrayMaxSize(60)
@ValidateNested({ each: true })
@Type(() => PortalDocSectionDto)
sections!: PortalDocSectionDto[];
}
/* --------------------------------- FAQ --------------------------------- */
export class PortalCtaCardDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(2_000)
body!: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(100)
ctaLabel!: string;
@ApiProperty()
@IsString()
@Matches(LINK_PATTERN, { message: LINK_MESSAGE })
@MaxLength(500)
ctaTo!: string;
}
export class PortalFaqItemDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(300)
question!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(5_000)
answer!: string;
}
export class PortalFaqGroupDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty({ type: [PortalFaqItemDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => PortalFaqItemDto)
items!: PortalFaqItemDto[];
}
export class PortalFaqContentDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalFaqGroupDto] })
@IsArray()
@ArrayMaxSize(20)
@ValidateNested({ each: true })
@Type(() => PortalFaqGroupDto)
groups!: PortalFaqGroupDto[];
@ApiPropertyOptional({ type: PortalCtaCardDto, nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => PortalCtaCardDto)
footer?: PortalCtaCardDto | null;
}
/* --------------------------------- HELP -------------------------------- */
export class PortalMediaDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty({ enum: ["image", "video"] })
@IsIn(["image", "video"])
kind!: "image" | "video";
@ApiProperty({
description: `An uploaded ${SUPPORT_MEDIA_PREFIX} key, a same-origin /path, or an https:// URL`,
})
@IsString()
@Matches(MEDIA_SRC_PATTERN, { message: MEDIA_SRC_MESSAGE })
@MaxLength(500)
src!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(300)
caption?: string | null;
}
export class PortalHelpSectionDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(20_000)
body!: string;
@ApiProperty({ type: [PortalMediaDto] })
@IsArray()
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => PortalMediaDto)
media!: PortalMediaDto[];
}
export class PortalHelpContentDto {
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalHelpSectionDto] })
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => PortalHelpSectionDto)
sections!: PortalHelpSectionDto[];
}
/* ------------------------------- request ------------------------------- */
export class UpdateSupportDocumentDto {
@ApiProperty({
description:
"The document's whole payload. Validated against the shape for its slug.",
type: Object,
})
@IsObject()
payload!: Record<string, unknown>;
@ApiPropertyOptional({ description: "Why this change was made" })
@IsOptional()
@IsString()
@MaxLength(255)
note?: string;
}
/** Which DTO class a slug's payload is validated against on write. */
export const PAYLOAD_DTO_BY_SLUG: Record<
SupportDocSlug,
new () => object
> = {
CONTACT: PortalSupportContactDto,
HELP: PortalHelpContentDto,
FAQ: PortalFaqContentDto,
PRIVACY: PortalLegalContentDto,
TERMS: PortalLegalContentDto,
};

View File

@@ -0,0 +1,69 @@
import { BaseEntity } from "@edr/api-common";
import type { SupportDocPayload, SupportDocSlug } from "@edr/types";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
/**
* One editable customer-facing document for the freight portal's public pages
* (/help, /faq, /terms, /privacy) plus the support contact block they all
* quote. Five fixed rows, keyed by slug and seeded on first boot — there is no
* create/delete route.
*
* The payload is opaque jsonb because the five documents have genuinely
* different shapes and the help page's blocks keep changing; typed columns
* would mean a migration per copy tweak. Shape safety lives in the per-slug
* DTOs the service validates against on write, the same arrangement
* `contract_templates.articles` already uses.
*
* `version` is the live row's version and always equals `max(version)` in
* {@link SupportDocumentVersion} — the seeder writes v1 and its history row
* together, so the log is never missing the current state.
*/
@Entity({ schema: "freight", name: "support_documents" })
@Index(["slug"], { unique: true })
export class SupportDocument extends BaseEntity {
@Column({ name: "slug", type: "varchar", length: 32, unique: true })
slug!: SupportDocSlug;
@Column({ name: "payload", type: "jsonb", default: () => "'{}'::jsonb" })
payload!: SupportDocPayload;
@Column({ name: "version", type: "int", default: 1 })
version!: number;
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}
/**
* Append-only history of every saved state of a document, written in the same
* transaction as the live row. Restoring is not a pointer reset: it re-saves an
* old payload through the normal write path, producing a *new* version, so a
* restore is itself undoable and the log only ever grows.
*
* Modelled on {@link CompanyRevision}, but snapshots the whole payload rather
* than a field diff — rollback needs the full state, not the delta.
*/
@Entity({ schema: "freight", name: "support_document_versions" })
@Index(["documentId"])
export class SupportDocumentVersion extends BaseEntity {
@Column({ name: "document_id", type: "uuid" })
documentId!: string;
@ManyToOne(() => SupportDocument, { onDelete: "CASCADE" })
@JoinColumn({ name: "document_id" })
document?: SupportDocument;
@Column({ name: "version", type: "int" })
version!: number;
@Column({ name: "payload", type: "jsonb" })
payload!: SupportDocPayload;
/** Who saved it. Null for the seeded initial version. */
@Column({ name: "actor_id", type: "uuid", nullable: true })
actorId?: string | null;
/** Optional "why" the editor typed, shown in the history list. */
@Column({ name: "note", type: "varchar", length: 255, nullable: true })
note?: string | null;
}

View File

@@ -0,0 +1,29 @@
import { Public } from "@edr/api-common";
import { Controller, Get, Header } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { SupportContentService } from "./support-content.service";
/**
* The portal's /help, /faq, /terms and /privacy routes are deliberately outside
* its auth guard — the sign-up screen links to them before a session exists —
* so this read must work with no token. `@Public()` is class-level because that
* is the idiom the other genuinely-anonymous controllers here use; without it
* the globally registered `JwtGuard` would 401 every anonymous visitor.
*/
@ApiTags("support-content")
@Public()
@Controller("support-content")
export class PublicSupportContentController {
constructor(private readonly service: SupportContentService) {}
@Get()
// Hit on every anonymous page view, and the copy changes a few times a year.
@Header("Cache-Control", "public, max-age=300")
@ApiOperation({
summary: "Public help, FAQ, legal and support-contact copy for the portal",
})
getBundle() {
return this.service.getBundle();
}
}

View File

@@ -0,0 +1,135 @@
import { CurrentUser } from "@edr/api-common";
import { SUPPORT_MEDIA_MAX_BYTES } from "@edr/types";
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiConsumes,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateSupportDocumentDto } from "./dto/support-content.dto";
import { SupportContentService } from "./support-content.service";
const READ = [
FREIGHT_PERMS.settings.supportContent.view,
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
];
const WRITE = [
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
];
@ApiTags("support-content")
@ApiBearerAuth()
@Controller("support-content")
export class SupportContentController {
constructor(private readonly service: SupportContentService) {}
/**
* Stores a help-page image or video and returns its object key. The editor
* saves the key, not the returned URL — see `SupportContentService.uploadMedia`.
*
* The Multer limit duplicates the service-side type check on purpose: it
* stops reading the socket once the part is oversized instead of buffering
* the whole thing into memory first.
*/
@Post("media")
@BookingStaff(WRITE)
@UseInterceptors(
FileInterceptor("file", { limits: { fileSize: SUPPORT_MEDIA_MAX_BYTES } }),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload an image or video for a help section" })
uploadMedia(@UploadedFile() file: Express.Multer.File) {
return this.service.uploadMedia(file);
}
/**
* Signs one stored key so the editor can preview an already-saved image.
* The editor stores `minio:<key>`, never a signed URL, so it needs somewhere
* to resolve those refs for display — this is it.
*/
@Get("media-url")
@BookingStaff(READ)
@ApiQuery({ name: "key", description: "MinIO object key" })
@ApiOperation({ summary: "Presigned URL for one stored media key" })
mediaUrl(@Query("key") key: string) {
return this.service.mediaUrl(key);
}
@Get("documents")
@BookingStaff(READ)
@ApiOperation({ summary: "List the five portal content documents (no payloads)" })
list() {
return this.service.list();
}
@Get("documents/:slug")
@BookingStaff(READ)
@ApiOperation({ summary: "Get one document with its payload" })
getBySlug(@Param("slug") slug: string) {
return this.service.getBySlug(slug);
}
@Patch("documents/:slug")
@BookingStaff(WRITE)
@ApiOperation({
summary: "Replace a document's payload, recording a new version",
})
update(
@Param("slug") slug: string,
@Body() dto: UpdateSupportDocumentDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.update(slug, dto, user?.id ?? null);
}
@Get("documents/:slug/versions")
@BookingStaff(READ)
@ApiOperation({ summary: "Version history, newest first (no payloads)" })
listVersions(@Param("slug") slug: string) {
return this.service.listVersions(slug);
}
@Get("documents/:slug/versions/:version")
@BookingStaff(READ)
@ApiOperation({ summary: "One historical version, with its payload" })
getVersion(
@Param("slug") slug: string,
@Param("version", ParseIntPipe) version: number,
) {
return this.service.getVersion(slug, version);
}
@Post("documents/:slug/versions/:version/restore")
@BookingStaff(WRITE)
@ApiOperation({
summary: "Restore a version — re-saves it as a new version, never destructive",
})
restore(
@Param("slug") slug: string,
@Param("version", ParseIntPipe) version: number,
@CurrentUser() user: TCurrentUser,
) {
return this.service.restore(slug, version, user?.id ?? null);
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { MinioModule } from "../minio/minio.module";
import {
SupportDocument,
SupportDocumentVersion,
} from "./entities/support-document.entity";
import { PublicSupportContentController } from "./public-support-content.controller";
import { SupportContentController } from "./support-content.controller";
import { SupportContentRepository } from "./support-content.repository";
import { SupportContentService } from "./support-content.service";
@Module({
imports: [
TypeOrmModule.forFeature([SupportDocument, SupportDocumentVersion]),
MinioModule,
],
controllers: [PublicSupportContentController, SupportContentController],
providers: [SupportContentRepository, SupportContentService],
exports: [SupportContentService],
})
export class SupportContentModule {}

View File

@@ -0,0 +1,71 @@
import { BaseRepository } from "@edr/api-common";
import type { SupportDocSlug } from "@edr/types";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import {
SupportDocument,
SupportDocumentVersion,
} from "./entities/support-document.entity";
@Injectable()
export class SupportContentRepository extends BaseRepository<SupportDocument> {
constructor(
@InjectRepository(SupportDocument)
repository: Repository<SupportDocument>,
@InjectRepository(SupportDocumentVersion)
private readonly versions: Repository<SupportDocumentVersion>,
) {
super(repository);
}
findBySlug(slug: SupportDocSlug): Promise<SupportDocument | null> {
return this.repository.findOne({ where: { slug } });
}
override findAll(): Promise<SupportDocument[]> {
return this.repository.find({ order: { slug: "ASC" } });
}
findVersions(documentId: string): Promise<SupportDocumentVersion[]> {
return this.versions.find({
where: { documentId },
order: { version: "DESC" },
});
}
findVersion(
documentId: string,
version: number,
): Promise<SupportDocumentVersion | null> {
return this.versions.findOne({ where: { documentId, version } });
}
/**
* Bump the live row and append its history entry atomically. Doing both in
* one transaction is what guarantees `document.version` always has a matching
* version row — the invariant the history list and rollback both rely on.
*/
async saveWithVersion(
document: SupportDocument,
actorId: string | null,
note: string | null,
): Promise<SupportDocument> {
return this.repository.manager.transaction(async (manager) => {
const saved = await manager.save(SupportDocument, document);
await manager.save(
manager.create(SupportDocumentVersion, {
documentId: saved.id,
version: saved.version,
payload: saved.payload,
actorId,
note,
}),
);
return saved;
});
}
}

View File

@@ -0,0 +1,237 @@
import {
SUPPORT_CONTENT_DEFAULTS,
SUPPORT_DOC_SLUGS,
SupportDocPayload,
SupportDocSlug,
} from "@edr/types";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { MinioService } from "../minio/minio.service";
import {
SupportDocument,
SupportDocumentVersion,
} from "./entities/support-document.entity";
import { SupportContentRepository } from "./support-content.repository";
import { SupportContentService, validatePayload } from "./support-content.service";
const ACTOR = "11111111-1111-4111-8111-111111111111";
/**
* In-memory stand-in for the repository: one seeded document plus its version
* log, with `saveWithVersion` doing what the real transaction does. Enough to
* assert the rollback contract without a database.
*/
function makeRepository(slug: SupportDocSlug) {
const document = {
id: "00000000-0000-0000-0000-000000000001",
slug,
payload: SUPPORT_CONTENT_DEFAULTS[slug],
version: 1,
updatedById: null,
} as SupportDocument;
const versions: SupportDocumentVersion[] = [
{
id: "v1",
documentId: document.id,
version: 1,
payload: document.payload,
actorId: null,
note: "Initial content",
} as SupportDocumentVersion,
];
const repository = {
findAll: jest.fn(async () => [document]),
findBySlug: jest.fn(async (s: SupportDocSlug) =>
s === slug ? document : null,
),
findVersions: jest.fn(async () => [...versions].reverse()),
findVersion: jest.fn(
async (_id: string, version: number) =>
versions.find((v) => v.version === version) ?? null,
),
saveWithVersion: jest.fn(
async (doc: SupportDocument, actorId: string | null, note: string | null) => {
versions.push({
id: `v${doc.version}`,
documentId: doc.id,
version: doc.version,
payload: doc.payload,
actorId,
note,
} as SupportDocumentVersion);
return doc;
},
),
};
// Signing is exercised only through getBundle; the write path never touches it.
const minio = {
getSignedUrl: jest.fn(async (key: string) => `https://minio.test/${key}?sig=x`),
uploadFile: jest.fn(),
};
return {
document,
versions,
minio,
service: new SupportContentService(
repository as unknown as SupportContentRepository,
minio as unknown as MinioService,
),
};
}
describe("SupportContentService.restore", () => {
it("rolls back to an older payload as a NEW version, leaving history intact", async () => {
const { document, versions, service } = makeRepository("CONTACT");
const original = SUPPORT_CONTENT_DEFAULTS.CONTACT;
await service.update(
"CONTACT",
{ payload: { ...original, phone: "+251 99 999 9999" } },
ACTOR,
);
expect(document.version).toBe(2);
expect(versions.map((v) => v.version)).toEqual([1, 2]);
const restored = await service.restore("CONTACT", 1, ACTOR);
expect(restored.payload).toEqual(original);
// The assertion that matters: restore is additive. If someone "optimises"
// it into a destructive pointer reset this drops back to 1 and rollback
// silently stops being undoable.
expect(restored.version).toBe(3);
expect(versions.map((v) => v.version)).toEqual([1, 2, 3]);
expect(versions[2].note).toBe("Restored version 1");
});
it("404s on a version that was never written", async () => {
const { service } = makeRepository("CONTACT");
await expect(service.restore("CONTACT", 99, ACTOR)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it("rejects an unknown slug", async () => {
const { service } = makeRepository("CONTACT");
await expect(service.getBySlug("NOPE")).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe("SupportContentService.getBundle", () => {
it("signs stored keys on read and leaves paths and URLs alone", async () => {
const { service, minio } = makeRepository("CONTACT");
const help = SUPPORT_CONTENT_DEFAULTS.HELP;
// findAll returns only CONTACT, so HELP falls back to the defaults — which
// is itself worth asserting: an unseeded row must not break the page.
const bundle = await service.getBundle();
expect(bundle.help.sections[0].media[0].src).toBe(
"/assets/edr-portal-guide.webm",
);
expect(minio.getSignedUrl).not.toHaveBeenCalled();
const withUpload = {
...help,
sections: [
{
...help.sections[0],
body: "See ![diagram](minio:support-content/d.png) below.",
media: [
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
],
},
],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const signed = await (service as any).signMedia({
...bundle,
help: withUpload,
});
expect(signed.help.sections[0].media[0].src).toBe(
"https://minio.test/support-content/a.png?sig=x",
);
expect(signed.help.sections[0].body).toContain(
"https://minio.test/support-content/d.png?sig=x",
);
// The stored copy must never be mutated into a URL — that is what would rot.
expect(withUpload.sections[0].media[0].src).toBe("support-content/a.png");
});
});
describe("validatePayload", () => {
const help = SUPPORT_CONTENT_DEFAULTS.HELP;
it("accepts every shipped default", () => {
for (const slug of SUPPORT_DOC_SLUGS) {
expect(() =>
validatePayload(slug, SUPPORT_CONTENT_DEFAULTS[slug]),
).not.toThrow();
}
});
const sectionWithMedia = (src: string) => ({
...help,
sections: [{ ...help.sections[0], media: [{ kind: "video", src }] }],
});
it("rejects a javascript: media source", () => {
// The markdown renderer drops raw HTML, so src attributes like this one are
// the only place a script URL could still execute.
expect(() =>
validatePayload("HELP", sectionWithMedia("javascript:alert(1)")),
).toThrow(BadRequestException);
});
it("accepts an uploaded key, a rooted path and an https URL", () => {
for (const src of [
"support-content/9f1c.png",
"/assets/edr-portal-guide.webm",
"https://cdn.example.com/clip.mp4",
]) {
expect(() => validatePayload("HELP", sectionWithMedia(src))).not.toThrow();
}
});
it("rejects a media kind that is neither image nor video", () => {
expect(() =>
validatePayload("HELP", {
...help,
sections: [
{
...help.sections[0],
media: [{ kind: "pdf", src: "support-content/a.pdf" }],
},
],
}),
).toThrow(BadRequestException);
});
it("keeps placeholder links, which sit after the scheme", () => {
expect(() =>
validatePayload("FAQ", {
...SUPPORT_CONTENT_DEFAULTS.FAQ,
footer: {
...SUPPORT_CONTENT_DEFAULTS.FAQ.footer!,
ctaTo: "mailto:{{supportEmail}}",
},
}),
).not.toThrow();
});
it("generates ids for items saved without one", () => {
const legal = validatePayload("TERMS", {
...SUPPORT_CONTENT_DEFAULTS.TERMS,
sections: [{ heading: "1. New", body: "Body." }],
}) as Extract<SupportDocPayload, { sections: unknown }>;
expect(legal.sections[0].id).toEqual(expect.any(String));
expect(legal.sections[0].id.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,368 @@
import {
PORTAL_MEDIA_URI_SCHEME,
PortalContentBundle,
PortalFaqContent,
PortalHelpContent,
PortalLegalContent,
PortalMediaKind,
SUPPORT_CONTENT_DEFAULTS,
SUPPORT_DOC_SLUGS,
SUPPORT_MEDIA_PREFIX,
SupportDocPayload,
SupportDocSlug,
} from "@edr/types";
import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { extname } from "path";
import { MinioService } from "../minio/minio.service";
import { plainToInstance } from "class-transformer";
import { validateSync, ValidationError } from "class-validator";
import { randomUUID } from "crypto";
import {
PAYLOAD_DTO_BY_SLUG,
UpdateSupportDocumentDto,
} from "./dto/support-content.dto";
import {
SupportDocument,
SupportDocumentVersion,
} from "./entities/support-document.entity";
import { SupportContentRepository } from "./support-content.repository";
/**
* A paste-bomb in one section would bloat the public bundle every anonymous
* visitor downloads, so the whole payload is capped as well as its fields.
*/
const MAX_PAYLOAD_BYTES = 200_000;
/**
* Comfortably longer than the 5-minute `Cache-Control` on the public bundle, so
* a cached response never outlives the URLs inside it.
*/
const MEDIA_URL_TTL_SECONDS = 6 * 60 * 60;
/** `minio:support-content/<file>` inside markdown. */
const MEDIA_REF = new RegExp(
`${PORTAL_MEDIA_URI_SCHEME}([A-Za-z0-9._\\-/]+)`,
"g",
);
/** Anything not already a URL or a rooted path is a MinIO object key. */
const isObjectKey = (src: string) => !/^(https?:\/\/|\/)/.test(src);
@Injectable()
export class SupportContentService {
constructor(
private readonly repository: SupportContentRepository,
private readonly minio: MinioService,
) {}
/**
* Stores an image or video for the help page and returns its object key.
*
* The key is what gets saved in the document — never the signed URL. A
* presigned URL expires, so persisting one would leave every embedded image
* broken a few hours later; signing happens per read instead.
*/
async uploadMedia(
file?: Express.Multer.File,
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
if (!file) throw new BadRequestException("No file uploaded");
const kind: PortalMediaKind | null = file.mimetype.startsWith("image/")
? "image"
: file.mimetype.startsWith("video/")
? "video"
: null;
if (!kind) {
throw new BadRequestException(
`Unsupported file type ${file.mimetype} — images and videos only`,
);
}
const key = `${SUPPORT_MEDIA_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
await this.minio.uploadFile(key, file.buffer, file.mimetype);
return {
key,
kind,
url: await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS),
};
}
/** Presigned URL for one stored key, for the backoffice editor's previews. */
async mediaUrl(key: string): Promise<{ url: string }> {
if (!key?.startsWith(SUPPORT_MEDIA_PREFIX)) {
throw new BadRequestException(
`key must be an uploaded ${SUPPORT_MEDIA_PREFIX} object`,
);
}
return { url: await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS) };
}
/**
* The public bundle. Missing rows fall back to the shipped defaults so an
* unseeded or half-migrated environment still serves the legal pages rather
* than 404-ing the first thing an anonymous visitor sees.
*/
async getBundle(): Promise<PortalContentBundle> {
const documents = await this.repository.findAll();
const bySlug = new Map(documents.map((d) => [d.slug, d.payload]));
const payload = <S extends SupportDocSlug>(slug: S) =>
(bySlug.get(slug) ?? SUPPORT_CONTENT_DEFAULTS[slug]) as never;
return this.signMedia({
contact: payload("CONTACT"),
help: payload("HELP"),
faq: payload("FAQ"),
privacy: payload("PRIVACY"),
terms: payload("TERMS"),
});
}
/**
* Swaps every stored MinIO reference for a freshly signed URL: attachment
* `src` keys, and `minio:<key>` references embedded in markdown by the
* editor's image button.
*
* Each distinct key is signed once per request, and a signing failure
* degrades to MinIO's public URL rather than failing the whole page (see
* `MinioService.getSignedUrl`).
*/
private async signMedia(
bundle: PortalContentBundle,
): Promise<PortalContentBundle> {
const keys = new Set<string>();
for (const section of bundle.help.sections ?? []) {
for (const item of section.media ?? []) {
if (isObjectKey(item.src)) keys.add(item.src);
}
}
const collect = (value: unknown): void => {
if (typeof value === "string") {
for (const match of value.matchAll(MEDIA_REF)) keys.add(match[1]);
} else if (Array.isArray(value)) {
value.forEach(collect);
} else if (value && typeof value === "object") {
Object.values(value).forEach(collect);
}
};
collect(bundle);
if (keys.size === 0) return bundle;
const signed = new Map(
await Promise.all(
[...keys].map(
async (key) =>
[key, await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS)] as const,
),
),
);
const rewrite = (value: unknown): unknown => {
if (typeof value === "string") {
return value.replace(MEDIA_REF, (whole, key: string) =>
signed.get(key) ?? whole,
);
}
if (Array.isArray(value)) return value.map(rewrite);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, rewrite(v)]),
);
}
return value;
};
const resolved = rewrite(bundle) as PortalContentBundle;
for (const section of resolved.help.sections ?? []) {
for (const item of section.media ?? []) {
if (isObjectKey(item.src)) item.src = signed.get(item.src) ?? item.src;
}
}
return resolved;
}
/** Admin list — metadata only, no payloads. */
async list(): Promise<Omit<SupportDocument, "payload">[]> {
const documents = await this.repository.findAll();
return documents.map(({ payload: _payload, ...rest }) => rest);
}
async getBySlug(rawSlug: string): Promise<SupportDocument> {
const slug = assertSlug(rawSlug);
const document = await this.repository.findBySlug(slug);
if (!document) {
throw new NotFoundException(`Support document ${slug} not found`);
}
return document;
}
/**
* Replace a document's whole payload. Writing the whole slice rather than
* patching fields is deliberate: one editorial change becomes exactly one
* version, which is what keeps the history readable.
*/
async update(
rawSlug: string,
dto: UpdateSupportDocumentDto,
actorId: string | null,
): Promise<SupportDocument> {
const document = await this.getBySlug(rawSlug);
const payload = validatePayload(document.slug, dto.payload);
document.payload = payload;
document.version += 1;
document.updatedById = actorId;
return this.repository.saveWithVersion(document, actorId, dto.note ?? null);
}
async listVersions(rawSlug: string): Promise<SupportDocumentVersion[]> {
const document = await this.getBySlug(rawSlug);
return this.repository.findVersions(document.id);
}
async getVersion(
rawSlug: string,
version: number,
): Promise<SupportDocumentVersion> {
const document = await this.getBySlug(rawSlug);
const found = await this.repository.findVersion(document.id, version);
if (!found) {
throw new NotFoundException(
`Version ${version} of ${document.slug} not found`,
);
}
return found;
}
/**
* Roll back to an earlier version by re-saving its payload through the normal
* write path. The result is a NEW version whose content equals the old one —
* never a destructive pointer reset — so the history only grows and a restore
* is itself undoable.
*/
async restore(
rawSlug: string,
version: number,
actorId: string | null,
): Promise<SupportDocument> {
const target = await this.getVersion(rawSlug, version);
return this.update(
rawSlug,
{
payload: target.payload as unknown as Record<string, unknown>,
note: `Restored version ${version}`,
},
actorId,
);
}
}
/* ------------------------------ helpers ------------------------------- */
export function assertSlug(raw: string): SupportDocSlug {
const slug = raw?.toUpperCase() as SupportDocSlug;
if (!SUPPORT_DOC_SLUGS.includes(slug)) {
throw new BadRequestException(
`Unknown support document "${raw}". Valid slugs: ${SUPPORT_DOC_SLUGS.join(", ")}`,
);
}
return slug;
}
/**
* Validate an incoming payload against the DTO registered for its slug, then
* fill in any missing item ids. Exported so the seeder's spec can assert the
* shipped defaults are themselves valid.
*/
export function validatePayload(
slug: SupportDocSlug,
raw: unknown,
): SupportDocPayload {
if (JSON.stringify(raw ?? null).length > MAX_PAYLOAD_BYTES) {
throw new BadRequestException(
`Payload for ${slug} exceeds ${MAX_PAYLOAD_BYTES} bytes`,
);
}
const instance = plainToInstance(PAYLOAD_DTO_BY_SLUG[slug], raw ?? {});
const errors = validateSync(instance as object, {
whitelist: true,
forbidNonWhitelisted: true,
});
if (errors.length) {
throw new BadRequestException(flattenErrors(errors));
}
return withGeneratedIds(slug, instance as SupportDocPayload);
}
function flattenErrors(errors: ValidationError[], path = ""): string[] {
return errors.flatMap((error) => {
const here = path ? `${path}.${error.property}` : error.property;
const own = Object.values(error.constraints ?? {}).map(
(message) => `${here}: ${message}`,
);
return [...own, ...flattenErrors(error.children ?? [], here)];
});
}
const withId = <T extends { id?: string }>(item: T): T => ({
...item,
id: item.id || randomUUID(),
});
/**
* Item ids are the React keys in the portal, and admin-authored headings and
* questions collide too easily to use as keys. Editors may omit them; the
* server mints one rather than making every client remember to.
*/
function withGeneratedIds(
slug: SupportDocSlug,
payload: SupportDocPayload,
): SupportDocPayload {
switch (slug) {
case "HELP": {
const help = payload as PortalHelpContent;
return {
...help,
sections: help.sections.map((section) => ({
...withId(section),
media: (section.media ?? []).map(withId),
})),
};
}
case "FAQ": {
const faq = payload as PortalFaqContent;
return {
...faq,
groups: faq.groups.map((group) => ({
...withId(group),
items: group.items.map(withId),
})),
};
}
case "PRIVACY":
case "TERMS": {
const legal = payload as PortalLegalContent;
return { ...legal, sections: legal.sections.map(withId) };
}
default:
return payload;
}
}

View File

@@ -21,7 +21,6 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import {
DataSource,
EntityManager,
@@ -2522,63 +2521,6 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/**
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
* loaded onto the wagons allocated to the booking, so anything still in the
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
* booking has warehouse inventory that never made it onto a wagon (received /
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
* or drop the booking's wagon allocation so it rides a later train.
*
* Import/domestic are untouched: their cargo isn't loaded out of an origin
* warehouse, so warehouse inventory says nothing about what's aboard.
*
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
* wagon before the goods arrive is normal planning; they simply aren't aboard.
*/
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
await this.dataSource.query(
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
[scheduleId],
);
if (!route) return;
const direction = deriveTradeDirection(
{ country: route.originCountry },
{ country: route.destinationCountry },
);
if (direction !== 'EXPORT') return;
// Only bookings boarding at the schedule's ORIGIN station gate dispatch —
// a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the
// train reaches its yard, so its warehouse state says nothing at departure.
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
`WITH ${SCHEDULE_BOOKINGS_CTE}
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
FROM sched_bookings sb
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
JOIN freight.warehouse_inventory inv
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = $1
AND b.origin_yard_id = ts.origin_station_id
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
[scheduleId],
);
if (rows.length) {
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
throw new BadRequestException(
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
);
}
}
async dispatchSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -2588,8 +2530,8 @@ export class TrainSchedulingService {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
await this.assertImportDjiboutiMayDepart(schedule);
// Export only: don't leave received cargo behind in the warehouse.
await this.assertAllocatedCargoLoaded(scheduleId);
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
// never blocks departure — the dispatch confirm dialog warns and staff decide.
// A locomotive may sit on many future schedules, but it can only pull one train
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);

View File

@@ -0,0 +1,56 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* Export self-haul has no separate "truck arrived" gate action the way import
* does (release()'s arrival branch, fired later when a truck shows up to
* COLLECT already-warehoused goods) — the truck delivering cargo TO the
* warehouse arrives and is received in the same act, so receive()/
* bulkReceive() must stamp customer_truck_assignments.arrived_at themselves.
*/
type Marker = (
manager: { query: jest.Mock },
bookingId: string,
plateNumber: string | null | undefined,
) => Promise<void>;
function makeMarker() {
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
const marker = (
service as unknown as { markCustomerTruckArrived: Marker }
).markCustomerTruckArrived.bind(service);
return marker;
}
describe('markCustomerTruckArrived', () => {
it('stamps arrival matched by booking + plate', async () => {
const marker = makeMarker();
const manager = { query: jest.fn().mockResolvedValue(undefined) };
await marker(manager, 'b-1', 'AAA-2323');
expect(manager.query).toHaveBeenCalledTimes(1);
const [sql, params] = manager.query.mock.calls[0];
expect(sql).toMatch(/UPDATE freight\.customer_truck_assignments/);
expect(sql).toMatch(/UPPER\(a\.plate_number\) = UPPER\(\$2\)/);
expect(params).toEqual(['b-1', 'AAA-2323']);
});
it('trims the plate before matching', async () => {
const marker = makeMarker();
const manager = { query: jest.fn().mockResolvedValue(undefined) };
await marker(manager, 'b-1', ' AAA-2323 ');
expect(manager.query.mock.calls[0][1]).toEqual(['b-1', 'AAA-2323']);
});
it('no-ops on a missing/blank plate — no query, nothing to match on', async () => {
const marker = makeMarker();
const manager = { query: jest.fn() };
await marker(manager, 'b-1', undefined);
await marker(manager, 'b-1', ' ');
expect(manager.query).not.toHaveBeenCalled();
});
});

View File

@@ -1643,6 +1643,12 @@ export class WarehouseInventoryService {
[bookingId],
);
// Export self-haul: this receive IS the truck's arrival — see
// markCustomerTruckArrived / receive()'s single-booking mirror.
if (dto.direction === 'EXPORT') {
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
@@ -1691,7 +1697,6 @@ export class WarehouseInventoryService {
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
private async exportInventoryByStatus(
status: WarehouseInventoryStatus,
requireInspectionPassed = false,
): Promise<ReadyToLoadRow[]> {
const rows: Array<
ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
@@ -1720,7 +1725,6 @@ export class WarehouseInventoryService {
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
WHERE inv.deleted_at IS NULL
AND inv.status = $1
${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
ORDER BY inv.created_at DESC`,
[status],
);
@@ -1733,9 +1737,13 @@ export class WarehouseInventoryService {
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
}
/** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */
/**
* EXPORT inventory waiting to be loaded (READY_FOR_LOADING). Inspection state
* rides along on each row for the UI to show, but does not filter the queue —
* uninspected cargo must still be loadable.
*/
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
return this.exportInventoryByStatus('READY_FOR_LOADING');
}
/** EXPORT inventory received at the facility and awaiting inspection. */
@@ -2886,6 +2894,13 @@ export class WarehouseInventoryService {
);
}
// Export self-haul: this receive IS the truck's arrival — stamp it on
// its own customer_truck_assignments row (mirror of import's arrival,
// see markCustomerTruckArrived).
if (dto.bookingId && bookingDirection === 'EXPORT') {
await this.markCustomerTruckArrived(manager, dto.bookingId, truckEntrance.truckPlateNumber);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
@@ -3117,9 +3132,8 @@ export class WarehouseInventoryService {
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
}
if (item.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
}
// Inspection is tracked, not enforced — uninspected cargo may still be
// marked ready and loaded so a train is never held for paperwork.
return this.transition(id, 'READY_FOR_LOADING', {
timestampField: 'readyForLoadingAt',
activityType: 'READY_FOR_LOADING',
@@ -6008,6 +6022,33 @@ export class WarehouseInventoryService {
};
}
/**
* EXPORT self-haul mirror of the customer truck lifecycle IMPORT already has:
* import stamps a truck's arrival on the SEPARATE gate action that comes
* later (release()'s arrival branch, when the customer's truck shows up to
* COLLECT already-warehoused goods). Export has no such separate step — the
* truck delivering cargo TO the warehouse arrives and is received in the
* same act, so receive()/bulkReceive() themselves are the arrival event.
* Matched by plate (not assignmentId — neither receive endpoint carries
* one), same as release()'s departure-branch self-haul UPDATE.
*/
private async markCustomerTruckArrived(
manager: EntityManager,
bookingId: string,
plateNumber: string | null | undefined,
): Promise<void> {
const plate = plateNumber?.trim();
if (!plate) return;
await manager.query(
`UPDATE freight.customer_truck_assignments a
SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
WHERE a.booking_id = $1
AND UPPER(a.plate_number) = UPPER($2)
AND a.deleted_at IS NULL`,
[bookingId, plate],
);
}
private async getBookingTruckEntranceSource(
manager: EntityManager,
bookingId: string,
@@ -6027,6 +6068,10 @@ export class WarehouseInventoryService {
firstMileDriverPhone?: string | null;
firstMileDriverLicenseNumber?: string | null;
firstMileTruckType?: string | null;
customerTruckPlateNumber?: string | null;
customerTruckDriverName?: string | null;
customerTruckType?: string | null;
customerTruckContainerNumber?: string | null;
}> {
const [booking] = await manager.query(
`SELECT b.reference AS "reference",
@@ -6046,7 +6091,24 @@ export class WarehouseInventoryService {
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType"
v.vehicle_type AS "firstMileTruckType",
-- Self-haul truck assigned via the portal — export delivering to
-- the warehouse or import collecting from it. Same pattern as
-- eligibleBookings/importQueueByStatuses: multi-truck self-haul
-- writes plates/drivers to customer_truck_assignments and leaves
-- the booking columns null, so read the assignments first and
-- keep the legacy column as the fallback for single-truck
-- bookings written before that table existed.
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}