mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
Merge branch 'freight/nati-2' into freight/feat/element-chat
This commit is contained in:
@@ -92,7 +92,7 @@ export class BillingController {
|
||||
@Get("offline-usd")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
|
||||
"Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context",
|
||||
})
|
||||
findOfflineUsd(@Query() query: FilterInvoiceDto) {
|
||||
return this.billingService.findOfflineUsdPaginated(query);
|
||||
@@ -104,7 +104,7 @@ export class BillingController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
|
||||
"Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance",
|
||||
})
|
||||
confirmOffline(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||
@@ -48,10 +49,18 @@ export interface PayInvoiceOptions {
|
||||
export interface OfflineUsdBookingInfo {
|
||||
id: string;
|
||||
reference: string;
|
||||
tradeDirection: string | null;
|
||||
paymentDeadline: Date | null;
|
||||
paymentStatus: string;
|
||||
}
|
||||
|
||||
/** Row shape of the manual-payments worklist. */
|
||||
export type OfflineUsdInvoiceRow = Invoice & {
|
||||
booking: OfflineUsdBookingInfo | null;
|
||||
/** Shipping-line credit invoices span many bookings — one entry per credit. */
|
||||
bookings: { id: string; reference: string; tradeDirection: string | null }[];
|
||||
};
|
||||
|
||||
/** A single manual/offline settlement to record against an invoice. */
|
||||
export interface RecordPaymentInput {
|
||||
/** Amount settled by this payment; must be > 0. */
|
||||
@@ -340,22 +349,23 @@ export class BillingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway), open ones by default or a single status when
|
||||
* filtered. Booking-sourced rows carry the booking's reference and pay-window
|
||||
* deadline so the UI can show the countdown and link to the booking.
|
||||
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway) and ETB invoices Finance settles by hand (bank
|
||||
* transfer / counter) instead of the customer paying online. Open ones by
|
||||
* default or a single status when filtered; both currencies unless
|
||||
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
|
||||
* trade direction and pay-window deadline so the UI can show the countdown
|
||||
* and link to the booking.
|
||||
*/
|
||||
async findOfflineUsdPaginated(
|
||||
filter: {
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{
|
||||
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
|
||||
total: number;
|
||||
}> {
|
||||
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
@@ -364,11 +374,16 @@ export class BillingService {
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) = 'USD'")
|
||||
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.currency) {
|
||||
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
||||
currency: filter.currency,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
@@ -381,7 +396,8 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
const [rawItems, total] = await qb.getManyAndCount();
|
||||
const items = await this.attachShippingLineCompanies(rawItems);
|
||||
|
||||
const bookingIds = items
|
||||
.filter((i) => i.source === "booking")
|
||||
@@ -389,11 +405,43 @@ export class BillingService {
|
||||
const bookings = bookingIds.length
|
||||
? await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(bookingIds) },
|
||||
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
|
||||
select: [
|
||||
"id",
|
||||
"reference",
|
||||
"tradeDirection",
|
||||
"paymentDeadline",
|
||||
"paymentStatus",
|
||||
],
|
||||
})
|
||||
: [];
|
||||
const byId = new Map(bookings.map((b) => [b.id, b]));
|
||||
|
||||
// Shipping-line credit invoices bill many bookings at once; each credit
|
||||
// keeps its own booking link, so collect them per invoice.
|
||||
const creditInvoiceIds = items
|
||||
.filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit)
|
||||
.map((i) => i.id);
|
||||
const credits = creditInvoiceIds.length
|
||||
? await this.dataSource.getRepository(ShippingLineCredit).find({
|
||||
where: { invoiceId: In(creditInvoiceIds) },
|
||||
relations: { booking: true },
|
||||
})
|
||||
: [];
|
||||
const bookingsByInvoice = new Map<
|
||||
string,
|
||||
OfflineUsdInvoiceRow["bookings"]
|
||||
>();
|
||||
for (const c of credits) {
|
||||
if (!c.invoiceId || !c.booking) continue;
|
||||
const list = bookingsByInvoice.get(c.invoiceId) ?? [];
|
||||
list.push({
|
||||
id: c.booking.id,
|
||||
reference: c.booking.reference,
|
||||
tradeDirection: c.booking.tradeDirection ?? null,
|
||||
});
|
||||
bookingsByInvoice.set(c.invoiceId, list);
|
||||
}
|
||||
|
||||
return {
|
||||
items: items.map((inv) => {
|
||||
const b = byId.get(inv.sourceId);
|
||||
@@ -403,19 +451,22 @@ export class BillingService {
|
||||
? {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
tradeDirection: b.tradeDirection ?? null,
|
||||
paymentDeadline: b.paymentDeadline ?? null,
|
||||
paymentStatus: b.paymentStatus,
|
||||
}
|
||||
: null,
|
||||
} as Invoice & { booking: OfflineUsdBookingInfo | null };
|
||||
bookings: bookingsByInvoice.get(inv.id) ?? [],
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
|
||||
* against the invoice and settles the FULL outstanding balance through
|
||||
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
|
||||
* or counter payment: stores the slip against the invoice and settles the
|
||||
* FULL outstanding balance through
|
||||
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||
* the booking advances exactly as if it had been paid through the gateway.
|
||||
@@ -434,11 +485,6 @@ export class BillingService {
|
||||
): Promise<Invoice> {
|
||||
const invoice = await this.invoices.findById(invoiceId);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
if (invoice.currency?.toUpperCase() !== "USD") {
|
||||
throw new BadRequestException(
|
||||
"Offline confirmation is only for USD invoices — this invoice is paid online.",
|
||||
);
|
||||
}
|
||||
if (!file) {
|
||||
throw new BadRequestException("The bank payment slip file is required.");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -39,4 +39,11 @@ export class FilterInvoiceDto {
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(Freight.InvoiceStatus))
|
||||
status?: Freight.InvoiceStatus;
|
||||
|
||||
/** Manual-payments worklist only: restrict to one currency. */
|
||||
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["USD", "ETB"])
|
||||
currency?: "USD" | "ETB";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -335,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => {
|
||||
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
|
||||
});
|
||||
|
||||
it("derives City from the buyer's zone via the city code map", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
|
||||
seller,
|
||||
context({ buyerCityCodes: { Kirkos: "101" } }),
|
||||
);
|
||||
expect(doc.BuyerDetails.City).toBe("101");
|
||||
});
|
||||
|
||||
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
|
||||
seller,
|
||||
context({ buyerCityCodes: {} }),
|
||||
);
|
||||
expect(doc.BuyerDetails.City).toBeNull();
|
||||
});
|
||||
|
||||
it("maps a buyer country name to its code via the country code map", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
|
||||
seller,
|
||||
context({ buyerCountryCodes: { Djibouti: "071" } }),
|
||||
);
|
||||
expect(doc.BuyerDetails.Country).toBe("071");
|
||||
});
|
||||
|
||||
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
|
||||
const doc = toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
|
||||
seller,
|
||||
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
|
||||
);
|
||||
expect(doc.BuyerDetails.Country).toBe("231");
|
||||
});
|
||||
|
||||
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
|
||||
seller,
|
||||
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
|
||||
),
|
||||
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
|
||||
});
|
||||
|
||||
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
||||
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
||||
|
||||
@@ -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,
|
||||
@@ -440,7 +505,16 @@ export function toEimsInvoice(
|
||||
|
||||
return {
|
||||
BuyerDetails: {
|
||||
City: context.buyerCity ?? null,
|
||||
// No dedicated city column on Company — Zone is the closest match; optional (see
|
||||
// resolveLocationCode's City comment).
|
||||
City: resolveLocationCode(
|
||||
"City",
|
||||
company.zone,
|
||||
context.buyerCityCodes,
|
||||
"EIMS_BUYER_CITY_CODES",
|
||||
invoice.invoiceNumber,
|
||||
{ required: false },
|
||||
),
|
||||
Email: company.email ?? null,
|
||||
HouseNumber: company.houseNo ?? null,
|
||||
IdNumber: context.buyerIdNumber ?? null,
|
||||
@@ -455,7 +529,12 @@ export function toEimsInvoice(
|
||||
"EIMS_BUYER_REGION_CODES",
|
||||
invoice.invoiceNumber,
|
||||
),
|
||||
Country: context.buyerCountryCode ?? null,
|
||||
Country: resolveCountryCode(
|
||||
company.country,
|
||||
context.buyerCountryCodes,
|
||||
context.buyerCountryCode ?? null,
|
||||
invoice.invoiceNumber,
|
||||
),
|
||||
Zone: company.zone ?? null,
|
||||
Kebele: company.kebele ?? null,
|
||||
VatNumber: company.vatNumber ?? null,
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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";
|
||||
@@ -1141,16 +1142,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 +1559,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 +1597,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 +1613,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);
|
||||
|
||||
@@ -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 ──────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ function makeService(overrides?: {
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
isDeliveryOrderFileCode,
|
||||
@@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
@@ -155,6 +157,7 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
) {}
|
||||
|
||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||
@@ -988,7 +991,39 @@ export class BookingClearanceService {
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
||||
}
|
||||
return filtered;
|
||||
return this.attachContractSummary(filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue rows show the parent contract's reference and lane. Booking has no
|
||||
* contract relation, and a bare initiated instance may not carry yards yet —
|
||||
* so batch-load the contracts (with routes) and fill in what's missing:
|
||||
* `contractReference` always, origin/destination yards only when the booking
|
||||
* lacks them (its own route wins).
|
||||
*/
|
||||
private async attachContractSummary(bookings: Booking[]): Promise<Booking[]> {
|
||||
const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[];
|
||||
if (!ids.length) return bookings;
|
||||
const contracts = await this.contractsRepository.findAll({
|
||||
where: { id: In(ids) },
|
||||
relations: { routes: { originYard: true, destinationYard: true } },
|
||||
});
|
||||
const byId = new Map(contracts.map((c) => [c.id, c]));
|
||||
for (const b of bookings) {
|
||||
const contract = b.contractId ? byId.get(b.contractId) : undefined;
|
||||
if (!contract) continue;
|
||||
const row = b as Booking & { contractReference?: string | null };
|
||||
row.contractReference = contract.reference ?? null;
|
||||
if (b.originYard && b.destinationYard) continue;
|
||||
const routes = contract.routes ?? [];
|
||||
const route =
|
||||
routes.find((r) => r.id === b.contractRouteId) ??
|
||||
(routes.length === 1 ? routes[0] : undefined);
|
||||
if (!route) continue;
|
||||
b.originYard = b.originYard ?? route.originYard;
|
||||
b.destinationYard = b.destinationYard ?? route.destinationYard;
|
||||
}
|
||||
return bookings;
|
||||
}
|
||||
|
||||
async djQueue(): Promise<Booking[]> {
|
||||
@@ -1008,6 +1043,6 @@ export class BookingClearanceService {
|
||||
filtered.push(b);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
return this.attachContractSummary(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "" } });
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1368,7 +1368,7 @@ export class TrainSchedulingService {
|
||||
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
|
||||
* past their window are left untouched — customers may have booked against the
|
||||
* times they were shown, so those stay frozen. Returns the count re-stamped.
|
||||
*/
|
||||
*/
|
||||
async restampPendingWindows(): Promise<number> {
|
||||
const cfg = await this.getWindowConfig();
|
||||
const now = new Date();
|
||||
|
||||
Reference in New Issue
Block a user