fix(eims): retimestamp the EIMS migration to 3330000000000

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.
This commit is contained in:
Hagernesh
2026-08-08 04:56:28 +00:00
parent 6b1ffa831f
commit 2e26936bf1
11 changed files with 170 additions and 24 deletions

View File

@@ -172,14 +172,16 @@ EIMS_SELLER_LOCALITY=
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set.
EIMS_TAX_CODE=0
# MoR enum: TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
EIMS_TAX_CODE=VAT0
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
EIMS_NATURE_OF_SUPPLIES=Service
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
EIMS_NATURE_OF_SUPPLIES=service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS

View File

@@ -81,6 +81,8 @@ export interface EimsInvoiceConfig {
paymentTerm: string;
unitDefault: string;
buyerCountryCode: string | null;
/** MoR region code used when a buyer's stored region is a name rather than a code. */
buyerRegionFallback: string | null;
cashierName: string | null;
salesPersonName: string | null;
}
@@ -168,6 +170,7 @@ 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,
buyerRegionFallback: process.env.EIMS_BUYER_REGION_FALLBACK || null,
cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
},

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* EIMS document numbering.
*
* MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer
* of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs
* its own sequence, allocated from the same locked state row as the invoice counter and recorded
* on the invoice so a filed document can be traced back to it.
*/
export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS in_flight_document_number bigint
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_document_number varchar(16)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number
`);
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
DROP COLUMN IF EXISTS next_document_number,
DROP COLUMN IF EXISTS in_flight_document_number
`);
}
}

View File

