Confirmed live 2026-08-17 on INV-20260817-00008: MoR rejected the
document with a SCHEMA ERROR on ItemList[0].Unit — 'PER_CONTAINER'
(from the line's own metadata.unit) fails MoR's enum
(LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG), its 8-char max, and its
^[A-Za-z]{3,8}$ regex all at once.
line.metadata.unit is our own fee-basis tag (PER_CONTAINER/PER_TON/
PER_ITEM — how a charge is computed) and was never a MoR unit of
measure; the mapper was reusing the same field name for two unrelated
concepts. Every line now sends the single configured
EIMS_UNIT_DEFAULT instead of guessing a per-line value that doesn't
exist in MoR's vocabulary.
Ten EIMS_SELLER_* env vars were the only source of EDR's own seller identity,
duplicating data the platform already has via the same e-Trade lookup used
for every customer company at onboarding. EimsSellerCacheService now enriches
it — but static config remains the source of truth: MoR validates
SellerDetails against its own taxpayer registry (rule 7017, already cleared
against the current static values), so e-Trade fills a field only when the
static value is blank, never overrides one already confirmed. The static
config is therefore the durable fallback, not the cache; an in-memory
snapshot lost on restart is harmless.
ETradeService has no request timeout of its own and no AbortController, so
the cache enforces one locally (stops waiting, doesn't cancel the request)
and de-duplicates concurrent refresh() calls into the same in-flight promise.
getSellerDetails() is fully synchronous — zero I/O — so live registration
never depends on e-Trade being reachable, at boot or per invoice.
VatNumber and Email stay on static config permanently — confirmed by reading
e-Trade's actual response shapes, neither field exists anywhere in what it
returns. Region/Wereda/City reuse the existing EIMS_BUYER_*_CODES maps rather
than adding seller-specific ones — the geography is objective, not
buyer-specific.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
City: EimsMapperContext.buyerCity was declared but never wired anywhere —
always null, silently, for every buyer. No dedicated city column on Company;
derives from Zone via a new EIMS_BUYER_CITY_CODES map, same lookup mechanism
as Region/Wereda but optional (an unmapped zone resolves to null rather than
throwing) — MoR has already accepted a live filing with City null.
Country: previously a single flat EIMS_BUYER_COUNTRY_CODE applied to every
buyer regardless of Company.country. Now reads company.country, resolved via
a new EIMS_BUYER_COUNTRY_CODES name-to-code map; the flat env var becomes a
domestic-only fallback (applies only when country is empty/Ethiopia), so an
unmapped foreign buyer fails locally instead of silently filing as Ethiopia.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
BuyerDetails.Wereda had the same problem Region did: companies.woreda holds
names ("Yeka") MoR has no confirmed regex for, but every Wereda value MoR has
actually shown us (seller "12"/"13", the collection's "574") is 1-3 digits
like Region. Precautionary, not confirmed -- but the fix is identical either
way: resolve through EIMS_BUYER_WEREDA_CODES and refuse to file rather than
send a guessed code.
Generalises the Region resolver (resolveRegionCode -> resolveLocationCode) to
cover both fields instead of duplicating it.
No code was invented for "Yeka" -- EIMS_BUYER_WEREDA_CODES ships empty, so
this buyer now fails locally (new stop) instead of silently sending a name
that was never verified against MoR's schema.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three live attempts turned six guesses into facts. Each fix below is the
gateway's own words, not a reading of the collection.
DocumentNumber and InvoiceCounter move differently, because MoR constrains
them differently. The counter must not skip -- "Invoice counter is not
correct. expected : 1" -- so a definitively refused document hands it back.
The document number must not repeat, so the attempt burns it. Both stay spent
after an ambiguous result, where MoR may have stored the document.
NatureOfSupplies is normalised to MoR's exact lowercase constant and rejected
outright if it is neither 'goods' nor 'service'; its schema branches on this
as a oneOf, so "Service" invalidated the whole ItemList.
Buyer region resolves through a name->code map and now FAILS locally when
unmapped. MoR validates Region against ^[0-9]{1,3}$ on both the seller and
buyer sides, so a name can never be sent and a guessed code on a tax document
is worse than refusing to file.
Seller phone, email, region and wereda are checked against MoR's own regexes
before anything is sent, so a placeholder like "_" fails locally instead of
costing a request and a counter.
EIMS_TAX_CODE stays required and unset in .env.example: the choice between
VAT0 (zero-rated) and VATEX (exempt) is a tax position awaiting finance, and
MoR's enum is recorded there for whoever decides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3300000000000 collided with BookingWagonCancellations after the rebase.
3320000000000 is also unavailable: BulkContractTemplates3320000000000 is
already recorded in freight.migrations on the shared dev database from a
branch not present in this checkout, so checking only src/migrations is not
sufficient.
3330000000000 is unique across src/migrations and greater than the current
maximum timestamp recorded in freight.migrations.
Rename the migration file and class. The migration has no explicit name field
and no other code references its previous identity.
Verify migration discovery through the actual runtime path:
scripts/migrate.js loads compiled dist/migrations/*.js migrations, while
application boot does not run migrations automatically. Confirm the renamed
migration is present in dist.
For controlled dev verification, remove its migration-history row and run
pnpm migration:run again. The migration is discovered and applied under
3330000000000; its idempotent DDL produces no schema changes where the EIMS
schema already exists.
Map EDR invoices onto the MoR EIMS /v1/register document and add the
cryptographic transport needed to talk to core.mor.gov.et.
Mapper: DTOs mirror the supplied Postman collection section by section.
Tax is resolved per line via a caller-supplied resolver and throws when
unresolved -- the app models no tax at all (invoice.taxAmount is always 0,
invoice_lines and the rate catalogue carry no fiscal columns), so a
zero-rated default would assert a tax position the codebase cannot support.
Seller identity, document number, counters and previous IRN are passed in
explicitly; the mapper stays pure.
Transport: config, credential loading, RSA-SHA512 signing and /auth/login
with an in-memory token cache. Signing reproduces the process that produced
a working live token -- compact JSON of the inner request only, exact UTF-8
bytes, base64 signature, and base64 of the certificate file's exact bytes
with no parsing or re-encoding. Concurrent callers share one login via an
in-flight promise. Refresh is deliberately unimplemented: the collection
shows an unsigned refresh body but also ships unsigned examples of calls
that do require signing, so an expired token re-logs in instead.
Errors normalise to EimsApiException carrying only the gateway's own error
fields; secrets, signature, certificate and tokens never reach logs.
Key and certificate file patterns are gitignored.
Nothing calls EIMS automatically and no invoice entity, migration or UI is
touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>