mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #1311 from Tria-plc/eims-integration
Eims integration
This commit is contained in:
@@ -80,7 +80,19 @@ export interface EimsInvoiceConfig {
|
|||||||
paymentMode: string;
|
paymentMode: string;
|
||||||
paymentTerm: string;
|
paymentTerm: string;
|
||||||
unitDefault: 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;
|
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`
|
* 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
|
* ("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>;
|
buyerRegionCodes: Record<string, string>;
|
||||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||||
buyerWeredaCodes: Record<string, string>;
|
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` +
|
* 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
|
* `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 ?? "",
|
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||||
|
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
|
||||||
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
||||||
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_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),
|
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||||
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||||
|
|||||||
@@ -84,7 +84,13 @@ export class PdfRenderService {
|
|||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
const thermal = opts.thermal ?? false;
|
const thermal = opts.thermal ?? false;
|
||||||
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
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.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||||
await page.emulateMediaType("print");
|
await page.emulateMediaType("print");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
@@ -154,7 +160,7 @@ export class PdfRenderService {
|
|||||||
// browser context regardless, same as the closure form would be.
|
// browser context regardless, same as the closure form would be.
|
||||||
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
||||||
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
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 {
|
private injectPdfPrintStyles(html: string): string {
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
|||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
incomeWithholdValue: 0,
|
incomeWithholdValue: 0,
|
||||||
transactionWithholdValue: 0,
|
transactionWithholdValue: 0,
|
||||||
|
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
|
||||||
|
buyerCountryCodes: {},
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
buyerWeredaCodes: {},
|
buyerWeredaCodes: {},
|
||||||
|
buyerCityCodes: {},
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +95,9 @@ describe("toEimsInvoice", () => {
|
|||||||
|
|
||||||
expect(doc.BuyerDetails).toEqual({
|
expect(doc.BuyerDetails).toEqual({
|
||||||
City: null,
|
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",
|
Email: "buyer@abc.et",
|
||||||
HouseNumber: "NEW",
|
HouseNumber: "NEW",
|
||||||
IdNumber: null,
|
IdNumber: null,
|
||||||
@@ -100,7 +106,6 @@ describe("toEimsInvoice", () => {
|
|||||||
LegalName: "ABC Trading PLC",
|
LegalName: "ABC Trading PLC",
|
||||||
Phone: "0912345678",
|
Phone: "0912345678",
|
||||||
Region: "13",
|
Region: "13",
|
||||||
Country: null,
|
|
||||||
Zone: "SHA",
|
Zone: "SHA",
|
||||||
Kebele: "03",
|
Kebele: "03",
|
||||||
VatNumber: "123475885858",
|
VatNumber: "123475885858",
|
||||||
@@ -335,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => {
|
|||||||
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
|
).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", () => {
|
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
||||||
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
||||||
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
||||||
|
|||||||
@@ -234,8 +234,13 @@ export interface EimsMapperContext {
|
|||||||
* from a registered invoice").
|
* from a registered invoice").
|
||||||
*/
|
*/
|
||||||
relatedDocument?: string | null;
|
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;
|
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.
|
* 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.
|
* fail locally on an unmapped name rather than file a guess.
|
||||||
*/
|
*/
|
||||||
buyerWeredaCodes: Record<string, string>;
|
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;
|
buyerIdType?: string | null;
|
||||||
buyerIdNumber?: string | null;
|
buyerIdNumber?: string | null;
|
||||||
buyerCity?: string | null;
|
|
||||||
/** Required when the invoice currency is not ETB. */
|
/** Required when the invoice currency is not ETB. */
|
||||||
exchangeRate?: number | null;
|
exchangeRate?: number | null;
|
||||||
invoiceDiscount?: number | null;
|
invoiceDiscount?: number | null;
|
||||||
@@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string =>
|
|||||||
* exchange rate.
|
* exchange rate.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
|
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
|
||||||
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
|
* numeric, otherwise looked up by name (case- and space-insensitive).
|
||||||
* a guessed code onto a tax document is worse than refusing to file.
|
*
|
||||||
|
* 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(
|
function resolveLocationCode(
|
||||||
field: "Region" | "Wereda",
|
field: "Region" | "Wereda" | "City",
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
codes: Record<string, string>,
|
codes: Record<string, string>,
|
||||||
envVar: string,
|
envVar: string,
|
||||||
invoiceNumber: string,
|
invoiceNumber: string,
|
||||||
): string {
|
opts: { required?: boolean } = {},
|
||||||
|
): string | null {
|
||||||
const raw = (value ?? "").trim();
|
const raw = (value ?? "").trim();
|
||||||
if (LOCATION_CODE.test(raw)) return raw;
|
if (LOCATION_CODE.test(raw)) return raw;
|
||||||
|
|
||||||
@@ -319,12 +335,61 @@ function resolveLocationCode(
|
|||||||
)?.[1];
|
)?.[1];
|
||||||
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
||||||
|
|
||||||
|
if (opts.required === false) return null;
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
|
`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}.`,
|
`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(
|
export function toEimsInvoice(
|
||||||
invoice: EimsMapperInvoice,
|
invoice: EimsMapperInvoice,
|
||||||
seller: EimsSellerDetails,
|
seller: EimsSellerDetails,
|
||||||
@@ -440,7 +505,16 @@ export function toEimsInvoice(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
BuyerDetails: {
|
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,
|
Email: company.email ?? null,
|
||||||
HouseNumber: company.houseNo ?? null,
|
HouseNumber: company.houseNo ?? null,
|
||||||
IdNumber: context.buyerIdNumber ?? null,
|
IdNumber: context.buyerIdNumber ?? null,
|
||||||
@@ -455,7 +529,12 @@ export function toEimsInvoice(
|
|||||||
"EIMS_BUYER_REGION_CODES",
|
"EIMS_BUYER_REGION_CODES",
|
||||||
invoice.invoiceNumber,
|
invoice.invoiceNumber,
|
||||||
),
|
),
|
||||||
Country: context.buyerCountryCode ?? null,
|
Country: resolveCountryCode(
|
||||||
|
company.country,
|
||||||
|
context.buyerCountryCodes,
|
||||||
|
context.buyerCountryCode ?? null,
|
||||||
|
invoice.invoiceNumber,
|
||||||
|
),
|
||||||
Zone: company.zone ?? null,
|
Zone: company.zone ?? null,
|
||||||
Kebele: company.kebele ?? null,
|
Kebele: company.kebele ?? null,
|
||||||
VatNumber: company.vatNumber ?? null,
|
VatNumber: company.vatNumber ?? null,
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
|||||||
// Consumed by NotificationInboxModule for portal recipient targeting.
|
// Consumed by NotificationInboxModule for portal recipient targeting.
|
||||||
ExternalProfileRepository,
|
ExternalProfileRepository,
|
||||||
CompanyProfileRepository,
|
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 { }
|
export class CompaniesModule { }
|
||||||
|
|||||||
@@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||||
buyerCountryCode: invoice.buyerCountryCode,
|
buyerCountryCode: invoice.buyerCountryCode,
|
||||||
|
buyerCountryCodes: invoice.buyerCountryCodes,
|
||||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||||
|
buyerCityCodes: invoice.buyerCityCodes,
|
||||||
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||||
buyerIdType: invoice.buyerIdType,
|
buyerIdType: invoice.buyerIdType,
|
||||||
buyerIdNumber: invoice.buyerIdNumber,
|
buyerIdNumber: invoice.buyerIdNumber,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { NotificationsService } from "../notifications/notifications.service";
|
|||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { buildEimsSeller } from "./eims-invoice-context";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
|
||||||
@@ -172,6 +174,9 @@ const build = (
|
|||||||
} as unknown as EimsAuthService,
|
} as unknown as EimsAuthService,
|
||||||
{ notify } as unknown as NotificationInboxService,
|
{ notify } as unknown as NotificationInboxService,
|
||||||
{ directSend } as unknown as NotificationsService,
|
{ 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,12 +27,9 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
|||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException } from "./eims.errors";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
import {
|
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||||
assertEimsInvoiceConfig,
|
|
||||||
buildEimsContext,
|
|
||||||
buildEimsSeller,
|
|
||||||
} from "./eims-invoice-context";
|
|
||||||
import {
|
import {
|
||||||
EimsInvoiceError,
|
EimsInvoiceError,
|
||||||
EimsInvoiceStatus,
|
EimsInvoiceStatus,
|
||||||
@@ -83,6 +80,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
private readonly auth: EimsAuthService,
|
private readonly auth: EimsAuthService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly sellerCache: EimsSellerCacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get cfg(): EimsConfig {
|
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.
|
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
|
||||||
const request = toEimsInvoice(
|
const request = toEimsInvoice(
|
||||||
invoice,
|
invoice,
|
||||||
buildEimsSeller(cfg),
|
this.sellerCache.getSellerDetails(cfg),
|
||||||
buildEimsContext(cfg, {
|
buildEimsContext(cfg, {
|
||||||
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
// 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.
|
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
|||||||
paymentTerm: "IMMIDIATE",
|
paymentTerm: "IMMIDIATE",
|
||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
buyerCountryCode: null,
|
buyerCountryCode: null,
|
||||||
|
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||||
|
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
|
||||||
taxCodeByChargeType: {},
|
taxCodeByChargeType: {},
|
||||||
taxRateByChargeType: {},
|
taxRateByChargeType: {},
|
||||||
exciseByChargeType: {},
|
exciseByChargeType: {},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
|
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { DocumentsModule } from "../billing/documents/documents.module";
|
import { DocumentsModule } from "../billing/documents/documents.module";
|
||||||
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
@@ -14,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
|||||||
import { EimsInvoiceController } from "./eims-invoice.controller";
|
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
import { EimsReceiptService } from "./eims-receipt.service";
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSignerService } from "./eims-signer.service";
|
import { EimsSignerService } from "./eims-signer.service";
|
||||||
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.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
|
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
|
||||||
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
|
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
|
||||||
DocumentsModule,
|
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],
|
controllers: [EimsInvoiceController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
EimsAutoSubmitService,
|
EimsAutoSubmitService,
|
||||||
EimsCancellationService,
|
EimsCancellationService,
|
||||||
EimsReceiptService,
|
EimsReceiptService,
|
||||||
|
EimsSellerCacheService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
EimsAuthService,
|
EimsAuthService,
|
||||||
|
|||||||
Reference in New Issue
Block a user