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

This commit is contained in:
Marshal
2026-08-19 06:42:36 +00:00
202 changed files with 15563 additions and 2170 deletions

View File

@@ -662,6 +662,11 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
// Yard
// Yard Position (desk↔yard mapping — an input to yard access scoping, so
// every change to it is evidence of who widened or narrowed someone's reach)
"PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"],
"PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"],
"POST /api/yards": ["Create a yard", "POST", "Yard"],
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],

View File

@@ -84,7 +84,13 @@ export class PdfRenderService {
const page = await browser.newPage();
const thermal = opts.thermal ?? false;
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 });
// 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));
@@ -154,7 +160,7 @@ export class PdfRenderService {
// browser context regardless, same as the closure form would be.
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm);
return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100);
}
private injectPdfPrintStyles(html: string): string {

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",
@@ -146,7 +151,9 @@ describe("toEimsInvoice", () => {
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
Discount: 25,
TotalLineAmount: 1050,
Unit: "CTR",
// Not "CTR" from the line's metadata.unit — that's our internal fee-basis tag, not a MoR
// unit of measure, and is never read for this field (see the mapper's own comment).
Unit: "PCS",
});
expect(doc.ValueDetails).toEqual({
Discount: null,
@@ -335,6 +342,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

@@ -234,8 +234,13 @@ export interface EimsMapperContext {
* 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.
*
@@ -252,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;
@@ -299,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;
@@ -319,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,
@@ -398,7 +463,13 @@ export function toEimsInvoice(
const PreTaxValue = round2(num(line.amount));
const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100);
const ExciseTaxValue = round2(tax.exciseTaxValue);
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
// `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge
// is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here
// (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex
// all at once) is what a prior version of this mapper did by mistake. MoR's own enum
// (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line
// uses the single configured default rather than guessing a per-line value that doesn't exist.
const unit = context.unitDefault;
return {
Discount: round2(tax.discount),
@@ -440,7 +511,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,
@@ -455,7 +535,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,

View File

@@ -36,9 +36,13 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
);
});
it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
it('sends a GL-created customs booking back to the GL who created it, not the customer', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
booking({
customsClearingEnabled: true,
createdByRole: 'GL_ET',
createdByUserId: 'gl-user-1',
}),
'Cargo weight does not match the declaration',
);
await flush();
@@ -54,7 +58,7 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
expect(notifications.directSend).not.toHaveBeenCalled();
});
it('still tells the customer when the booking is their own', async () => {
it('still tells the customer when the booking is a non-customs self-service booking', async () => {
service.operationChangesRequested(booking(), 'Please attach the packing list');
await flush();
@@ -65,14 +69,38 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
expect(notifications.directSend).toHaveBeenCalled();
});
it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
it('routes a customer-opened customs booking to the clearance desk, not the customer', async () => {
// Path B lets the customer open the ONE_TIME shipment instance themselves
// (contract-booking.service assertGate's customerMayInitiate) — createdByRole
// stays 'CUSTOMER', but GL still owns completing/resubmitting it.
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: null }),
booking({ customsClearingEnabled: true, createdByRole: 'CUSTOMER' }),
'Fix the declaration',
);
await flush();
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
});
expect(sent.audience).toBe('BACKOFFICE');
expect(notifications.directSend).not.toHaveBeenCalled();
});
it('falls back to the clearance desk when the GL creator is unknown (legacy rows)', async () => {
service.operationChangesRequested(
booking({
customsClearingEnabled: true,
createdByRole: 'GL_ET',
createdByUserId: null,
}),
'Fix the declaration',
);
await flush();
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
});
});
});

View File

@@ -247,20 +247,27 @@ export class BookingLifecycleNotifierService {
/**
* Operations returned the operation request for changes.
*
* A customs (Path B) booking was created BY GL Ethiopia on the customer's
* behalf — the customer cannot edit or resubmit it, so telling them to "update
* from the portal" is a dead end. Those go to the GL who created it, linking
* the contract clearance page they work from. Everything else (customer-made
* bookings) keeps the portal message.
* A customs (Path B) booking is completed by GL Ethiopia on the customer's
* behalf regardless of who opened the shipment instance — the customer-opened
* ONE_TIME case (see contract-booking.service assertGate) still stamps
* createdByRole 'CUSTOMER', so gate on customsClearingEnabled, not on who
* created it. The customer cannot edit or resubmit a customs booking, so
* telling them to "update from the portal" is a dead end. Those go to the GL
* who created it when known, else the clearance desk, linking the contract
* clearance page they work from. Everything else (customer-made bookings)
* keeps the portal message.
*/
operationChangesRequested(b: Booking, note: string): void {
if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
if (b.customsClearingEnabled) {
const msg =
`Operations returned booking ${b.reference} for changes: ${note}. ` +
`Address it on the contract clearance page and resubmit to Operations.`;
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
recipients: { userIds: [b.createdByUserId] },
recipients:
b.createdByRole === 'GL_ET' && b.createdByUserId
? { userIds: [b.createdByUserId] }
: CLEARANCE_DESK,
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} needs changes`,

View File

@@ -0,0 +1,73 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { NotificationType, type NotifyInput } from '@edr/types';
import chatConfig from '../../config/chat.config';
import { MatrixClient } from './matrix.client';
const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
/**
* Best-effort per-type routing to an existing dept room. Anything not listed
* (including GENERIC) falls through to #freight-alerts — safer than a wrong
* guess at which department a type belongs to. Extend as real usage shows
* which types actually want a dept room instead of the shared feed.
*
* `name` matters only if this bridge is the very first thing to touch that
* alias (normally the nightly/on-demand reconcile creates dept rooms first,
* with the position's real name) — ensureRoom never renames an existing
* room, so this must match what ChatProvisioningService would have used.
*/
const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: string }>> = {
[NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' },
[NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' },
};
/**
* Mirrors BACKOFFICE-audience notifications into chat so staff see them
* without having the inbox open. Hooked once into
* NotificationInboxService.notify() — every one of that service's ~20
* callers gets this for free.
*
* Gated on BACKOFFICE only: notify() also serves PORTAL (customer)
* notifications, which must never land in an internal staff room.
*/
@Injectable()
export class ChatBridgeService {
private readonly logger = new Logger(ChatBridgeService.name);
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
private readonly matrix: MatrixClient,
) {}
async bridge(input: NotifyInput): Promise<void> {
if (!this.config.enabled) return;
try {
const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM;
const roomId = await this.matrix.ensureRoom(room.alias, room.name);
const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`;
const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''
}`;
await this.matrix.sendMessage(roomId, body, html);
} catch (err) {
// Same contract as NotificationInboxService.notify(): a chat-bridge
// failure must never break or roll back the notification that
// triggered it.
this.logger.error(
`Chat bridge failed for ${input.type}: ${(err as Error).message}`,
);
}
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,237 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { MatrixClient } from './matrix.client';
/** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat
* (one org, one unit), so this is the entire scope of what gets provisioned. */
const ORG_KEY = 'edr_freight';
const UNIT_KEY = 'edr_freight_app';
const SPACE_ALIAS = 'edr-freight';
const GENERAL_ALIAS = 'general';
interface PositionHolder {
positionKey: string;
positionName: string;
userId: string;
userName: string;
}
export interface ReconcileResult {
rooms: number;
joined: number;
kicked: number;
deactivated: number;
}
/**
* Keeps Matrix rooms and their membership in sync with IAM's unit/position
* tree. There is no local hook on "employee position changed" — IAM writes
* happen inside the vendored @tria-plc/iamapi-common package — so this is a
* reconcile loop, not an event handler: nightly, plus on-demand via
* POST /chat/sync.
*
* Room identity is a deterministic alias (#dept-<positionKey>), not a stored
* mapping table — resolved via the directory API, created on first miss.
* Room membership is diffed against Matrix's own joined_members, not a local
* snapshot — so a user removed from IAM disappears from chat on the very
* next reconcile, with no extra state for this service to own.
*/
@Injectable()
export class ChatProvisioningService {
private readonly logger = new Logger(ChatProvisioningService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly matrix: MatrixClient,
) {}
@Cron(CronExpression.EVERY_DAY_AT_3AM, { name: 'chat-provisioning-reconcile' })
async scheduledReconcile(): Promise<void> {
try {
const result = await this.reconcile();
this.logger.log(
`Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` +
`${result.kicked} kicked, ${result.deactivated} deactivated`,
);
} catch (err) {
// Never throws into the scheduler — chat provisioning must not be able
// to take down anything else on the cron registry.
this.logger.error(
`Chat reconcile failed: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
/** Every current holder in the unit, or just one person's rows when `userId` is given. */
private async currentHolders(userId?: string): Promise<PositionHolder[]> {
return this.dataSource.query(
`SELECT p.key AS "positionKey",
COALESCE(p.name->>'en', p.key) AS "positionName",
e.user_id AS "userId",
COALESCE(iu.name->>'en', iu.username, iu.email) AS "userName"
FROM iam.employee_positions ep
JOIN iam.employees e ON e.id = ep.employee_id
JOIN iam.positions p ON p.id = ep.position_id
JOIN iam.units u ON u.id = p.unit_id
JOIN iam.organizations o ON o.id = u.organization_id
JOIN iam.users iu ON iu.id = e.user_id
WHERE ep.is_current = true
AND e.is_current = true
AND o.key = $1
AND u.key = $2
${userId ? 'AND e.user_id = $3' : ''}`,
userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY],
);
}
/**
* Put one person in their rooms right now.
*
* {@link reconcile} is nightly, so without this a new employee's first
* sign-in shows an empty client until 3AM — the SSO handoff creates their
* account but joins them to nothing. Called on every /chat/sso, so it is
* scoped to the one user (a full reconcile per click would be a room-count
* multiple of Matrix calls) and every step is get-or-create.
*
* Someone holding no current position in the unit joins nothing, by the same
* rule the reconcile uses — chat membership follows the org tree.
*/
async joinUserRooms(userId: string, displayName: string): Promise<number> {
const positions = await this.currentHolders(userId);
if (positions.length === 0) return 0;
const mxid = this.matrix.mxidFor(userId, displayName);
// The JWT login auto-registers too, but that happens after this runs and
// the admin join API 404s on an account that does not exist yet.
await this.matrix.ensureUser(mxid, displayName);
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
isSpace: true,
});
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
parentSpaceId: spaceId,
});
await this.matrix.ensureJoined(generalRoomId, mxid);
for (const position of positions) {
const roomId = await this.matrix.ensureRoom(
`dept-${position.positionKey}`,
position.positionName,
{ parentSpaceId: spaceId },
);
await this.matrix.ensureJoined(roomId, mxid);
}
return positions.length + 1;
}
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
private async syncMembership(
roomId: string,
desiredUserIds: Set<string>,
botMxid: string,
): Promise<{ joined: number; kicked: string[] }> {
const current = await this.matrix.joinedMembers(roomId);
const currentSet = new Set(current.filter((id) => id !== botMxid));
let joined = 0;
for (const userId of desiredUserIds) {
if (!currentSet.has(userId)) {
await this.matrix.ensureJoined(roomId, userId);
joined += 1;
}
}
const kicked: string[] = [];
for (const userId of currentSet) {
if (!desiredUserIds.has(userId)) {
await this.matrix.kick(roomId, userId, 'No longer assigned to this room');
kicked.push(userId);
}
}
return { joined, kicked };
}
async reconcile(): Promise<ReconcileResult> {
const holders = await this.currentHolders();
const botMxid = await this.matrix.whoami();
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
isSpace: true,
});
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
parentSpaceId: spaceId,
});
const allUserIds = new Set(
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
);
// Accounts are otherwise only created lazily on first JWT login (see
// ChatSsoService) — force-joining someone who has never clicked "Chat"
// yet 404s ("User not found") without this.
const seenUserIds = new Set<string>();
for (const h of holders) {
const mxid = this.matrix.mxidFor(h.userId, h.userName);
if (seenUserIds.has(mxid)) continue;
seenUserIds.add(mxid);
await this.matrix.ensureUser(mxid, h.userName);
}
let rooms = 2; // space + general
let joined = 0;
let kicked = 0;
// A user kicked from anything while holding zero current positions
// anywhere in the unit (allUserIds spans every position) is a full
// leaver, not just moved between positions — deactivate their account.
const kickedUserIds = new Set<string>();
const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid);
joined += generalDiff.joined;
kicked += generalDiff.kicked.length;
generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
const byPosition = new Map<string, { name: string; userIds: Set<string> }>();
for (const h of holders) {
const entry = byPosition.get(h.positionKey) ?? {
name: h.positionName,
userIds: new Set<string>(),
};
entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName));
byPosition.set(h.positionKey, entry);
}
for (const [positionKey, { name, userIds }] of byPosition) {
const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, {
parentSpaceId: spaceId,
});
rooms += 1;
const diff = await this.syncMembership(roomId, userIds, botMxid);
joined += diff.joined;
kicked += diff.kicked.length;
diff.kicked.forEach((uid) => kickedUserIds.add(uid));
}
let deactivated = 0;
for (const userId of kickedUserIds) {
if (allUserIds.has(userId)) continue; // moved position, still current elsewhere
try {
await this.matrix.deactivateUser(userId);
deactivated += 1;
} catch (err) {
this.logger.warn(
`Failed to deactivate departed user ${userId}: ${(err as Error).message}`,
);
}
}
return { rooms, joined, kicked, deactivated };
}
}