@@ -210,6 +210,14 @@ export interface EimsMapperContext {
relatedDocument?: string | null;
/** MoR numeric country code for the buyer; our DB stores the country name. */
buyerCountryCode?: string | null;
/**
* Region code to use when the buyer's stored region is not already one.
*
* MoR validates `BuyerDetails.Region` against `^[0-9]{1,3}$`, but `companies.region` is free
* text ("Addis Ababa"). Rather than ship a name→code table we cannot verify, a stored value that
* already looks like a code is passed through and anything else falls back to this.
*/
buyerRegionFallback?: string | null;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
@@ -220,6 +228,9 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/** MoR's own constraint on `Region`: one to three digits. */
const REGION_CODE = /^[0-9]{1,3}$/;
const num = (v: number | string): number => {
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`);
@@ -329,7 +340,9 @@ export function toEimsInvoice(
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: company.region ?? null,
Region: REGION_CODE.test(company.region ?? "")
? (company.region as string)
: (context.buyerRegionFallback ?? null),
Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,

View File

@@ -115,6 +115,10 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
eimsIrn?: string | null;
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
eimsInvoiceCounter?: number | null;

View File

@@ -112,6 +112,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
// companies.region is free text ("Addis Ababa"); MoR wants ^[0-9]{1,3}$. A stored value that
// already looks like a code wins, otherwise the seller's own region stands in.
buyerRegionFallback: invoice.buyerRegionFallback || invoice.sellerRegion,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -92,9 +92,11 @@ class FakeDb {
id: "state-1",
systemNumber: SYSTEM_NUMBER,
nextInvoiceCounter: 7,
nextDocumentNumber: 5,
previousIrn: null,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
...state,
} as EimsSystemState;
@@ -131,7 +133,10 @@ class FakeDb {
return {
manager: this.manager,
getRepository: this.manager.getRepository,
query: async () => LINES,
query: async (sql: string) =>
sql.includes("eims_system_state")
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
: LINES,
transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.();
return body(this.manager);
@@ -161,8 +166,13 @@ const build = (
{ notify } as unknown as NotificationInboxService,
);
/** Document number the fixtures register under; `/v1/verify` must echo it back. */
const DOCUMENT_NUMBER = "INV-20260807-00042";
/**
* Document number the fixtures register under; `/v1/verify` must echo it back.
*
* A plain integer, not our `invoiceNumber`: MoR validates the field against
* `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above.
*/
const DOCUMENT_NUMBER = "5";
/**
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
@@ -225,7 +235,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.SourceSystem.InvoiceCounter).toBe(42);
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042");
expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER);
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
});
@@ -350,7 +360,9 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 8, // consumed: the attempt reached the gateway
// Returned, not consumed: MoR tracks the sequence and rejects a gap
// ("Invoice counter is not correct. expected : 1").
nextInvoiceCounter: 7,
});
});
@@ -396,7 +408,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("never reuses a counter once an attempt has begun", async () => {
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest
.fn()
@@ -409,8 +421,10 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
// A refused document returns its counter, so the next attempt reuses it — MoR expects a
// contiguous sequence of *accepted* documents, not of attempts.
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
});
});
@@ -510,7 +524,15 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const blocked = () =>
new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], {
new FakeDb(
[
invoiceRow({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsInvoiceCounter: 7,
eimsDocumentNumber: DOCUMENT_NUMBER,
}),
],
{
inFlightInvoiceId: INVOICE_ID,
inFlightCounter: 7,
nextInvoiceCounter: 8,
@@ -561,13 +583,13 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue(
verifyResponse({
DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" },
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
}),
);
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/not INV-20260807-00042/);
).rejects.toThrow(/not 5/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
@@ -610,7 +632,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
it("refuses to resolve an invoice that is not the in-flight one", async () => {
const db = blocked();
db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID }));
db.invoices.set(
OTHER_INVOICE_ID,
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
);
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
await expect(

View File

@@ -47,6 +47,8 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
interface Reservation {
stateId: string;
invoiceCounter: number;
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
documentNumber: string;
previousIrn: string;
}
@@ -102,8 +104,9 @@ export class EimsInvoiceRegistrationService {
invoice,
buildEimsSeller(cfg),
buildEimsContext(cfg, {
// Our own invoice number is the document number; EIMS only requires it to be unique.
documentNumber: invoice.invoiceNumber,
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
@@ -178,8 +181,9 @@ export class EimsInvoiceRegistrationService {
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
* IRN is the one it holds, and that it belongs to this invoice.
*
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
* from our own `invoiceNumber` — the only field tying an IRN back to a row in this database.
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
* allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back
* to a row in this database.
*
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
* chains every later document to a stranger's reference, so both checks are refusals rather
@@ -235,11 +239,34 @@ export class EimsInvoiceRegistrationService {
});
}
// Cheap ownership check before touching the gateway: resolving an invoice that does not hold
// the reservation is a caller mistake, not something to spend a MoR round trip on. The
// authoritative re-check happens under lock in the transaction below.
const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query(
`SELECT in_flight_invoice_id FROM freight.eims_system_state
WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`,
[(await this.auth.getSessionContext()).systemNumber],
);
if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) {
throw new ConflictException({
code: "EIMS_RESOLVE_WRONG_INVOICE",
message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`,
});
}
// Outside the transaction: no lock is held across the wire, and a refused verification must
// leave the block exactly as it was.
if (irn) {
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber);
if (!invoice.eimsDocumentNumber) {
throw new BadRequestException({
code: "EIMS_NO_DOCUMENT_NUMBER",
message:
`Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` +
"returned IRN cannot be tied back to it.",
});
}
await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber);
}
// Same source of truth as registration: the state row is keyed by the token's system number.
@@ -270,6 +297,7 @@ export class EimsInvoiceRegistrationService {
...(irn ? { previousIrn: irn } : {}),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
@@ -315,23 +343,27 @@ export class EimsInvoiceRegistrationService {
if (invoice.eimsIrn) return null;
const invoiceCounter = Number(state.nextInvoiceCounter);
const documentNumber = String(Number(state.nextDocumentNumber));
const previousIrn = state.previousIrn ?? "";
// Counter consumed here, not on success: once an attempt begins it can never be reused,
// whatever happens next. A gap is harmless at MoR; a collision is not.
await manager.update(EimsSystemState, state.id, {
nextInvoiceCounter: invoiceCounter + 1,
nextDocumentNumber: Number(documentNumber) + 1,
inFlightInvoiceId: invoiceId,
inFlightCounter: invoiceCounter,
inFlightDocumentNumber: Number(documentNumber),
});
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Submitting,
eimsInvoiceCounter: invoiceCounter,
eimsDocumentNumber: documentNumber,
eimsSubmittedAt: new Date(),
eimsLastError: null,
});
return { stateId: state.id, invoiceCounter, previousIrn };
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
});
}
@@ -354,15 +386,21 @@ export class EimsInvoiceRegistrationService {
previousIrn: irn,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
}
/**
* TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and
* blocks the system number, because `PreviousIrn` is now unknown for every later document.
* The counter stays consumed either way.
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* Returning the counter is not an optimisation — MoR tracks the sequence itself and rejects a
* gap: "Invoice counter is not correct. expected : 1". A document it definitively refused was
* never counted on its side, so ours must not advance either. An ambiguous result is the
* opposite case: MoR may have counted it, so the number stays spent until a human resolves it.
*/
private async settleFailure(
invoiceId: string,
@@ -390,7 +428,15 @@ export class EimsInvoiceRegistrationService {
EimsSystemState,
reservation.stateId,
deterministic
? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null }
? {
// Hand both numbers back: MoR never counted a document it refused outright.
nextInvoiceCounter: reservation.invoiceCounter,
nextDocumentNumber: Number(reservation.documentNumber),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
}
: {
blockedReason:
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
@@ -527,6 +573,7 @@ export class EimsInvoiceRegistrationService {
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,

View File

@@ -80,6 +80,8 @@ export interface EimsInvoiceStatusView {
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
/** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */
eimsDocumentNumber: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;

View File

@@ -33,6 +33,7 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerRegionFallback: "13",
cashierName: null,
salesPersonName: null,
...over,

View File

@@ -18,6 +18,18 @@ export class EimsSystemState extends BaseEntity {
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
nextInvoiceCounter!: number;
/**
* `DocumentDetails.DocumentNumber` for the next registration.
*
* Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against
* `^(0|[1-9][0-9]{0,8})$`, a plain integer.
*/
@Column({ name: "next_document_number", type: "bigint", default: 1 })
nextDocumentNumber!: number;
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
inFlightDocumentNumber?: number | null;
/** IRN of the last successful registration; null until the first one succeeds. */
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
previousIrn?: string | null;