Merge pull request #1305 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-08-17 11:04:09 +03:00
committed by GitHub
21 changed files with 736 additions and 81 deletions

View File

@@ -80,7 +80,19 @@ export interface EimsInvoiceConfig {
paymentMode: string;
paymentTerm: string;
unitDefault: string;
/**
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
*/
buyerCountryCode: string | null;
/**
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
* this is not validated against a fixed digit pattern, only looked up by name.
*/
buyerCountryCodes: Record<string, string>;
/**
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
@@ -89,6 +101,14 @@ export interface EimsInvoiceConfig {
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
* mapping.
*/
buyerCityCodes: Record<string, string>;
/**
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
@@ -208,8 +228,10 @@ export default registerAs("eims", (): EimsConfig => {
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_CODES),
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),

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

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

View File

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

View File

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

View File

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

View File

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

@@ -11,7 +11,9 @@ import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } 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,
);
/**

View File

@@ -27,12 +27,9 @@ 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 { 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 {
@@ -128,7 +126,7 @@ export class EimsInvoiceRegistrationService {
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
const request = toEimsInvoice(
invoice,
buildEimsSeller(cfg),
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.

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,20 @@ import {
Card,
Group,
SegmentedControl,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Search, X } from "lucide-react";
import {
Banknote,
CircleDollarSign,
Landmark,
RefreshCw,
Search,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -22,6 +28,7 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
@@ -83,7 +90,7 @@ export default function InvoicesPanel() {
// Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page.
const { data: summary } = useQuery(
const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({
input: {
filter: { search: debouncedQuery, status: statusFilter || undefined },
@@ -190,39 +197,30 @@ export default function InvoicesPanel() {
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Total collected
</Text>
<Text size="xl" fw={700} c="edr-text">
{etbFromUsd !== null
? formatMoney(etbCollected + etbFromUsd, "ETB")
: formatMoney(etbCollected, "ETB")}
</Text>
<Text size="xs" c="dimmed">
{etbFromUsd !== null
? `Includes ${formatMoney(usdCollected, "USD")} converted @ ${rate} ETB/USD`
: "USD rate unavailable — ETB collected only"}
</Text>
</Card>
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Collected ETB only
</Text>
<Text size="xl" fw={700} c="edr-text">
{formatMoney(etbCollected, "ETB")}
</Text>
</Card>
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
Collected USD only
</Text>
<Text size="xl" fw={700} c="edr-text">
{formatMoney(usdCollected, "USD")}
</Text>
</Card>
</SimpleGrid>
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total collected",
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Collected in ETB",
value: formatMoney(etbCollected, "ETB"),
icon: Banknote,
color: "blue",
},
{
label: "Collected in USD",
value: formatMoney(usdCollected, "USD"),
icon: Landmark,
color: "violet",
},
]}
/>
<Card p={0}>
<Stack gap={0}>

View File

@@ -11,7 +11,7 @@ import {
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
import { CheckCircle2, Clock, CreditCard } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Link, useNavigate } from "react-router-dom";
@@ -235,7 +235,7 @@ export function WagonCancellationCard({
<SectionCard>
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{canRequest && !openRow && !creditRow && (
{/* {canRequest && !openRow && !creditRow && (
<Button
variant="default"
radius="md"
@@ -244,7 +244,7 @@ export function WagonCancellationCard({
>
Cancel wagons
</Button>
)}
)} */}
</Group>
{openRow ? (

View File

@@ -26,7 +26,7 @@ import {
LayoutList,
MoreVertical,
Package,
// Plus,
Search,
Train,
Wallet,