View File

@@ -0,0 +1,91 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { SignJWT } from 'jose';
import chatConfig from '../../config/chat.config';
import { ChatProvisioningService } from './chat-provisioning.service';
import { MatrixClient, chatLocalpart } from './matrix.client';
/** Long enough for one login call, short enough to be worthless if it leaks. */
const JWT_TTL_SECONDS = 60;
function displayName(user: TCurrentUser): string {
return (
user.name?.en ||
Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) ||
user.username ||
user.email
);
}
/**
* The SSO handoff: turn an already-authenticated freight session into a
* one-click Element sign-in link, with no second password anywhere.
*
* 1. Sign a short-lived JWT asserting this user's id (Synapse's
* org.matrix.login.jwt auto-registers the account on first use).
* 2. Trade that JWT for a real Matrix session.
* 3. Hand the caller a link to Element's sso.html shim, which writes that
* session into localStorage and drops the user straight into Element.
*
* Step 3 used to mint a one-shot login_token and let Element redeem it. That
* path is capped at one request per user per minute by a limiter hardcoded in
* Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a
* spent token surfaces in Element as "Incorrect username and/or password".
* Element accepts a plaintext token out of localStorage (Lifecycle.ts
* getStoredToken/tryDecryptToken), so handing over the session we already hold
* removes both failure modes and one round-trip.
*/
@Injectable()
export class ChatSsoService {
private readonly logger = new Logger(ChatSsoService.name);
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
private readonly matrix: MatrixClient,
private readonly provisioning: ChatProvisioningService,
) {}
async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> {
const secret = new TextEncoder().encode(this.config.jwtSecret);
const name = displayName(user);
// Before the link, not after: the reconcile that fills rooms is nightly, so
// a first sign-in would otherwise open an empty client. Best-effort —
// failing to join a room is no reason to refuse someone a sign-in link.
try {
await this.provisioning.joinUserRooms(user.id, name);
} catch (err) {
this.logger.error(
`Room join on sign-in failed for ${user.id}: ${(err as Error).message}`,
);
}
// Synapse takes the localpart straight from `sub` on auto-registration, so
// this must be byte-identical to what ChatProvisioningService derives for
// the same person — otherwise SSO signs them into one account while the
// reconcile force-joins a different one into the rooms.
const jwt = await new SignJWT({ name })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(chatLocalpart(user.id, name))
.setIssuer('edr-freight-api')
.setAudience('matrix')
.setIssuedAt()
.setExpirationTime(`${JWT_TTL_SECONDS}s`)
.sign(secret);
const session = await this.matrix.loginWithJwt(jwt);
// Session goes in the URL fragment, never the query: a fragment is not sent
// to any server, so the token stays out of Element's access log, and
// sso.html replaces the entry so it does not linger in history either.
const params = new URLSearchParams({
hs: this.config.publicBaseUrl,
t: session.access_token,
u: session.user_id,
d: session.device_id,
});
return { url: `${this.config.webUrl}/sso.html#${params.toString()}` };
}
}

View File

@@ -0,0 +1,35 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ChatSync } from '../../common/booking-guards';
import { ChatProvisioningService } from './chat-provisioning.service';
import { ChatSsoService } from './chat-sso.service';
@ApiTags('chat')
@Controller('chat')
@ApiBearerAuth()
export class ChatController {
constructor(
private readonly sso: ChatSsoService,
private readonly provisioning: ChatProvisioningService,
) {}
@Get('sso')
@UseGuards(JwtGuard)
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
getSso(@CurrentUser() user: TCurrentUser) {
return this.sso.getSsoUrl(user);
}
@Post('sync')
@ChatSync()
@ApiOperation({
summary: 'Re-run the chat room/membership reconcile immediately (normally nightly)',
})
sync() {
return this.provisioning.reconcile();
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ChatBridgeService } from './chat-bridge.service';
import { ChatController } from './chat.controller';
import { ChatProvisioningService } from './chat-provisioning.service';
import { ChatSsoService } from './chat-sso.service';
import { MatrixClient } from './matrix.client';
@Module({
controllers: [ChatController],
providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService],
// ChatBridgeService: consumed by NotificationInboxModule to mirror
// BACKOFFICE notifications into chat — see notification-inbox.module.ts.
exports: [ChatBridgeService],
})
export class ChatModule {}

View File

@@ -0,0 +1,35 @@
import { chatLocalpart } from './matrix.client';
describe('chatLocalpart', () => {
it('reads from the name, not the id', () => {
expect(
chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'),
).toBe('nati-wondi.03f5eb');
});
it('separates two people who share a name', () => {
// Both of these are real dev rows — same name, different employees.
const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA');
const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA');
expect(a).not.toBe(b);
});
it('is stable for the same person', () => {
const id = '7d798218-09de-47a1-98eb-f61ec44e9280';
expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod'));
});
it('still yields a usable localpart for a name that slugs to nothing', () => {
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe(
'user.7d7982',
);
});
it('only emits characters Matrix accepts in a localpart', () => {
for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) {
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch(
/^[a-z0-9._=\-/]+$/,
);
}
});
});

View File

