mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
feat(eims): resolve buyer geography from the MoR location master
BuyerDetails Country/Region/City/Wereda now resolve from the Ministry's own EIMS_COUNTRY_REGION_VW master instead of the EIMS_BUYER_*_CODES env maps and the ethiopia-geo-codes table. Both invented their codes and looked names up globally, so KERSA/GORO/BABILE/BURE — each present in several zones with different LOCALITY_NOs — could be filed against the wrong jurisdiction. Resolution is hierarchical and refuses to guess: an unknown or ambiguous address raises a local validation error naming the level that failed, and never selects the first matching row. Spelling differences between EDR and MoR live in a reviewed, parent-scoped alias layer; the dataset itself stays verbatim so it remains traceable to the Ministry sheet. Resolution now runs before the counter reservation in both the single and bulk paths, so a bad company address no longer burns an EIMS sequence number. Adds eims:import-locations to regenerate the dataset from a future workbook, reporting duplicate rows and same-hierarchy code conflicts.
This commit is contained in:
@@ -36,9 +36,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||
tin: "0999930000",
|
||||
vatNumber: "123475885858",
|
||||
phone: "0912345678",
|
||||
region: "13",
|
||||
zone: "SHA",
|
||||
woreda: "574",
|
||||
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
|
||||
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
|
||||
// alias layer is exercised end to end rather than only in the resolver's own spec.
|
||||
region: "Somali",
|
||||
zone: "Fafen",
|
||||
woreda: "Jigjiga",
|
||||
kebele: "03",
|
||||
houseNo: "NEW",
|
||||
country: "Ethiopia",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver";
|
||||
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
@@ -32,6 +33,8 @@ interface BulkReservation {
|
||||
invoice: Invoice & { lines: EimsMapperLine[] };
|
||||
documentType: EimsDocumentType;
|
||||
relatedDocument: string | null;
|
||||
/** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */
|
||||
buyerGeo: MorGeoCodes;
|
||||
invoiceCounter: number;
|
||||
documentNumber: string;
|
||||
previousIrn: string;
|
||||
@@ -123,7 +126,16 @@ export class EimsBulkRegistrationService {
|
||||
}
|
||||
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
||||
}
|
||||
return { invoice, documentType, relatedDocument };
|
||||
// Same rule as the single-invoice path: buyer geography is resolved from the MoR location
|
||||
// master before reserveBulk touches a counter, so one bad company address fails the whole
|
||||
// batch locally instead of burning a block of EIMS sequence numbers.
|
||||
const buyerGeo = resolveMorGeo({
|
||||
country: invoice.company?.country,
|
||||
region: invoice.company?.region,
|
||||
zone: invoice.company?.zone,
|
||||
woreda: invoice.company?.woreda,
|
||||
});
|
||||
return { invoice, documentType, relatedDocument, buyerGeo };
|
||||
});
|
||||
|
||||
if (prepared.length === 0) {
|
||||
@@ -141,6 +153,7 @@ export class EimsBulkRegistrationService {
|
||||
r.invoice,
|
||||
this.sellerCache.getSellerDetails(cfg),
|
||||
buildEimsContext(cfg, {
|
||||
buyerGeo: r.buyerGeo,
|
||||
documentNumber: r.documentNumber,
|
||||
invoiceCounter: r.invoiceCounter,
|
||||
previousIrn: r.previousIrn,
|
||||
@@ -269,7 +282,12 @@ export class EimsBulkRegistrationService {
|
||||
|
||||
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
|
||||
private async reserveBulk(
|
||||
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
|
||||
prepared: Array<{
|
||||
invoice: Invoice & { lines: EimsMapperLine[] };
|
||||
documentType: EimsDocumentType;
|
||||
relatedDocument: string | null;
|
||||
buyerGeo: MorGeoCodes;
|
||||
}>,
|
||||
systemNumber: string,
|
||||
placeholder: string,
|
||||
): Promise<BulkReservation[]> {
|
||||
@@ -302,7 +320,7 @@ export class EimsBulkRegistrationService {
|
||||
|
||||
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
|
||||
// on the opposite lock order.
|
||||
for (const { invoice, documentType, relatedDocument } of prepared) {
|
||||
for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) {
|
||||
const locked = await this.lockInvoice(manager, invoice.id);
|
||||
const thisCounter = counter++;
|
||||
const thisDocNumber = String(docNumber++);
|
||||
@@ -322,6 +340,7 @@ export class EimsBulkRegistrationService {
|
||||
invoice: Object.assign(locked, { lines: invoice.lines }),
|
||||
documentType,
|
||||
relatedDocument,
|
||||
buyerGeo,
|
||||
invoiceCounter: thisCounter,
|
||||
documentNumber: thisDocNumber,
|
||||
previousIrn: thisPreviousIrn,
|
||||
|
||||
@@ -66,7 +66,13 @@ describe("assertEimsInvoiceConfig — charge-type overrides", () => {
|
||||
});
|
||||
|
||||
describe("buildEimsContext — taxForLine", () => {
|
||||
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
|
||||
const input = {
|
||||
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
|
||||
documentNumber: "24",
|
||||
invoiceCounter: 7,
|
||||
previousIrn: "",
|
||||
session: SESSION,
|
||||
};
|
||||
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
|
||||
|
||||
it("uses the per-chargeType override when one is configured", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { MorGeoCodes } from "../../config/mor-location.resolver";
|
||||
import { EimsSessionContext } from "./eims-auth.service";
|
||||
import {
|
||||
EimsMapperContext,
|
||||
@@ -149,6 +150,12 @@ export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
|
||||
}
|
||||
|
||||
export interface EimsContextInput {
|
||||
/**
|
||||
* The buyer's MoR location codes, resolved from the Ministry location master by
|
||||
* `resolveMorGeo` **before** the caller reserved an EIMS counter — see
|
||||
* `EimsMapperContext.buyerGeo`.
|
||||
*/
|
||||
buyerGeo: MorGeoCodes;
|
||||
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
|
||||
documentNumber: string;
|
||||
invoiceCounter: number;
|
||||
@@ -205,11 +212,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
unitDefault: invoice.unitDefault,
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||
buyerCountryCode: invoice.buyerCountryCode,
|
||||
buyerCountryCodes: invoice.buyerCountryCodes,
|
||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||
buyerCityCodes: invoice.buyerCityCodes,
|
||||
buyerGeo: input.buyerGeo,
|
||||
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||
buyerIdType: invoice.buyerIdType,
|
||||
buyerIdNumber: invoice.buyerIdNumber,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||
import { buildEimsSeller } from "./eims-invoice-context";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { ETradeService } from "../companies/services/etrade.service";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
@@ -58,9 +59,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||
vatNumber: "123475885858",
|
||||
phone: "0912345678",
|
||||
email: "buyer@abc.et",
|
||||
region: "13",
|
||||
zone: "SHA",
|
||||
woreda: "574",
|
||||
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
|
||||
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
|
||||
// alias layer is exercised end to end rather than only in the resolver's own spec.
|
||||
region: "Somali",
|
||||
zone: "Fafen",
|
||||
woreda: "Jigjiga",
|
||||
kebele: "03",
|
||||
houseNo: "NEW",
|
||||
country: "Ethiopia",
|
||||
@@ -502,21 +506,23 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
|
||||
it("a mapper failure after reservation still releases the reservation", async () => {
|
||||
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
|
||||
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
|
||||
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
|
||||
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
|
||||
]);
|
||||
//
|
||||
// The trigger used to be an unmapped buyer country. That can no longer get this far: geography
|
||||
// is resolved before the reservation now (see the test below). A line/total mismatch is a
|
||||
// mapper-only failure that still reaches this point.
|
||||
const db = new FakeDb([invoiceRow({ totalAmount: 999999 })]);
|
||||
const postSigned = jest.fn();
|
||||
|
||||
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
|
||||
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
|
||||
// known exception types.
|
||||
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
||||
/no MoR country code mapping/,
|
||||
/lines sum to/,
|
||||
);
|
||||
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
@@ -532,6 +538,69 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("an unmappable buyer address fails before a counter is ever reserved", async () => {
|
||||
// The whole point of resolving geography ahead of reserve(): a company-record problem is a
|
||||
// local data problem, and it must not cost an EIMS sequence number. Nothing about the invoice
|
||||
// or the system state may change.
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ company: { ...invoiceRow().company, woreda: "Nowhere" } as never }),
|
||||
]);
|
||||
const before = { ...db.state };
|
||||
const postSigned = jest.fn();
|
||||
|
||||
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
||||
/no MoR LOCALITY_DESC match/,
|
||||
);
|
||||
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(db.state).toMatchObject({
|
||||
nextInvoiceCounter: before.nextInvoiceCounter,
|
||||
nextDocumentNumber: before.nextDocumentNumber,
|
||||
inFlightInvoiceId: null,
|
||||
});
|
||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||
eimsStatus: EimsInvoiceStatus.NotSubmitted,
|
||||
eimsInvoiceCounter: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("files the buyer's MoR codes, resolved from the location master with no e-Trade call", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "IRN-1" } });
|
||||
|
||||
// The real seller cache, wired to an e-Trade mock that must never be reached: registration
|
||||
// reads the company row EDR already stored, so filing stays deterministic and independent of
|
||||
// e-Trade's availability. `refresh()` is deliberately not called — the cache stays empty and
|
||||
// the seller falls back to static config, exactly as it does on a cold process.
|
||||
const cfg = config();
|
||||
const resolveCompanyData = jest.fn();
|
||||
const sellerCache = new EimsSellerCacheService(
|
||||
{ resolveCompanyData, extractRegistrationData: jest.fn() } as unknown as ETradeService,
|
||||
{ get: () => cfg } as unknown as ConfigService,
|
||||
);
|
||||
|
||||
const service = new EimsInvoiceRegistrationService(
|
||||
db.asDataSource(),
|
||||
{ get: () => cfg } as unknown as ConfigService,
|
||||
{ postSigned, postBearer: jest.fn() } as unknown as EimsClientService,
|
||||
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||
{ notify: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationInboxService,
|
||||
{ directSend: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationsService,
|
||||
sellerCache,
|
||||
);
|
||||
|
||||
await service.registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
const [, body] = postSigned.mock.calls[0];
|
||||
expect(body.BuyerDetails).toMatchObject({
|
||||
Country: "70",
|
||||
Region: "6",
|
||||
City: "31",
|
||||
Wereda: "190",
|
||||
});
|
||||
expect(resolveCompanyData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a success response with no IRN as a failed registration", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
||||
|
||||
@@ -29,6 +29,7 @@ import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import { resolveMorGeo } from "../../config/mor-location.resolver";
|
||||
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||
import {
|
||||
EimsInvoiceError,
|
||||
@@ -116,6 +117,17 @@ export class EimsInvoiceRegistrationService {
|
||||
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
||||
}
|
||||
|
||||
// Buyer geography is resolved from the MoR location master *here*, ahead of the reservation:
|
||||
// an unknown or ambiguous company address is a local data problem, and failing it after
|
||||
// reserving would consume an EIMS sequence number for an invoice that was never filable. It
|
||||
// needs no network access, so there is no reason for it to sit behind the login either.
|
||||
const buyerGeo = resolveMorGeo({
|
||||
country: invoice.company?.country,
|
||||
region: invoice.company?.region,
|
||||
zone: invoice.company?.zone,
|
||||
woreda: invoice.company?.woreda,
|
||||
});
|
||||
|
||||
// Authenticate before reserving: the source system comes from the token, and the state row is
|
||||
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
||||
const session = await this.auth.getSessionContext();
|
||||
@@ -135,6 +147,7 @@ export class EimsInvoiceRegistrationService {
|
||||
invoice,
|
||||
this.sellerCache.getSellerDetails(cfg),
|
||||
buildEimsContext(cfg, {
|
||||
buyerGeo,
|
||||
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
||||
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||
documentNumber: reservation.documentNumber,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { ETradeService } from "../companies/services/etrade.service";
|
||||
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||
|
||||
// A real MoR address (PARISH_NO 13 / CITY_NO 78 / LOCALITY_NO 1100) — the resolver now works off
|
||||
// the Ministry's own hierarchy, so a made-up address would simply not resolve.
|
||||
const registrationData = (over: Record<string, unknown> = {}) => ({
|
||||
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
|
||||
region: "Addis Ababa",
|
||||
zone: "Bole",
|
||||
woreda: "Yeka",
|
||||
woreda: "Woreda 1",
|
||||
mobilePhone: "0911000000",
|
||||
regularPhone: "",
|
||||
...over,
|
||||
@@ -24,16 +26,10 @@ const build = (cfg: EimsConfig = eimsConfig()) => {
|
||||
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 }),
|
||||
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C." }),
|
||||
});
|
||||
const { service, resolveCompanyData } = build(cfg);
|
||||
resolveCompanyData.mockResolvedValue({
|
||||
@@ -56,7 +52,6 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
|
||||
sellerRegion: "",
|
||||
sellerWereda: "",
|
||||
sellerCity: null,
|
||||
...CODES,
|
||||
}),
|
||||
});
|
||||
const { service, resolveCompanyData } = build(cfg);
|
||||
@@ -67,13 +62,32 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
|
||||
|
||||
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
|
||||
expect(seller.Region).toBe("13");
|
||||
expect(seller.Wereda).toBe("99");
|
||||
expect(seller.City).toBe("78");
|
||||
expect(seller.Wereda).toBe("1100");
|
||||
});
|
||||
|
||||
it("leaves the static seller values alone when MoR does not list the e-Trade address", async () => {
|
||||
// e-Trade's free text does not always correspond to a MoR row (here "Yeka" is a MoR *City*
|
||||
// under ADDIS ABABA, not a locality under BOLE). That must degrade to the static config, which
|
||||
// MoR has already cleared under rule 7017 — never throw, and never file a guessed code.
|
||||
const cfg = eimsConfig({
|
||||
invoice: eimsInvoiceConfig({ sellerRegion: "1", sellerWereda: "13", sellerCity: "101" }),
|
||||
});
|
||||
const { service, resolveCompanyData, extractRegistrationData } = build(cfg);
|
||||
extractRegistrationData.mockReturnValue(registrationData({ woreda: "Yeka" }));
|
||||
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
|
||||
|
||||
await expect(service.refresh()).resolves.toBeUndefined();
|
||||
const seller = service.getSellerDetails(cfg);
|
||||
|
||||
expect(seller.Region).toBe("1");
|
||||
expect(seller.Wereda).toBe("13");
|
||||
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 }),
|
||||
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et" }),
|
||||
});
|
||||
const { service, resolveCompanyData } = build(cfg);
|
||||
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
|
||||
@@ -108,7 +122,7 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
|
||||
|
||||
describe("EimsSellerCacheService.refresh", () => {
|
||||
it("keeps the previous snapshot when a refresh fails", async () => {
|
||||
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
|
||||
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
|
||||
const { service, resolveCompanyData } = build(cfg);
|
||||
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
|
||||
await service.refresh();
|
||||
@@ -123,7 +137,7 @@ describe("EimsSellerCacheService.refresh", () => {
|
||||
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 cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
|
||||
const { service, resolveCompanyData } = build(cfg);
|
||||
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
|
||||
await service.refresh();
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 { tryResolveMorGeo } from "../../config/mor-location.resolver";
|
||||
import { EimsSellerDetails } from "../billing/eims-invoice.mapper";
|
||||
import { buildEimsSeller } from "./eims-invoice-context";
|
||||
|
||||
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
|
||||
@@ -104,17 +105,24 @@ export class EimsSellerCacheService implements OnModuleInit {
|
||||
);
|
||||
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
|
||||
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
|
||||
const codes = cfg.invoice;
|
||||
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the
|
||||
// same MoR location master the buyer side uses, since the geography is objective, not
|
||||
// buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply
|
||||
// leaves these fields to getSellerDetails' static-config fallback, which is authoritative
|
||||
// anyway (see the class comment — MoR has already cleared the static seller values under
|
||||
// rule 7017, so nothing here may override one). e-Trade carries no country field; the
|
||||
// resolver reads a blank country as domestic, which is correct for EDR's own registration.
|
||||
const geo = tryResolveMorGeo({
|
||||
region: data.region,
|
||||
zone: data.zone,
|
||||
woreda: data.woreda,
|
||||
});
|
||||
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),
|
||||
Region: geo?.Region,
|
||||
Wereda: geo?.Wereda,
|
||||
City: geo?.City,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
||||
paymentMode: "CASH",
|
||||
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: {},
|
||||
|
||||
Reference in New Issue
Block a user