diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 086923df5..53d599de5 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -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 diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 6eaaf8007..659377c77 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -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, }, diff --git a/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts new file mode 100644 index 000000000..481b7489c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 864d9f829..0bd5d5a92 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index c5c000943..411d15ebe 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 20ccfdfba..8f81b5f78 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -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, }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 0cbe76cb5..f32b78000 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -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) => { 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( diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index bb38a64cf..66131ed01 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index ad6a3aa34..c4d9f3842 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 79fe30f96..d620cfa40 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -33,6 +33,7 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerRegionFallback: "13", cashierName: null, salesPersonName: null, ...over, diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts index ac6489c93..a21057042 100644 --- a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -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;