@@ -0,0 +1,320 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import chatConfig from '../../config/chat.config';
/**
* Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API
* calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a
* browser/Element concern; the server side only ever provisions rooms/users
* and posts bot messages, so a fetch wrapper is the whole job.
*
* All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That
* same account also posts the notification-bridge messages (see
* ChatBridgeService) — one bot/admin account covers both jobs, no separate
* bot user needed.
*/
/**
* Localpart of a staff member's MXID: their name, plus the first 6 hex of
* their freight user id.
*
* The tail is not decoration. Names collide — 19 of the 114 users in the dev
* IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are
* two different people) — and an MXID is permanent, so a bare slug would hand
* two employees the same Matrix account and each other's rooms. The id is
* already random, so 6 hex of it separates them without a lookup or a mapping
* table, and keeps the derivation pure: ChatSsoService (which mints the JWT
* `sub`) and ChatProvisioningService (which force-joins rooms) must agree on
* this string exactly or they provision two accounts per person.
*/
export function chatLocalpart(userId: string, displayName: string): string {
const slug = displayName
// NFKD splits an accent off its letter; the non-alnum sweep below then
// folds the leftover mark into the same `-` run as the neighbouring space.
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
// Amharic-only names slug to nothing — the tail still makes it unique.
return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`;
}
@Injectable()
export class MatrixClient {
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
) {}
/**
* Alias localparts go in a URL path segment, so a `/` in one is fatal:
* Synapse decodes the path before routing, and `%2F` splits the request into
* a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404
* as "no such room" and ensureRoom then tries to create the same broken alias
* on every run. Position keys are `edr_freight_app/opn` shaped, so this hits
* every dept room but the handful whose key happens to be a bare word.
*/
private static aliasSafe(alias: string): string {
return alias.replace(/[^A-Za-z0-9._=-]/g, '-');
}
/** `@<localpart>:<server_name>` — the one place this format is assembled. */
mxid(localpart: string): string {
return `@${localpart}:${this.config.serverName}`;
}
/** The MXID of a freight user — see {@link chatLocalpart}. */
mxidFor(userId: string, displayName: string): string {
return this.mxid(chatLocalpart(userId, displayName));
}
get serverName(): string {
return this.config.serverName;
}
private async request<T>(
method: string,
path: string,
body?: unknown,
token: string = this.config.adminToken,
): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
/** No auth — only /login accepts a bare JWT with nothing else on the request. */
private async publicRequest<T>(
method: string,
path: string,
body: unknown,
): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
);
}
return (await res.json()) as T;
}
/** 404 → null. Every other non-2xx still throws via {@link request}. */
private async requestOrNull<T>(
method: string,
path: string,
token?: string,
): Promise<T | null> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
method,
headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` },
});
if (res.status === 404) return null;
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
);
}
return (await res.json()) as T;
}
/** Sign an already-authenticated freight session into a Matrix session. */
loginWithJwt(
jwt: string,
): Promise<{ access_token: string; user_id: string; device_id: string }> {
return this.publicRequest('POST', '/_matrix/client/v3/login', {
type: 'org.matrix.login.jwt',
token: jwt,
initial_device_display_name: 'EDR Backoffice',
});
}
/** The account behind MATRIX_ADMIN_TOKEN — used to exclude the bot itself from membership reconciliation. */
async whoami(): Promise<string> {
const res = await this.request<{ user_id: string }>(
'GET',
'/_matrix/client/v3/account/whoami',
);
return res.user_id;
}
/** Currently-joined user ids for a room (not full member-event state). */
async joinedMembers(roomId: string): Promise<string[]> {
const res = await this.request<{ joined: Record<string, unknown> }>(
'GET',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`,
);
return Object.keys(res.joined);
}
// No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token
// is rate limited to 1 request per user per MINUTE, hardcoded in Synapse
// (rest/client/login_token_request.py: "Ratelimit aggressively … could be
// abused by a malicious client to create many sessions") and not settable
// from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED.
// ChatSsoService hands Element the session from loginWithJwt directly
// instead, which needs no second call.
/** null when the alias doesn't resolve to a room yet. */
resolveAlias(alias: string): Promise<{ room_id: string } | null> {
return this.requestOrNull(
'GET',
`/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
);
}
createRoom(input: {
alias: string;
name: string;
topic?: string;
isSpace?: boolean;
parentSpaceId?: string;
}): Promise<{ room_id: string }> {
return this.request('POST', '/_matrix/client/v3/createRoom', {
room_alias_name: input.alias,
name: input.name,
topic: input.topic,
preset: 'private_chat',
creation_content: input.isSpace ? { type: 'm.space' } : undefined,
initial_state: input.parentSpaceId
? [
{
type: 'm.space.parent',
state_key: input.parentSpaceId,
content: { via: [this.config.serverName], canonical: true },
},
]
: undefined,
});
}
addToSpace(spaceId: string, childRoomId: string): Promise<void> {
return this.request(
'PUT',
`/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(childRoomId)}`,
{ via: [this.config.serverName] },
);
}
/**
* Get-or-create by alias — the room identity scheme this whole module
* relies on instead of a local id-mapping table. Idempotent: safe to call
* on every reconcile run and every bridged notification alike.
*/
async ensureRoom(
rawAlias: string,
name: string,
opts: { isSpace?: boolean; parentSpaceId?: string } = {},
): Promise<string> {
const alias = MatrixClient.aliasSafe(rawAlias);
const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`);
if (existing) return existing.room_id;
const { room_id } = await this.createRoom({
alias,
name,
isSpace: opts.isSpace,
parentSpaceId: opts.parentSpaceId,
});
if (opts.parentSpaceId) {
await this.addToSpace(opts.parentSpaceId, room_id);
}
return room_id;
}
/**
* Create the account if absent (no password — this deployment is JWT-SSO
* only), or no-op if it already exists. Needed before force-joining a
* position holder who has never clicked "Chat": accounts are otherwise
* only created lazily on first JWT login, and the admin join API 404s
* ("User not found") on an account that doesn't exist yet.
*/
async ensureUser(userId: string, displayName?: string): Promise<void> {
const existing = await this.requestOrNull<{ name: string }>(
'GET',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
);
if (existing) return;
await this.request(
'PUT',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
displayName ? { displayname: displayName } : {},
);
}
/** Server-admin force-join — no invite to accept, works even mid-outage for the invitee. */
forceJoin(roomIdOrAlias: string, userId: string): Promise<void> {
return this.request(
'POST',
`/_synapse/admin/v1/join/${encodeURIComponent(roomIdOrAlias)}`,
{ user_id: userId },
);
}
/**
* Force-join, treating "already a member" as success. Synapse answers a
* repeat join with 403 `M_FORBIDDEN: "<user> is already in the room."`, which
* is a failure only if you assumed you knew the membership first. Callers
* that just want someone in a room (sign-in, reconcile racing itself) want
* this; the raw 403 tells them nothing they can act on.
*/
async ensureJoined(roomIdOrAlias: string, userId: string): Promise<void> {
try {
await this.forceJoin(roomIdOrAlias, userId);
} catch (err) {
if (!/already in the room/i.test((err as Error).message)) throw err;
}
}
kick(roomId: string, userId: string, reason: string): Promise<void> {
return this.request(
'POST',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`,
{ user_id: userId, reason },
);
}
/** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */
deactivateUser(userId: string): Promise<void> {
return this.request(
'POST',
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
{ erase: false },
);
}
sendMessage(roomId: string, body: string, formattedBody?: string): Promise<void> {
const txnId = `edr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return this.request(
'PUT',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
formattedBody
? {
msgtype: 'm.text',
body,
format: 'org.matrix.custom.html',
formatted_body: formattedBody,
}
: { msgtype: 'm.text', body },
);
}
}

View File

@@ -288,10 +288,25 @@ export class CompaniesController {
dto.roles,
dto.nationality,
dto.cooperative,
dto.investorLicence,
);
return new CompanyInfoResponseDto(profile, company);
}
@Post("onboarding/revert-to-etrade")
@PortalCustomer()
@ApiOperation({
summary:
"Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade",
})
async revertToRegularCompany(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.revertToRegularCompany(user.id);
return new CompanyInfoResponseDto(profile, company);
}
@Post("company-profile")
@PortalCustomer()
@ApiOperation({

View File

@@ -0,0 +1,292 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import {
CompanyNationality,
CompanyStatus,
CompanyType,
} from "./entities/company.entity";
import {
ProfileStatus,
ProfileType,
} from "./entities/company-profile.entity";
/**
* A foreign company on an Investment Commission licence has no eTrade record,
* so it types its registration — and the flag saying so is what makes the
* backoffice treat those fields as unverified. Two things must hold: only a
* foreign company can carry it, and dropping it must not leave the typed
* registration behind looking like eTrade's.
*
* The dropping half is shared with the co-operative route, which has the same
* "eTrade holds nothing" shape, so it is exercised here for both.
*/
function makeService(company: Record<string, unknown> | null) {
const companiesRepo = {
findById: jest.fn(async () => company),
update: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "company-1",
...row,
})),
existsByTin: jest.fn(async () => false),
};
const companyProfilesRepo = {
findByCompanyId: jest.fn(async (): Promise<Record<string, unknown>[]> => []),
updateStatus: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cp-1",
...row,
})),
softDelete: jest.fn(async () => undefined),
};
const profilesRepo = {
findByUserId: jest.fn(async () =>
company
? { id: "external-1", companyId: "company-1", company: { id: "company-1" } }
: null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "external-1",
...row,
})),
update: jest.fn(async () => null),
};
const service = new CompaniesService(
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
{} as never,
profilesRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
);
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
}
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
const start = (
service: CompaniesService,
nationality: CompanyNationality | undefined,
cooperative: boolean,
investorLicence: boolean,
) =>
service.startOnboarding(
identity as never,
CompanyType.Customer,
[ProfileType.importer],
nationality,
cooperative,
investorLicence,
);
describe("the foreign investment-licence route", () => {
it("refuses the flag for an Ethiopian company", async () => {
const { service } = makeService(null);
await expect(
start(service, CompanyNationality.Ethiopian, false, true),
).rejects.toBeInstanceOf(BadRequestException);
});
it("refuses the flag alongside the co-operative one", async () => {
const { service } = makeService(null);
await expect(
start(service, CompanyNationality.Foreign, true, true),
).rejects.toBeInstanceOf(BadRequestException);
});
it("stores the flag on a new foreign draft", async () => {
const { service, companiesRepo } = makeService(null);
await start(service, CompanyNationality.Foreign, false, true);
expect(companiesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true },
}),
);
});
it("clears the typed registration and reopens onboarding when switching back to eTrade", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
});
await service.revertToRegularCompany("user-1");
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.attributes).toEqual({});
expect(updates.status).toBe(CompanyStatus.Pending);
// The wizard treats a populated registration as a passed lookup, so leaving
// any of it behind would walk the customer straight past the eTrade step.
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingCompleted: false,
onboardingStep: "company",
});
});
it("clears the typed registration when the box is un-ticked on the way back", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
region: "Addis Ababa",
licenceNumber: "TYPED-1",
});
await start(service, CompanyNationality.Foreign, false, false);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
// The wizard sends both flags; the typed manager does not survive.
expect(updates.attributes).toEqual({
cooperative: false,
investorLicence: false,
});
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
// Resume must land back on the company step, or the customer never reaches
// the eTrade lookup they just opted back into.
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingStep: "company",
});
});
it("does the same for a co-operative that stops being one", async () => {
const { service, companiesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Ethiopian,
attributes: { cooperative: true },
region: "Oromia",
});
await start(service, CompanyNationality.Ethiopian, false, false);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.region).toBeNull();
});
it("leaves the registration alone while the flag stays on", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true },
region: "Addis Ababa",
});
await start(service, CompanyNationality.Foreign, false, true);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates).not.toHaveProperty("region");
expect(profilesRepo.update).not.toHaveBeenCalled();
});
it("refuses to switch a company that never took a manual-registration route", async () => {
const { service } = makeService({ id: "company-1", attributes: {} });
await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf(
BadRequestException,
);
});
/**
* The switch belongs to both manual-registration routes, not just this one. A
* co-operative that has since taken out a trade licence had no way back at
* all: the wizard is where the flag is chosen, and an onboarded company can no
* longer reach it.
*/
it("switches a co-operative back to eTrade on the same terms", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Ethiopian,
attributes: { cooperative: true, etradeManagerName: "Typed Name" },
region: "Oromia",
licenceNumber: "TYPED-1",
});
await service.revertToRegularCompany("user-1");
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.attributes).toEqual({});
expect(updates.status).toBe(CompanyStatus.Pending);
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
// A co-op owes no per-role business licence; once it stops being one it
// does, so the application has to be re-opened and re-reviewed.
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingCompleted: false,
onboardingStep: "company",
});
});
/**
* Approval of a co-op's role was granted without a business licence, because
* a co-op owes none. Leaving makes one due, so the approval no longer stands
* for what it said.
*/
it("sends a co-operative's approved roles back for approval", async () => {
const { service, companyProfilesRepo } = makeService({
id: "company-1",
attributes: { cooperative: true },
});
companyProfilesRepo.findByCompanyId.mockResolvedValue([
{ id: "role-active", status: ProfileStatus.Active },
{ id: "role-blocked", status: ProfileStatus.Blacklisted },
{ id: "role-pending", status: ProfileStatus.Pending },
]);
await service.revertToRegularCompany("user-1");
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledWith(
"role-active",
ProfileStatus.Pending,
);
// A staff decision is not the customer's to undo by switching registration:
// promoting a blocked role to "awaiting approval" would launder the block.
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledTimes(1);
});
it("leaves an investor's roles alone — their licences were always due", async () => {
const { service, companyProfilesRepo } = makeService({
id: "company-1",
attributes: { investorLicence: true },
});
companyProfilesRepo.findByCompanyId.mockResolvedValue([
{ id: "role-active", status: ProfileStatus.Active },
]);
await service.revertToRegularCompany("user-1");
expect(companyProfilesRepo.updateStatus).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,187 @@
import { CompaniesService } from "./companies.service";
import { CompanyStatus } from "./entities/company.entity";
import {
ProfileStatus,
ProfileType,
} from "./entities/company-profile.entity";
/**
* Uploading a business licence only ever adds a row — nothing overwrites. So a
* customer answering a rejection or a document correction used to end up with
* the refused licence still listed beside the new one, in the portal and in the
* backoffice, with nothing saying which is current. An upload that answers a
* reviewer now retires what it answers; an upload with nothing outstanding is a
* genuine addition and still just adds.
*/
interface StoredFile {
id: string;
name: string;
code: string;
createdAt: Date;
reviewStatus?: string | null;
removed?: boolean;
}
const T0 = new Date("2026-01-01T00:00:00Z");
const REJECTED_AT = new Date("2026-02-01T00:00:00Z");
const T2 = new Date("2026-03-01T00:00:00Z");
function makeService(
status: ProfileStatus,
files: StoredFile[],
reviewedAt: Date | null = null,
) {
const stored = [...files];
const profile = {
id: "profile-1",
companyId: "company-1",
type: ProfileType.importer,
status,
reviewedAt,
};
const company = {
id: "company-1",
status:
status === ProfileStatus.Active
? CompanyStatus.Active
: CompanyStatus.Pending,
companyProfiles: [profile],
};
const live = () => stored.filter((f) => !f.removed);
const filesService = {
upload: jest.fn(async (input: { code: string; file: { originalname: string } }) => {
const record = {
id: `file-${stored.length + 1}`,
name: input.file.originalname,
code: input.code,
createdAt: T2,
size: 1,
mimeType: "application/pdf",
};
stored.push(record);
return record;
}),
findByResource: jest.fn(async () => live()),
findWithOpenChangeRequest: jest.fn(async () =>
live().filter((f) => f.reviewStatus === "change_requested"),
),
findById: jest.fn(async (id: string) => ({
...stored.find((f) => f.id === id),
resource: "company_profiles",
resourceId: "profile-1",
})),
remove: jest.fn(async (id: string) => {
const found = stored.find((f) => f.id === id);
if (found) found.removed = true;
}),
clearReview: jest.fn(async (id: string) => {
const found = stored.find((f) => f.id === id);
if (found) found.reviewStatus = null;
}),
};
const changeRequestRepo = {
findPendingByCompanyId: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({ id: "cr-1", ...row })),
update: jest.fn(async () => ({ id: "cr-1" })),
findByCompanyId: jest.fn(async () => []),
};
const service = new CompaniesService(
{ findById: jest.fn(async () => company) } as never,
{ findByCompanyId: jest.fn(async () => [profile]) } as never,
changeRequestRepo as never,
{} as never,
{ findByCompanyId: jest.fn(async () => []) } as never,
{} as never,
filesService as never,
{} as never,
{} as never,
{ changeRequestSubmitted: jest.fn() } as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () => ({ profile: { id: "external-1" }, company }) as never,
);
return { service, stored, live, filesService, changeRequestRepo };
}
const upload = (service: CompaniesService) =>
service.addProfileLicenseFiles("user-1", "profile-1", [
{ originalname: "new-licence.pdf" } as never,
]);
describe("a business licence uploaded to answer a reviewer", () => {
it("retires the file the reviewer flagged for correction", async () => {
const { service, live } = makeService(ProfileStatus.Pending, [
{ id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" },
]);
await upload(service);
expect(live().map((f) => f.name)).toEqual(["new-licence.pdf"]);
});
it("retires what was on file when the role was rejected, but not the customer's own fix so far", async () => {
// Two uploads answering one rejection (a second page, or a re-pick) must not
// cannibalise each other — only what the reviewer actually refused goes.
const { service, live } = makeService(
ProfileStatus.Rejected,
[
{ id: "file-refused", name: "refused.pdf", code: "business_license", createdAt: T0 },
{ id: "file-fix-1", name: "fix-page-1.pdf", code: "business_license", createdAt: T2 },
],
REJECTED_AT,
);
await upload(service);
expect(live().map((f) => f.name)).toEqual([
"fix-page-1.pdf",
"new-licence.pdf",
]);
});
it("leaves an ordinary addition alone when nothing was asked for", async () => {
const { service, live } = makeService(ProfileStatus.Pending, [
{ id: "file-old", name: "existing.pdf", code: "business_license", createdAt: T0 },
]);
await upload(service);
expect(live().map((f) => f.name)).toEqual([
"existing.pdf",
"new-licence.pdf",
]);
});
it("stages the swap for review on an approved role instead of deleting", async () => {
// A live role's licence is not the customer's to remove unilaterally: the
// old file stays until a reviewer approves the swap.
const { service, live, changeRequestRepo } = makeService(
ProfileStatus.Active,
[
{ id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" },
],
);
await upload(service);
expect(live().map((f) => f.name)).toEqual(["old.pdf", "new-licence.pdf"]);
const intents = changeRequestRepo.create.mock.calls.flatMap(
([row]) => (row as any).documents.licenseChanges,
);
expect(intents).toEqual(
expect.arrayContaining([
expect.objectContaining({ op: "add", fileId: "file-2" }),
expect.objectContaining({ op: "remove", fileId: "file-old" }),
]),
);
});
});

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

@@ -0,0 +1,115 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { Company, CompanyStatus } from "./entities/company.entity";
import {
CompanyProfile,
ProfileStatus,
ProfileType,
} from "./entities/company-profile.entity";
/**
* A rejection hands the role back to the customer: they fix what was flagged
* and resubmit (`reapplyCompanyProfile` → Pending). The reviewer used to be
* able to skip that entirely and approve straight out of Rejected — granting
* the role over the documents that were just refused, while the customer's
* "please fix this" note was still on their screen.
*/
function makeService(status: ProfileStatus) {
const profile: Partial<CompanyProfile> = {
id: "profile-1",
companyId: "company-1",
type: ProfileType.importer,
status,
reference: null,
reviewNote: status === ProfileStatus.Rejected ? "Licence expired" : null,
};
const company = {
id: "company-1",
status: CompanyStatus.Pending,
attributes: {},
};
const written: Partial<CompanyProfile>[] = [];
const profileRepo = {
update: jest.fn(async (_id: string, patch: Partial<CompanyProfile>) => {
written.push(patch);
Object.assign(profile, patch);
return null;
}),
findOne: jest.fn(async () => profile),
};
const companyRepo = { findOne: jest.fn(async () => company), update: jest.fn() };
const companyProfilesRepo = {
findById: jest.fn(async () => profile),
generateReference: jest.fn(async () => "IM-A00001"),
};
const profilesRepo = {
// Onboarding submitted — the other gate in this method is not what these
// tests are about.
findByCompanyId: jest.fn(async () => [{ onboardingCompleted: true }]),
};
const filesService = { findWithOpenChangeRequest: jest.fn(async () => []) };
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({
findOne: jest.fn(async () => company),
getRepository: (entity: unknown) =>
entity === Company ? companyRepo : profileRepo,
}),
),
};
const companyNotifier = { profileStatusChanged: jest.fn(), companyApproved: jest.fn() };
const service = new CompaniesService(
{} as never,
companyProfilesRepo as never,
{} as never,
{} as never,
profilesRepo as never,
{} as never,
filesService as never,
{} as never,
{} as never,
companyNotifier as never,
dataSource as never,
{} as never,
);
return { service, profile, written, companyProfilesRepo };
}
describe("approving an operational role", () => {
it("refuses to approve a role the customer has not resubmitted", async () => {
const { service, companyProfilesRepo } = makeService(ProfileStatus.Rejected);
await expect(
service.setCompanyProfileStatus("profile-1", ProfileStatus.Active),
).rejects.toBeInstanceOf(BadRequestException);
// Refused before any reference could be minted against the rejected role.
expect(companyProfilesRepo.generateReference).not.toHaveBeenCalled();
});
it("lets a reviewer undo their own rejection, and drops the note with it", async () => {
const { service, written } = makeService(ProfileStatus.Rejected);
await service.setCompanyProfileStatus("profile-1", ProfileStatus.Pending);
expect(written[0]).toMatchObject({
status: ProfileStatus.Pending,
reviewNote: null,
});
});
it("still approves a role that is awaiting its first decision", async () => {
const { service, written } = makeService(ProfileStatus.Pending);
await service.setCompanyProfileStatus("profile-1", ProfileStatus.Active);
expect(written[0]).toMatchObject({
status: ProfileStatus.Active,
reference: "IM-A00001",
});
});
});

View File

@@ -7,6 +7,7 @@ import {
ForbiddenException,
} from "@nestjs/common";
import { DataSource, EntityManager } from "typeorm";
import { resolveIamUserNames } from "../../common/utils/iam-user-name.util";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
@@ -62,7 +63,10 @@ import {
CompanyStatus,
CompanyType,
COOPERATIVE_KEY,
INVESTOR_LICENCE_KEY,
hasInvestorLicence,
isCooperative,
usesManualRegistration,
} from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
@@ -377,6 +381,7 @@ export class CompaniesService {
roles: ProfileType[],
nationality?: CompanyNationality,
cooperative?: boolean,
investorLicence?: boolean,
): Promise<{ profile: ExternalProfile; company: Company }> {
// Already started — reuse the existing draft, just ensure roles exist and
// keep the nationality up to date if it was (re)selected.
@@ -387,13 +392,20 @@ export class CompaniesService {
// flag into `attributes`, or to read a stored one the caller didn't send.
const needsCompany =
cooperative !== undefined ||
investorLicence !== undefined ||
roles.includes(ProfileType.freightForwarder);
const current = needsCompany
? await this.companiesRepo.findById(companyId)
: null;
const isCoop = cooperative ?? isCooperative(current);
const isInvestor = investorLicence ?? hasInvestorLicence(current);
this.assertRolesAllowedForCooperative(isCoop, roles);
this.assertNationalityAllowedForCooperative(isCoop, nationality);
this.assertInvestorLicenceAllowed(
isInvestor,
isCoop,
nationality ?? current?.nationality ?? undefined,
);
await this.syncCompanyProfiles(companyId, companyType, roles);
const updates: Partial<Company> = {};
if (nationality) updates.nationality = nationality;
@@ -401,20 +413,49 @@ export class CompaniesService {
// stored nationality too, or the company keeps resolving to the foreign
// document set.
if (isCoop) updates.nationality = CompanyNationality.Ethiopian;
if (cooperative !== undefined) {
if (cooperative !== undefined || investorLicence !== undefined) {
updates.attributes = {
...(current?.attributes ?? {}),
[COOPERATIVE_KEY]: cooperative,
...(cooperative !== undefined
? { [COOPERATIVE_KEY]: cooperative }
: {}),
...(investorLicence !== undefined
? { [INVESTOR_LICENCE_KEY]: investorLicence }
: {}),
};
}
// Going back and un-ticking the box is the same act as the settings
// switch, so it has to cost the same: the registration the customer typed
// goes, and onboarding drops back to the company step. Without this the
// draft keeps the typed values, `hasRegistrationDetails` reads as a passed
// lookup, resume lands past the company step entirely — and the company
// finishes onboarding on unverified data with no flag left to say so.
const backToEtrade =
usesManualRegistration(current) && !isCoop && !isInvestor;
if (backToEtrade) {
Object.assign(updates, CompaniesService.CLEARED_REGISTRATION);
updates.attributes = this.withoutTypedEtradeManager(
updates.attributes ?? current?.attributes,
);
}
if (Object.keys(updates).length > 0) {
await this.companiesRepo.update(companyId, updates);
}
if (backToEtrade) {
await this.profilesRepo.update(existing.id, {
onboardingStep: "company",
});
}
return this.getCompanyInfoByUserId(identity.userId);
}
this.assertRolesAllowedForCooperative(cooperative === true, roles);
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
this.assertInvestorLicenceAllowed(
investorLicence === true,
cooperative === true,
nationality,
);
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
@@ -427,7 +468,14 @@ export class CompaniesService {
country: "Ethiopia",
nationality: nationality ?? CompanyNationality.Ethiopian,
status: CompanyStatus.Pending,
...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}),
...(cooperative || investorLicence
? {
attributes: {
...(cooperative ? { [COOPERATIVE_KEY]: true } : {}),
...(investorLicence ? { [INVESTOR_LICENCE_KEY]: true } : {}),
},
}
: {}),
});
await this.profilesRepo.create({
@@ -484,6 +532,73 @@ export class CompaniesService {
}
}
/**
* The registration block as it must look when nobody has verified it.
*
* Used wherever a company stops being one eTrade cannot answer for: whatever
* sits in these columns was the customer's own statement, and the wizard
* treats a populated registration as a lookup that already passed
* (`hasRegistrationDetails`). Leaving it behind would hand the company an
* eTrade-verified record eTrade never supplied — and, once the flag is gone,
* a backoffice screen that says so.
*/
private static readonly CLEARED_REGISTRATION: Partial<Company> = {
licenceNumber: null,
statusDescription: null,
dateRegistered: null,
renewedFrom: null,
renewalDate: null,
renewedTo: null,
region: null,
zone: null,
woreda: null,
kebele: null,
houseNo: null,
etradePhone: null,
};
/**
* The company's own `attributes`, minus the manager captured alongside a
* typed registration. It never came from a licence, so it must not outlive
* the registration it belonged to.
*/
private withoutTypedEtradeManager(
attributes: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
const next = { ...(attributes ?? {}) };
delete next.etradeManagerName;
delete next.etradeManagerPhone;
return next;
}
/**
* An investment licence belongs to a foreign company and to nothing else.
*
* It is the Ethiopian Investment Commission's licence, issued to a foreign
* investor — an Ethiopian company registers with the trade registry, which is
* exactly the eTrade record this flag says does not exist. A co-operative
* cannot hold one either: it is Ethiopian by construction, and the two flags
* resolve to different document sets, so a company carrying both would owe an
* incoherent list of papers.
*/
private assertInvestorLicenceAllowed(
investorLicence: boolean,
cooperative: boolean,
nationality: CompanyNationality | undefined,
): void {
if (!investorLicence) return;
if (cooperative) {
throw new BadRequestException(
"A co-operative union or farm is registered in Ethiopia — it cannot also onboard on a foreign investment licence.",
);
}
if (nationality !== CompanyNationality.Foreign) {
throw new BadRequestException(
"Only a foreign company can onboard on an investment licence.",
);
}
}
/**
* Reconcile the company's operational profiles with the roles the user has
* selected: create the missing ones, drop the ones they deselected.
@@ -1141,16 +1256,55 @@ export class CompaniesService {
return new ProfileResponseDto(profile, live, request);
}
/** List a company's change requests, newest first (backoffice review). */
/**
* List a company's change requests, newest first (backoffice review). Actor
* ids are resolved to display names here — the history screen has to say who
* asked for a change and who sent it back, not print two uuids.
*/
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
await this.findCompanyById(companyId);
return this.changeRequestRepo.findByCompanyId(companyId);
const requests = await this.changeRequestRepo.findByCompanyId(companyId);
const names = await this.resolveActorNames(
requests.flatMap((r) => [r.submittedBy, r.reviewedBy]),
);
for (const request of requests) {
request.submittedByName = request.submittedBy
? (names.get(request.submittedBy) ?? null)
: null;
request.reviewedByName = request.reviewedBy
? (names.get(request.reviewedBy) ?? null)
: null;
}
return requests;
}
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
await this.findCompanyById(companyId);
return this.revisionRepo.findByCompanyId(companyId);
const revisions = await this.revisionRepo.findByCompanyId(companyId);
const names = await this.resolveActorNames(revisions.map((r) => r.actorId));
for (const revision of revisions) {
revision.actorName = revision.actorId
? (names.get(revision.actorId) ?? null)
: null;
}
return revisions;
}
/**
* Display names for actor ids, one query for the whole list. A lookup failure
* degrades the history to ids rather than failing the request — the entry is
* still worth showing without the name.
*/
private async resolveActorNames(
actorIds: (string | null | undefined)[],
): Promise<Map<string, string>> {
try {
return await resolveIamUserNames(this.dataSource, actorIds);
} catch (err) {
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
return new Map();
}
}
/**
@@ -1519,6 +1673,7 @@ export class CompaniesService {
}
await this.discardLicenseChanges(request);
await this.discardDocumentChanges(request);
await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected,
@@ -1556,6 +1711,12 @@ export class CompaniesService {
`Change request ${id} is already ${request.status}`,
);
}
await this.notifyChangeRequestReturned(
request,
"changes_requested",
note,
reviewerId,
);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.ChangesRequested,
@@ -1566,6 +1727,35 @@ export class CompaniesService {
);
}
/**
* Tell the customer desk a change request came back unapproved. Best-effort:
* a missing company or an unresolvable reviewer name must not fail the
* reviewer's decision, which is already the point of the try/catch.
*/
private async notifyChangeRequestReturned(
request: CompanyChangeRequest,
outcome: "rejected" | "changes_requested",
note: string,
reviewerId?: string,
): Promise<void> {
try {
const company = await this.companiesRepo.findById(request.companyId);
if (!company) return;
const names = await this.resolveActorNames([reviewerId]);
this.companyNotifier.changeRequestReturned(
company,
request.id,
outcome,
note,
reviewerId ? (names.get(reviewerId) ?? null) : null,
);
} catch (err) {
this.logger.warn(
`Could not notify the customer desk about ${request.id}: ${String(err)}`,
);
}
}
async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);
@@ -1640,6 +1830,24 @@ export class CompaniesService {
);
}
// A rejected role is waiting on the customer, not on the reviewer: nothing
// has been resubmitted, and the note telling them what to fix is still on
// their screen. Approving straight out of Rejected grants the very role that
// was refused, over the documents that were refused with it. The way back is
// the customer's own resubmission (`reapplyCompanyProfile` → Pending); a
// rejection made in error is undone by moving the role back to pending
// review first — the same shape as "withdraw the change request first" on
// the document gate below.
if (
status === ProfileStatus.Active &&
existing.status === ProfileStatus.Rejected
) {
throw new BadRequestException(
"This role was rejected — the customer has to fix what was flagged and resubmit it before it can be approved. " +
"If the rejection was a mistake, move the role back to pending review first.",
);
}
// A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an
@@ -1768,7 +1976,14 @@ export class CompaniesService {
status === ProfileStatus.Suspended
) {
patch.reviewNote = note ?? null;
} else if (status === ProfileStatus.Active) {
} else if (
status === ProfileStatus.Active ||
status === ProfileStatus.Pending
) {
// Pending only reaches here when a reviewer withdraws their own rejection
// (the customer's resubmission clears the note in `reapplyCompanyProfile`),
// so the reason they gave goes with it — leaving it would keep telling the
// customer to fix something nobody is waiting on any more.
patch.reviewNote = null;
}
if (status !== ProfileStatus.Pending) {
@@ -2067,6 +2282,7 @@ export class CompaniesService {
// or farm holds no business licence, so it owes its own list rather than the
// nationality list plus extras.
const cooperative = isCooperative(company);
const investorLicence = hasInvestorLicence(company);
const documentSettingCode = this.documentSettingCodeFor(company);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
@@ -2216,6 +2432,7 @@ export class CompaniesService {
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
cooperative,
investorLicence,
companyInfo: {
complete: missingInfo.length === 0,
missingFields: missingInfo,
@@ -2293,6 +2510,97 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(userId);
}
/**
* Drop whichever manual-registration route the company is on and send it back
* through the normal eTrade one.
*
* Both routes exist for the same reason — eTrade holds no record to fetch —
* so leaving one is the same act whichever it is, and it is the only way back
* to eTrade for either. A co-operative union or farm that has since taken out
* a trade licence had no exit at all before this; its only route was the
* wizard, which an onboarded company can no longer reach.
*
* Everything the flag let the customer type is cleared, not kept: the
* registration block on file was their own statement, and leaving it there
* would let the wizard treat the company as already looked-up
* (`hasRegistrationDetails` is what stands in for a verified TIN on a
* resume) and walk straight past the eTrade step this switch exists to
* reach. Onboarding reopens at the company step and the company goes back to
* pending — an approval granted against typed data cannot silently carry over
* to a record that now claims to be eTrade's.
*
* Switching the other way — INTO a co-operative or an investment licence — is
* deliberately not here. It is the wizard's nationality/role step, which this
* reopens, and which is the one place the mutually-exclusive rules live
* (`assertInvestorLicenceAllowed`, `assertRolesAllowedForCooperative`,
* `assertNationalityAllowedForCooperative`). A second entry point would have
* to restate all three.
*/
async revertToRegularCompany(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.companiesRepo.findById(companyId);
if (!company)
throw new NotFoundException(`Company ${companyId} not found`);
if (!usesManualRegistration(company)) {
throw new BadRequestException(
"This company is already registered through eTrade — there is nothing to switch.",
);
}
const wasCooperative = isCooperative(company);
// Both flags go, not just the one that was set: they are mutually exclusive
// and a company can only ever hold one, but the destination is "neither",
// so stripping only the one we happened to check for would leave the other
// behind if the pair ever did coexist.
const attributes = this.withoutTypedEtradeManager(company.attributes);
delete attributes[INVESTOR_LICENCE_KEY];
delete attributes[COOPERATIVE_KEY];
await this.companiesRepo.update(companyId, {
...CompaniesService.CLEARED_REGISTRATION,
attributes,
status: CompanyStatus.Pending,
});
await this.profilesRepo.update(profile.id, {
onboardingCompleted: false,
onboardingStep: "company",
});
// A co-operative owes no per-role business licence — that is the whole
// reason its own document set stands in for one. The moment it stops being
// one, every role owes a licence that was never uploaded, so an approval
// granted without one no longer means what it said: back to Pending, and
// the reviewer sees the licence with the rest of the re-application.
//
// Only Active roles move. Rejected, Suspended and Blacklisted are the
// backoffice's own decisions, and quietly promoting a blocked role to
// "awaiting approval" would launder the block away. The reference survives
// either way — it is minted once (`setCompanyProfileStatus`) and re-approval
// reuses it, so bookings that cite it keep citing the same number.
//
// An investor is untouched: it always held a licence per role, so nothing
// becomes due that was not already reviewed.
if (wasCooperative) {
const roles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const role of roles) {
if (role.status !== ProfileStatus.Active) continue;
await this.companyProfilesRepo.updateStatus(
role.id,
ProfileStatus.Pending,
);
}
}
return this.getCompanyInfoByUserId(userId);
}
/**
* Block a self-service action when the company account isn't active, naming
* the actual status — a suspended customer told "awaiting approval" has no
@@ -2392,6 +2700,9 @@ export class CompaniesService {
* with the role itself. Only for an already-approved role are they staged under
* the pending code and recorded as `add` intents on a pending change request —
* a licence swap on a live role is a change; a licence on a new role is not.
*
* An upload that answers a reviewer also retires the licence it answers (see
* below), so a correction never leaves both copies on file.
*/
async addProfileLicenseFiles(
userId: string,
@@ -2403,6 +2714,28 @@ export class CompaniesService {
const gated = profile.status === ProfileStatus.Active;
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
// An upload that answers the reviewer replaces what they refused; it does
// not sit next to it. Uploading only ever adds a row, so without this the
// refused licence stays listed in the portal and the backoffice beside the
// new one and nothing says which is current. Two things count as refused:
// the file the reviewer flagged for correction, and — when the whole role
// came back rejected — every licence that was already on file when they
// rejected it. Anything the customer uploaded *since* that decision is part
// of the same fix (a second page, a re-pick), so it survives, and an upload
// with nothing outstanding is a genuine addition and is left alone.
const rejectedAt =
profile.status === ProfileStatus.Rejected
? (profile.reviewedAt ?? null)
: null;
const superseded = rejectedAt
? (
await this.filesService.findByResource(profileId, LICENSE_RESOURCE)
).filter((f) => f.createdAt < rejectedAt)
: await this.filesService.findWithOpenChangeRequest(
[profileId],
LICENSE_RESOURCE,
);
const uploaded = await Promise.all(
files.map((file) =>
this.filesService.upload({
@@ -2427,6 +2760,13 @@ export class CompaniesService {
);
}
// Retire what the upload supersedes, through the normal removal path so an
// approved role stages a `remove` intent (reviewed as a swap) while an
// unapproved one just drops the file.
for (const stale of superseded) {
await this.removeProfileLicenseFile(userId, profileId, stale.id);
}
// A fresh licence upload answers any correction the reviewer asked for on the
// previous one, so the old row must stop blocking approval.
await this.resolveDocumentChangeRequests(
@@ -3459,7 +3799,7 @@ export class CompaniesService {
// the registered address themselves, and what they send IS the data. The
// check is skipped rather than failed: running the lookup would 400 every
// save with "no registration found for this TIN".
if (isCooperative(company)) return;
if (usesManualRegistration(company)) return;
const touched = ETRADE_SOURCED_FIELDS.some(
(key) => key !== "tin" && dto[key] !== undefined,

View File

@@ -244,6 +244,35 @@ export class CompanyNotifierService {
);
}
/**
* A reviewer did NOT approve a customer's profile changes — they rejected it
* or sent it back for correction. The customer desk (Marketing included, via
* the `customers:get_notification` key) owns the follow-up with the customer,
* so the decision has to reach their inbox; without this it was silent, and
* only visible to whoever happened to reopen the customer's History tab.
*/
changeRequestReturned(
company: Company,
changeRequestId: string,
outcome: "rejected" | "changes_requested",
note: string,
reviewerName?: string | null,
): void {
const rejected = outcome === "rejected";
const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : "";
this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()}${company.id}`);
this.notifyStaff(
company,
rejected
? "Customer profile changes rejected"
: "Customer profile changes sent back for correction",
`${company.name}'s profile changes were ` +
`${rejected ? "rejected" : "sent back for correction"}${by}. ` +
`Reason: ${note}`,
{ changeRequestId, outcome, note, reviewerName: reviewerName ?? null },
);
}
// ── Customer-facing: a specific document needs correcting ──────────────────
/**

View File

@@ -23,8 +23,12 @@ export class ChangeRequestResponseDto {
documentChanges: DocumentChangeIntent[];
note: string | null;
submittedBy: string | null;
/** Who filed the request, for the history screen (null when unresolvable). */
submittedByName: string | null;
submittedAt: Date | null;
reviewedBy: string | null;
/** Who approved / rejected / sent it back. */
reviewedByName: string | null;
reviewedAt: Date | null;
createdAt: Date;
updatedAt: Date;
@@ -39,8 +43,10 @@ export class ChangeRequestResponseDto {
this.documentChanges = req.documents?.documentChanges ?? [];
this.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null;
this.submittedByName = req.submittedByName ?? null;
this.submittedAt = req.submittedAt ?? null;
this.reviewedBy = req.reviewedBy ?? null;
this.reviewedByName = req.reviewedByName ?? null;
this.reviewedAt = req.reviewedAt ?? null;
this.createdAt = req.createdAt;
this.updatedAt = req.updatedAt;

View File

@@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto {
id: string;
companyId: string;
actorId: string | null;
/** Who made the edit, for the history screen (null when unresolvable). */
actorName: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: Date;
@@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto {
this.id = revision.id;
this.companyId = revision.companyId;
this.actorId = revision.actorId ?? null;
this.actorName = revision.actorName ?? null;
this.summary = revision.summary;
this.changes = revision.changes ?? [];
this.createdAt = revision.createdAt;

View File

@@ -78,6 +78,14 @@ export class OnboardingRequirementsResponseDto {
*/
cooperative: boolean;
/**
* The company is a foreign investor on an investment licence: no eTrade
* record, so the registration was typed. The nationality document set still
* applies (it already asks for the investment licence itself), and so does
* the per-role business licence.
*/
investorLicence: boolean;
/** Required company-information fields and whether each is filled. */
companyInfo: {
complete: boolean;
@@ -116,6 +124,7 @@ export class OnboardingRequirementsResponseDto {
this.documentSettingCode = init.documentSettingCode;
this.nationality = init.nationality;
this.cooperative = init.cooperative;
this.investorLicence = init.investorLicence;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;

View File

@@ -2,7 +2,11 @@ import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company, isCooperative } from "../entities/company.entity";
import {
Company,
hasInvestorLicence,
isCooperative,
} from "../entities/company.entity";
import { ExternalProfile } from "../entities/external-profile.entity";
import {
ChangeRequestStatus,
@@ -21,6 +25,12 @@ export class ProfileResponseDto {
* it from eTrade.
*/
cooperative: boolean;
/**
* The company is a foreign investor on an investment licence: eTrade holds
* no record, so the company step collects the registration by hand. Drives
* the settings card that switches back to the eTrade route.
*/
investorLicence: boolean;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
@@ -93,6 +103,7 @@ export class ProfileResponseDto {
this.companyType = company.type;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.investorLicence = hasInvestorLicence(company);
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];

View File

@@ -3,6 +3,7 @@ import {
CompanyType,
CompanyStatus,
CompanyNationality,
hasInvestorLicence,
isCooperative,
} from '../entities/company.entity';
import {
@@ -62,6 +63,13 @@ export class ResponseCompanyDto {
* eTrade manager to check the owner against.
*/
cooperative: boolean;
/**
* The company onboarded as a foreign investor on an investment licence:
* eTrade holds no record for its TIN, so its registration below was typed by
* the customer rather than fetched — nothing here has been checked against a
* licence, and the reviewer is the check.
*/
investorLicence: boolean;
tin: string;
vatNumber?: string | null;
fanNumber?: string | null;
@@ -118,6 +126,7 @@ export class ResponseCompanyDto {
this.status = company.status;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.investorLicence = hasInvestorLicence(company);
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber;

View File

@@ -31,4 +31,15 @@ export class StartOnboardingDto {
@IsOptional()
@IsBoolean()
cooperative?: boolean;
/**
* The company is a foreign investor: it operates on an investment licence
* issued by the Ethiopian Investment Commission, so eTrade holds no record
* for its TIN and the registration is typed here instead. Chosen on the same
* step for the same reason as the co-operative flag — it decides what the
* company step asks for. Only a foreign company can hold one.
*/
@IsOptional()
@IsBoolean()
investorLicence?: boolean;
}

View File

@@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity {
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
reviewedAt?: Date | null;
/**
* Display names for {@link submittedBy} / {@link reviewedBy}, resolved from
* `iam.users` on read. Not columns — the history screen has to name the
* person who asked for the change, and an opaque uuid does not.
*/
submittedByName?: string | null;
reviewedByName?: string | null;
}

View File

@@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity {
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
changes!: CompanyRevisionChange[];
/**
* Display name for {@link actorId}, resolved from `iam.users` on read. Not a
* column — history has to name who made the edit, and a uuid does not.
*/
actorName?: string | null;
}

View File

@@ -51,6 +51,37 @@ export function isCooperative(
return company?.attributes?.[COOPERATIVE_KEY] === true;
}
/**
* `attributes` key marking a foreign company onboarding on an investment
* licence.
*
* The Ethiopian Investment Commission registers it, not the trade registry, so
* eTrade holds no record for its TIN: the registration is typed and the eTrade
* authenticity check is skipped rather than failed — exactly as for a
* co-operative. What does NOT change is the licence: the company still holds
* one per operational role, so that requirement stands.
*/
export const INVESTOR_LICENCE_KEY = "investorLicence";
/** Is this a foreign company registered on an investment licence? */
export function hasInvestorLicence(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return company?.attributes?.[INVESTOR_LICENCE_KEY] === true;
}
/**
* eTrade holds nothing for this company, so its registration was typed by hand
* rather than fetched — and the backoffice is told so. Two different companies
* reach it (a co-operative has no licence at all; a foreign investor's is not
* the trade registry's), and every consequence they share hangs off this.
*/
export function usesManualRegistration(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return isCooperative(company) || hasInvestorLicence(company);
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
ContractDocumentChange,
diffSnapshots,
@@ -20,28 +21,6 @@ export interface RecordRevisionInput {
stepId?: string | null;
}
/**
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
* plain `String(name)` there yields "[object Object]" in the audit trail.
*/
interface IamUserRow {
name?: Record<string, string> | string | null;
username?: string | null;
email?: string | null;
}
/** Best display name for a user row: English label → any locale → login → email. */
function pickUserName(user: IamUserRow): string | null {
const { name } = user;
if (typeof name === 'string' && name.trim()) return name.trim();
if (name && typeof name === 'object') {
const localized =
name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
if (localized?.trim()) return localized.trim();
}
return user.username?.trim() || user.email?.trim() || null;
}
/** Pre-computed changes (contract fields), rather than a document diff. */
export interface RecordChangesInput {
contractId: string;
@@ -120,23 +99,12 @@ export class ContractDocumentHistoryService {
private async resolveActorNames(
actorIds: string[],
): Promise<Map<string, string>> {
const resolved = new Map<string, string>();
const ids = [...new Set(actorIds.filter(Boolean))];
if (ids.length === 0) return resolved;
try {
const rows = (await this.dataSource.query(
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
[ids],
)) as Array<IamUserRow & { id: string }>;
for (const row of rows) {
const name = pickUserName(row);
if (name) resolved.set(row.id, name);
}
return await resolveIamUserNames(this.dataSource, actorIds);
} catch (err) {
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
return new Map();
}
return resolved;
}
/** Revision history for a contract, newest first. */

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator";
export class BulkCancelEimsItemDto {
@ApiProperty({ description: "Invoice ID to cancel." })
@IsUUID()
invoiceId!: string;
@ApiProperty({
description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).',
example: "1",
})
@IsString()
@Length(1, 8)
reasonCode!: string;
@ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" })
@IsOptional()
@IsString()
@Length(0, 500)
remark?: string;
}
/** `POST invoices/eims/bulk-cancel` body — see `EimsCancellationService.cancelBulkWithEims`. */
export class BulkCancelEimsRegistrationDto {
@ApiProperty({ type: [BulkCancelEimsItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BulkCancelEimsItemDto)
items!: BulkCancelEimsItemDto[];
}

View File

@@ -9,7 +9,9 @@ import { EimsApiException } from "./eims.errors";
import { EimsInvoiceStatus } from "./eims-registration.types";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const OTHER_IRN = "0af579eaef6f1e2d39fa77bd21cf8ecc64e26869275ae1c04eaa9ffea78b6c06";
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
@@ -153,3 +155,105 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
});
});
describe("EimsCancellationService.cancelBulkWithEims", () => {
it("cancels every eligible invoice in one call, matching results back by IRN", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
{ id: 2, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
],
});
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "6", remark: "x" },
]);
expect(postBearer).toHaveBeenCalledWith("/v1/bulkCancel", [
{ Irn: IRN, ReasonCode: "1", Remark: "" },
{ Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
]);
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
]);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
// Bulk success carries no cancellationDate at all, unlike single cancel.
expect(db.invoices.get(INVOICE_ID)?.eimsCancellationDate).toBeNull();
});
it("refuses an already-cancelled or never-registered invoice locally — never sent to MoR", async () => {
const db = new FakeDb([
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled }),
invoiceRow({ id: OTHER_INVOICE_ID, eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null }),
]);
const postBearer = jest.fn();
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(postBearer).not.toHaveBeenCalled();
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: false, message: expect.stringContaining("already cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: false, message: expect.stringContaining("never registered") },
]);
});
it("a mix of MoR success and rejection only updates the succeeding invoice", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
{ Status: "Processing_Error", msg: "IRN already Canceled.", Irn: OTHER_IRN },
],
});
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: false, message: "IRN already Canceled." },
]);
});
it("makes no HTTP call at all when every item fails the local eligibility check", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
const postBearer = jest.fn();
await build(db, postBearer).cancelBulkWithEims([{ invoiceId: INVOICE_ID, reasonCode: "1" }]);
expect(postBearer).not.toHaveBeenCalled();
});
it("notifies the buyer only for invoices that actually got cancelled", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockResolvedValue(undefined);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ status: "C", Irn: IRN },
{ Status: "Processing_Error", msg: "boom", Irn: OTHER_IRN },
],
});
await build(db, postBearer, directSend).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(directSend).toHaveBeenCalledTimes(1);
});
});

View File

@@ -6,7 +6,15 @@ import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsClientService } from "./eims-client.service";
import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
import {
EimsBulkCancelItemResult,
EimsBulkCancelRequest,
EimsBulkCancelResponse,
EimsCancelRequest,
EimsCancelResponse,
EimsInvoiceStatus,
EimsInvoiceStatusView,
} from "./eims-registration.types";
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
/**
@@ -92,6 +100,102 @@ export class EimsCancellationService {
return this.getEimsCancellationStatus(invoiceId);
}
/**
* `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the
* collection's own shape (an array in, an array of mixed success/error results back).
*
* Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything
* goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP
* call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible
* invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back
* immediately.
*
* ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch —
* same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across
* every item for the whole call isn't needed for correctness, only for avoiding a wasted call on
* an item that's already ineligible).
*/
async cancelBulkWithEims(
items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>,
): Promise<EimsBulkCancelItemResult[]> {
const results = new Map<string, EimsBulkCancelItemResult>();
const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = [];
for (const item of items) {
try {
const invoice = await this.dataSource.transaction(async (manager) => {
const inv = await this.lockInvoice(manager, item.invoiceId);
if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) {
throw new ConflictException(
`Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`,
);
}
if (!inv.eimsIrn) {
throw new BadRequestException(
`Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
);
}
return inv;
});
eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark });
} catch (err) {
results.set(item.invoiceId, {
invoiceId: item.invoiceId,
success: false,
message: (err as Error).message,
});
}
}
if (eligible.length > 0) {
const request: EimsBulkCancelRequest = eligible.map((e) => ({
Irn: e.invoice.eimsIrn!,
ReasonCode: e.reasonCode,
Remark: e.remark ?? "",
}));
// Outside any transaction — no DB lock held across the wire, same as single cancel.
const response = await this.client.postBearer<EimsBulkCancelRequest, EimsBulkCancelResponse>(
"/v1/bulkCancel",
request,
);
const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry]));
for (const { invoice, reasonCode, remark } of eligible) {
const entry = byIrn.get(invoice.eimsIrn!);
const failed = !entry || "Status" in entry;
if (failed) {
const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice.";
this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`);
results.set(invoice.id, { invoiceId: invoice.id, success: false, message });
continue;
}
await this.dataSource.transaction(async (manager) => {
const fresh = await this.lockInvoice(manager, invoice.id);
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return;
await manager.update(Invoice, invoice.id, {
eimsStatus: EimsInvoiceStatus.Cancelled,
eimsCancelledAt: new Date(),
// The bulk success shape carries no cancellationDate at all, unlike single cancel.
eimsCancellationDate: null,
eimsCancellationReasonCode: reasonCode,
eimsCancellationRemark: remark ?? null,
});
});
this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`);
await this.notifyBuyer(invoice);
results.set(invoice.id, {
invoiceId: invoice.id,
success: true,
message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`,
});
}
}
return items.map((item) => results.get(item.invoiceId)!);
}
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);

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

@@ -206,8 +206,10 @@ 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,

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,
);
/**
@@ -474,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

@@ -26,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,
@@ -83,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 {
@@ -125,27 +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,
documentType,
reason: invoice.eimsReason,
relatedDocument,
}),
);
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;
@@ -471,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,
@@ -478,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,
@@ -588,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" },

View File

@@ -5,6 +5,7 @@ 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 { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
@@ -89,6 +90,17 @@ export class EimsInvoiceController {
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
}
@Post("eims/bulk-cancel")
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
@ApiOperation({
summary:
"Cancel multiple invoices' registered EIMS documents in one call. Each invoice's outcome is " +
"reported independently — one failure never blocks the rest.",
})
bulkCancel(@Body() dto: BulkCancelEimsRegistrationDto) {
return this.cancellation.cancelBulkWithEims(dto.items);
}
@Post(":id/eims/receipt/sales")
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })

View File

@@ -89,6 +89,46 @@ export interface EimsCancelResponse {
body?: EimsCancelResponseBody;
}
/** `POST /v1/bulkCancel` — an array of the same `Irn`/`ReasonCode`/`Remark` shape as single cancel. */
export type EimsBulkCancelRequest = EimsCancelRequest[];
/**
* One element of a `/v1/bulkCancel` response array — MoR mixes success and error shapes in the same
* array, one entry per submitted IRN, disambiguated by `Status` (capital, error) vs `status`
* (lowercase, success — always `"C"`). Unlike single cancel, a bulk success carries no
* `cancellationDate` at all.
*/
export interface EimsBulkCancelSuccessItem {
id?: number;
tin?: string;
status: string;
mode?: string;
Irn: string;
ReasonCode?: string;
Remark?: string;
}
export interface EimsBulkCancelErrorItem {
Status: string;
msg: string;
Irn: string;
}
export type EimsBulkCancelResponseItem = EimsBulkCancelSuccessItem | EimsBulkCancelErrorItem;
export interface EimsBulkCancelResponse {
statusCode?: number;
message?: string;
body?: EimsBulkCancelResponseItem[];
}
/** One invoice's outcome from `cancelBulkWithEims` — local eligibility failure or MoR's own result. */
export interface EimsBulkCancelItemResult {
invoiceId: string;
success: boolean;
message: string;
}
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError {
kind: string;

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

@@ -4,6 +4,7 @@ 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";
@@ -14,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";
@@ -33,6 +35,10 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
// 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: [
@@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
EimsAutoSubmitService,
EimsCancellationService,
EimsReceiptService,
EimsSellerCacheService,
],
exports: [
EimsAuthService,

View File

@@ -4,6 +4,7 @@ import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entit
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { BackofficeModule } from "../backoffice/backoffice.module";
import { ChatModule } from "../chat/chat.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { Notification } from "./entities/notification.entity";
@@ -24,6 +25,8 @@ import { WsAuthService } from "./ws-auth.service";
BackofficeModule,
// EmailClientService + SmsClientService (HIGH-priority fan-out)
NotificationsModule,
// ChatBridgeService (mirrors BACKOFFICE notifications into chat)
ChatModule,
],
controllers: [NotificationInboxController],
providers: [

View File

@@ -1,4 +1,5 @@
import {
NotificationAudience,
NotificationChannels,
NotificationChannelsSent,
NotificationDto,
@@ -11,6 +12,7 @@ import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { ChatBridgeService } from "../chat/chat-bridge.service";
import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service";
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
@@ -37,6 +39,7 @@ export class NotificationInboxService {
private readonly gateway: NotificationsGateway,
private readonly emailClient: EmailClientService,
private readonly smsClient: SmsClientService,
private readonly chatBridge: ChatBridgeService,
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
@@ -44,11 +47,18 @@ export class NotificationInboxService {
/**
* Fan a logical notification out to every resolved recipient: persist one row
* each, push it live over WebSocket, and (for HIGH priority) also queue
* email/SMS via the existing clients.
* email/SMS via the existing clients. BACKOFFICE-audience notifications are
* also mirrored into internal chat (ChatBridgeService) — a shared-room
* broadcast, not per-recipient, so it runs once regardless of how many (if
* any) in-app rows get created below. Never PORTAL — that's customer-facing
* and must never reach a staff room.
*/
async notify(input: NotifyInput): Promise<void> {
try {
const userIds = await this.recipients.resolve(input.recipients);
if (input.audience === NotificationAudience.BACKOFFICE) {
await this.chatBridge.bridge(input);
}
if (userIds.length === 0) {
this.logger.debug(
`notify(${input.type}) resolved 0 recipients — skipped`,

View File

@@ -0,0 +1,85 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { StaffReference } from '../../../common/booking-guards';
import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import {
ListYardPositionsQueryDto,
SetPositionYardsDto,
SetYardPositionsDto,
} from '../dto/yard-positions.dto';
import { YardPositionsService } from '../services/yard-positions.service';
import { YardScopeService } from '../services/yard-scope.service';
/**
* Desk↔yard mapping — which positions ("departments" in the user-management
* tree) staff which yard. It is yard configuration, so it is gated by the same
* rule-engine yard keys as the rest of the yards screen.
*
* Writes REPLACE the whole set for the side being edited. The admin UI submits
* the full multi-select value; a caller sending a delta will drop everything it
* omits. Both write paths flush the scope resolver's cache so a mapping change
* takes effect on the next request instead of up to a minute later.
*/
@ApiTags('yard-positions')
@Controller('yard-positions')
@ApiBearerAuth()
export class YardPositionsController {
constructor(
private readonly service: YardPositionsService,
private readonly scope: YardScopeService,
) {}
@Get()
@RuleEngineView('yards')
@ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' })
list(@Query() query: ListYardPositionsQueryDto) {
return this.service.list(query);
}
@Get('positions')
@RuleEngineView('yards')
@ApiOperation({ summary: 'Positions selectable as yard desks' })
listPositions() {
return this.service.listSelectablePositions();
}
@Get('my-yards')
// Any signed-in staff member, NOT gated on the yards keys: this returns the
// caller's own access and nothing else, and the frontend needs it to
// preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every
// desk that does not administer yards — i.e. exactly the users it is for.
@StaffReference()
@ApiOperation({
summary: "The caller's own yard scope (null yardIds = unrestricted)",
})
async myYards(@CurrentUser() user: unknown) {
const yardIds = await this.scope.getScopedYardIds(user as never);
return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced };
}
@Put('yard/:yardId')
@RuleEngineUpdate('yards')
@ApiOperation({ summary: "Replace a yard's whole position set" })
async setPositionsForYard(
@Param('yardId', ParseUUIDPipe) yardId: string,
@Body() dto: SetYardPositionsDto,
) {
const rows = await this.service.setPositionsForYard(yardId, dto.positionIds);
this.scope.invalidate();
return rows;
}
@Put('position/:positionId')
@RuleEngineUpdate('yards')
@ApiOperation({ summary: "Replace a position's whole yard set" })
async setYardsForPosition(
@Param('positionId', ParseUUIDPipe) positionId: string,
@Body() dto: SetPositionYardsDto,
) {
const rows = await this.service.setYardsForPosition(positionId, dto.yardIds);
this.scope.invalidate();
return rows;
}
}

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsUUID } from 'class-validator';
export class ListYardPositionsQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
positionId?: string;
}
/** Replaces the yard's whole position set — see the controller's PUT docs. */
export class SetYardPositionsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@IsUUID('4', { each: true })
positionIds!: string[];
}
/** Replaces the position's whole yard set. */
export class SetPositionYardsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@IsUUID('4', { each: true })
yardIds!: string[];
}

View File

@@ -0,0 +1,28 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from './yard.entity';
/**
* One desk staffed at one yard.
*
* The pairing that yard access scoping resolves against: a caller's active
* position decides which yards they may touch. Position rows live in `iam`
* (`iam.positions` — what the user-management tree labels "departments"), so
* `positionId` is an unconstrained uuid by design; see the migration for why.
*/
@Entity({ schema: 'freight', name: 'yard_positions' })
@Index(['yardId'])
@Index(['positionId'])
export class YardPosition extends BaseEntity {
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
/** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */
@Column({ name: 'position_id', type: 'uuid' })
positionId!: string;
}

View File

@@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { YardDistancesController } from './controllers/yard-distances.controller';
import { YardsController } from './controllers/yards.controller';
import { YardPositionsController } from './controllers/yard-positions.controller';
import { ApprovalRule } from './entities/approval-rule.entity';
import { CargoType } from './entities/cargo-type.entity';
@@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity';
import { YardDistance } from './entities/yard-distance.entity';
import { YardFacility } from './entities/yard-facility.entity';
import { YardLocation } from './entities/yard-location.entity';
import { YardPosition } from './entities/yard-position.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
@@ -65,6 +67,8 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { YardsService } from './services/yards.service';
import { YardDistancesService } from './services/yard-distances.service';
import { YardFacilitiesService } from './services/yard-facilities.service';
import { YardPositionsService } from './services/yard-positions.service';
import { YardScopeService } from './services/yard-scope.service';
import { RuleEngineService } from './rule-engine.service';
@@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
YardDistance,
YardFacility,
YardLocation,
YardPosition,
ShippingLine,
Rate,
ApprovalRule,
@@ -116,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceTypesController,
WeightLimitRulesController,
YardsController,
YardPositionsController,
YardDistancesController,
ShippingLinesController,
RatesController,
@@ -152,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
YardsService,
YardDistancesService,
YardFacilitiesService,
YardPositionsService,
YardScopeService,
ShippingLinesService,
RatesService,
ApprovalRulesService,
@@ -168,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
YardsService,
YardDistancesService,
YardFacilitiesService,
YardPositionsService,
// Exported so any module can narrow its yard queries through the one
// resolver — the module is @Global, so no import is needed to inject it.
YardScopeService,
ShippingLinesService,
RatesService,
ApprovalRulesService,

View File

@@ -0,0 +1,194 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm';
import { YardPosition } from '../entities/yard-position.entity';
import { Yard } from '../entities/yard.entity';
/** A mapped desk, joined to its IAM position for display. */
export interface YardPositionRow {
id: string;
yardId: string;
yardCode: string;
yardLabel: string;
positionId: string;
/** Localised name from `iam.positions.name` — null if the position is gone. */
positionName: { am?: string; en?: string } | null;
positionTypeKey: string | null;
}
/**
* The desk↔yard mapping behind yard access scoping.
*
* Reads always join `iam.positions` and drop soft-deleted rows: the mapping has
* no FK to IAM (see the migration), so a position deleted in the admin UI leaves
* an orphan row here. Dropping it on read means the orphan can never widen
* someone's scope — it just disappears.
*/
@Injectable()
export class YardPositionsService {
constructor(private readonly dataSource: DataSource) {}
/** Mapping rows, optionally narrowed to one yard or one position. */
async list(filter: {
yardId?: string;
positionId?: string;
}): Promise<YardPositionRow[]> {
const params: unknown[] = [];
const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL'];
if (filter.yardId) {
params.push(filter.yardId);
where.push(`yp.yard_id = $${params.length}`);
}
if (filter.positionId) {
params.push(filter.positionId);
where.push(`yp.position_id = $${params.length}`);
}
return this.dataSource.query(
`SELECT yp.id AS "id",
yp.yard_id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
yp.position_id AS "positionId",
p.name AS "positionName",
pt.key AS "positionTypeKey"
FROM freight.yard_positions yp
JOIN freight.yards y ON y.id = yp.yard_id
-- INNER join: a mapping whose position was deleted grants nothing and
-- is not shown. The row stays for audit until someone re-saves the set.
JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL
LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
WHERE ${where.join(' AND ')}
ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`,
params,
);
}
/**
* Replace the yard's entire position set.
*
* Replace, not append — the admin UI submits the full multi-select value, so a
* partial payload would silently keep desks the user just unticked. Callers
* sending a delta will remove everything they omit.
*/
async setPositionsForYard(
yardId: string,
positionIds: string[],
): Promise<YardPositionRow[]> {
await this.assertYardExists(yardId);
await this.assertPositionsExist(positionIds);
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(YardPosition);
await repo.delete({ yardId });
if (positionIds.length) {
await repo.insert(
[...new Set(positionIds)].map((positionId) => ({ yardId, positionId })),
);
}
});
return this.list({ yardId });
}
/** Replace the position's entire yard set. Same replace semantics. */
async setYardsForPosition(
positionId: string,
yardIds: string[],
): Promise<YardPositionRow[]> {
await this.assertPositionsExist([positionId]);
await this.assertYardsExist(yardIds);
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(YardPosition);
await repo.delete({ positionId });
if (yardIds.length) {
await repo.insert(
[...new Set(yardIds)].map((yardId) => ({ yardId, positionId })),
);
}
});
return this.list({ positionId });
}
/**
* Positions offered by the mapping picker.
*
* Reads `iam.positions` directly rather than going through IAM's
* `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit
* first, and the picker wants every desk that could staff a yard regardless of
* which unit it hangs under.
*/
async listSelectablePositions(): Promise<
Array<{
id: string;
name: { am?: string; en?: string } | null;
positionTypeKey: string | null;
unitKey: string | null;
}>
> {
return this.dataSource.query(
`SELECT p.id AS "id",
p.name AS "name",
pt.key AS "positionTypeKey",
u.key AS "unitKey"
FROM iam.positions p
LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
LEFT JOIN iam.units u ON u.id = p.unit_id
WHERE p.deleted_at IS NULL
ORDER BY p.name->>'en' ASC`,
);
}
/** Yard ids mapped to any of these positions — the scope resolver's read. */
async yardIdsForPositions(positionIds: string[]): Promise<string[]> {
if (!positionIds.length) return [];
const rows: { yardId: string }[] = await this.dataSource.query(
`SELECT DISTINCT yp.yard_id AS "yardId"
FROM freight.yard_positions yp
JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL
WHERE yp.deleted_at IS NULL
AND yp.position_id = ANY($1)`,
[positionIds],
);
return rows.map((r) => r.yardId);
}
private async assertYardExists(yardId: string): Promise<void> {
const yard = await this.dataSource
.getRepository(Yard)
.findOne({ where: { id: yardId, deletedAt: IsNull() } });
if (!yard) throw new NotFoundException(`Yard ${yardId} not found`);
}
private async assertYardsExist(yardIds: string[]): Promise<void> {
if (!yardIds.length) return;
const found = await this.dataSource
.getRepository(Yard)
.count({ where: { id: In(yardIds), deletedAt: IsNull() } });
if (found !== new Set(yardIds).size) {
throw new BadRequestException('One or more yards do not exist');
}
}
/**
* Validated in the service because the database cannot: there is no FK to
* `iam.positions`, so an unchecked payload would happily store a typo'd uuid
* that silently grants nothing and reads as a configuration bug later.
*/
private async assertPositionsExist(positionIds: string[]): Promise<void> {
if (!positionIds.length) return;
const unique = [...new Set(positionIds)];
const rows: { count: string }[] = await this.dataSource.query(
`SELECT COUNT(*)::text AS count
FROM iam.positions
WHERE id = ANY($1) AND deleted_at IS NULL`,
[unique],
);
if (Number(rows[0]?.count ?? 0) !== unique.length) {
throw new BadRequestException('One or more positions do not exist');
}
}
}

View File

@@ -0,0 +1,139 @@
import { ForbiddenException } from '@nestjs/common';
import { YardScopeService } from './yard-scope.service';
/**
* The resolver answers "which yards", never "may they act at all" — that stays
* with the permission guard. So a mapped desk is narrowed to its yards, and an
* unmapped one keeps the reach its permissions already gave it.
*/
describe('YardScopeService', () => {
const yardIdsForPositions = jest.fn();
const service = () =>
new YardScopeService({ yardIdsForPositions } as never);
const staff = (positionId: string, permissions: string[] = []) => ({
roles: [{ key: 'staff' }],
permissions: permissions.map((key) => ({ key })),
employee: { position: { id: positionId, permissions: [] } },
});
beforeEach(() => {
jest.clearAllMocks();
delete process.env.YARD_SCOPE_ENFORCE;
});
it('resolves a mapped position to its yards', async () => {
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
const scope = await service().getScopedYardIds(staff('pos-officer'));
expect(scope).toEqual(['yard-kality', 'yard-mojo']);
expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']);
});
it('leaves an unmapped position unrestricted — permissions still gate the action', async () => {
yardIdsForPositions.mockResolvedValue([]);
expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull();
});
it('leaves a caller with no resolvable position unrestricted', async () => {
const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } };
expect(await service().getScopedYardIds(noPosition)).toBeNull();
expect(yardIdsForPositions).not.toHaveBeenCalled();
});
it('narrows nothing for an anonymous caller but grants nothing either', async () => {
expect(await service().getScopedYardIds(null)).toEqual([]);
});
it('returns unrestricted only for super admins and view_all holders', async () => {
const superAdmin = { roles: [{ key: 'super_admin' }] };
const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']);
expect(await service().getScopedYardIds(superAdmin)).toBeNull();
expect(await service().getScopedYardIds(hqDesk)).toBeNull();
expect(yardIdsForPositions).not.toHaveBeenCalled();
});
it('includes delegated positions — standing in must not lose the yard', async () => {
yardIdsForPositions.mockResolvedValue(['yard-kality']);
await service().getScopedYardIds({
roles: [{ key: 'staff' }],
employee: {
position: { id: 'pos-own' },
delegatedPositions: [{ id: 'pos-gelan-director' }],
},
});
expect(yardIdsForPositions).toHaveBeenCalledWith([
'pos-own',
'pos-gelan-director',
]);
});
describe('listFilterYardIds', () => {
it('narrows nothing while shadow-logging', async () => {
yardIdsForPositions.mockResolvedValue(['yard-kality']);
expect(
await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
).toBeNull();
});
it('narrows to the mapped yards once enforcing', async () => {
process.env.YARD_SCOPE_ENFORCE = 'true';
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
expect(
await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
).toEqual(['yard-kality', 'yard-mojo']);
});
it('keeps an in-scope yard filter as the caller asked', async () => {
process.env.YARD_SCOPE_ENFORCE = 'true';
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
expect(
await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'),
).toEqual(['yard-mojo']);
});
it('returns an empty set — not everything — for an out-of-scope yard filter', async () => {
process.env.YARD_SCOPE_ENFORCE = 'true';
yardIdsForPositions.mockResolvedValue(['yard-kality']);
expect(
await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'),
).toEqual([]);
});
it('never narrows an unmapped desk', async () => {
process.env.YARD_SCOPE_ENFORCE = 'true';
yardIdsForPositions.mockResolvedValue([]);
expect(
await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'),
).toBeNull();
});
});
it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => {
yardIdsForPositions.mockResolvedValue(['yard-kality']);
const shadow = service();
await expect(
shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
).resolves.toBeUndefined();
process.env.YARD_SCOPE_ENFORCE = 'true';
const enforcing = service();
await expect(
enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -0,0 +1,186 @@
import { ForbiddenException, Injectable, Logger } from "@nestjs/common";
import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util";
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
import { YardPositionsService } from "./yard-positions.service";
/**
* Caller shape the resolver reads — the `/auth/me` user in either of its two
* shapes. Structurally compatible with what `freight-permission.util` accepts,
* so the same object serves both the permission checks and the position walk.
*/
type PositionLike = {
id?: string;
permissions?: { key?: string }[];
positionType?: { key?: string } | null;
};
type ScopeUser = {
roles?: { key?: string }[];
permissions?: { key?: string }[];
employee?:
| {
position?: PositionLike;
delegatedPositions?: PositionLike[];
}
| { positions?: PositionLike[] }[]
| null;
};
/**
* Which yards a caller may touch.
*
* Scope follows the caller's ACTIVE position, not a union of every position they
* have ever held: the frontends already send `x-current-position-id` and the
* token snapshots that one position, so switching desks switches yards — which
* is what staff covering two yards actually do. Delegated positions are added on
* top, otherwise standing in for the Gelan director silently loses Gelan.
*
* `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a
* desk that has been given yards; it does not hand out access. Whether the
* caller may perform the action at all is the permission guard's job — this
* resolver only answers "which yards", so a desk with the permission and no
* mapping keeps the reach it had before the mapping existed.
*
* The trade-off is deliberate and worth knowing: an accidentally-cleared
* mapping widens access rather than blocking work, so the mapping is not a
* containment barrier on its own — the permission keys still are. Super admins
* and holders of `yards:view_all` are unrestricted regardless of mapping.
*
* ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then
* {@link assertYardInScope} logs what it would have blocked and returns. Flip it
* only once the mapping table is populated and the log is quiet — on an empty
* table, enforcing locks out every staff member at once.
*/
@Injectable()
export class YardScopeService {
private readonly logger = new Logger(YardScopeService.name);
// ponytail: 60s cache keyed by the position-id set, no invalidation hook. A
// mapping change takes up to a minute to reach the resolver. Call
// `invalidate()` from the mutation if that lag ever matters.
private static readonly CACHE_TTL_MS = 60_000;
private readonly cache = new Map<string, { yardIds: string[]; at: number }>();
constructor(private readonly yardPositions: YardPositionsService) {}
/** True when the deny path is live; false while shadow-logging. */
get enforced(): boolean {
return process.env.YARD_SCOPE_ENFORCE === "false";
}
/** Yard ids the caller is scoped to, or `null` for unrestricted. */
async getScopedYardIds(user: ScopeUser | null | undefined): Promise<string[] | null> {
// No user at all is an unauthenticated call the guards should already have
// rejected — narrow to nothing rather than trusting it.
if (!user) return [];
if (isSuperAdmin(user)) return null;
if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null;
const positionIds = this.effectivePositionIds(user);
// No resolvable position — nothing to narrow by, so nothing is narrowed.
if (!positionIds.length) return null;
const key = positionIds.join(",");
const hit = this.cache.get(key);
if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) {
return hit.yardIds.length ? hit.yardIds : null;
}
const yardIds = await this.yardPositions.yardIdsForPositions(positionIds);
this.cache.set(key, { yardIds, at: Date.now() });
// Unmapped desk → unrestricted. Mapping narrows; absence of one does not.
return yardIds.length ? yardIds : null;
}
async isYardInScope(
user: ScopeUser | null | undefined,
yardId: string | null | undefined,
): Promise<boolean> {
if (!yardId) return true;
const scope = await this.getScopedYardIds(user);
return scope === null || scope.includes(yardId);
}
/**
* Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only
* logs — wire it into write paths first and read filters second, so the
* shadow log shows what enforcement would break before it breaks it.
*/
async assertYardInScope(
user: ScopeUser | null | undefined,
yardId: string | null | undefined,
context: string,
): Promise<void> {
if (await this.isYardInScope(user, yardId)) return;
const positions = this.effectivePositionIds(user).join(",") || "none";
if (!this.enforced) {
this.logger.warn(
`[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`,
);
return;
}
throw new ForbiddenException("This yard is outside your assigned yards");
}
/**
* Yard ids a list query should be narrowed to, or `null` for no narrowing.
*
* Returns an EMPTY array only when the caller explicitly asked for a yard
* outside their scope and enforcement is on — the caller should answer with an
* empty result rather than silently widening back to everything.
*
* While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what
* it would have narrowed, so the mapping can be populated against real traffic
* before it starts hiding rows.
*/
async listFilterYardIds(
user: ScopeUser | null | undefined,
requestedYardId: string | null | undefined,
context: string,
): Promise<string[] | null> {
const scope = await this.getScopedYardIds(user);
if (scope === null) return null;
const outOfScope = !!requestedYardId && !scope.includes(requestedYardId);
if (!this.enforced) {
this.logger.warn(
`[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` +
(outOfScope ? ` and reject yard=${requestedYardId}` : ""),
);
return null;
}
if (outOfScope) return [];
return requestedYardId ? [requestedYardId] : scope;
}
/** Drops the memoised scopes — call after editing the mapping. */
invalidate(): void {
this.cache.clear();
}
/** Active position plus any delegated ones, across both `employee` shapes. */
private effectivePositionIds(user: ScopeUser | null | undefined): string[] {
const ids = new Set<string>();
const employee = user?.employee;
if (!employee) return [];
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const position of emp.positions ?? []) {
if (position?.id) ids.add(position.id);
}
}
return [...ids];
}
if (employee.position?.id) ids.add(employee.position.id);
for (const delegated of employee.delegatedPositions ?? []) {
if (delegated?.id) ids.add(delegated.id);
}
return [...ids];
}
}

View File

@@ -38,15 +38,21 @@ export class WarehouseInventoryController {
@Get()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List warehouse inventory' })
findAll(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findAll(filter);
findAll(
@Query() filter: FilterWarehouseInventoryDto,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.findAll(filter, user);
}
@Get('ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List inventory ready for loading' })
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findReadyForLoading(filter);
findReadyForLoading(
@Query() filter: FilterWarehouseInventoryDto,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.findReadyForLoading(filter, user);
}
@Get('inquiry')

View File

@@ -39,6 +39,7 @@ import {
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -423,6 +424,7 @@ export class WarehouseInventoryService {
private readonly events: EventEmitter2,
private readonly stampSettings: StampSettingsService,
private readonly logoSettings: LogoSettingsService,
private readonly yardScope: YardScopeService,
) {}
/**
@@ -978,7 +980,16 @@ export class WarehouseInventoryService {
// ── Listing ────────────────────────────────────────────────────────────
async findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
/**
* `user` drives yard access scoping: a desk mapped to yards sees only those
* yards' inventory. Optional so internal callers that are not serving a
* request (schedulers, other services) are unaffected — they pass nothing and
* get the unscoped list, which is what they had before.
*/
async findAll(
filter: FilterWarehouseInventoryDto,
user?: unknown,
): Promise<WarehouseInventory[]> {
const createdAt =
filter.dateFrom && filter.dateTo
? Between(new Date(filter.dateFrom), new Date(filter.dateTo))
@@ -1020,6 +1031,32 @@ export class WarehouseInventoryService {
});
}
// Yard scoping — applied to `base` before the search branch splits it, so
// both OR arms carry the constraint. A null result means "do not narrow".
//
// Scoped on `warehouse.stationId`, NOT on `inventory.yardId`: those are two
// different id spaces that share a name. `warehouse_inventory.yard_id` is a
// FK to `warehouse_yards` — a yard INSIDE a warehouse — while the desk↔yard
// mapping is against `freight.yards`, the network yard, which inventory
// reaches through `warehouses.station_id`. Filtering `yardId` against
// mapped network yards matches nothing and hides every row (observed: all
// 34 rows disappeared before this was corrected).
//
// `filter.yardId` is likewise a warehouse-yard id, so it is NOT passed as
// the requested yard here; `filter.facilityId` is the station-yard filter.
const scopedYardIds = await this.yardScope.listFilterYardIds(
user as never,
filter.facilityId,
'warehouse-inventory list',
);
if (scopedYardIds) {
if (!scopedYardIds.length) return [];
base.warehouse = {
...((base.warehouse as FindOptionsWhere<Warehouse>) ?? {}),
stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds),
};
}
const search = filter.search?.trim();
const where: FindManyOptions<WarehouseInventory>['where'] = search
? [
@@ -1037,8 +1074,11 @@ export class WarehouseInventoryService {
return items;
}
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
findReadyForLoading(
filter: FilterWarehouseInventoryDto,
user?: unknown,
): Promise<WarehouseInventory[]> {
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user);
}
/**