feat(eims): register invoices with MoR EIMS and persist the outcome

Add manual single-invoice registration, verification and reconciliation.
Nothing submits automatically; invoice creation is untouched.

Sequencing uses a durable reservation. The counter is consumed and the
holder recorded in a committed transaction before the request leaves the
process, and the HTTP call runs outside every transaction. A counter is
therefore never reused once an attempt begins, a crash mid-flight leaves the
reservation standing instead of inviting a blind resubmission, and an
ambiguous result blocks the whole system number rather than one invoice --
PreviousIrn is unknown, so any later document would chain to a stale IRN.

Deterministic rejections (400/406/401/403) mark the invoice FAILED and clear
the block. Timeouts and 5xx mark it UNKNOWN and keep it. Since /v1/verify
takes an IRN we never received in that case, POST :id/eims/resolve is the
exit: record the IRN confirmed in the MoR portal, or discard. A recorded IRN
is verified against the gateway first and refused unless EIMS reports it
against this invoice's document number.

Business and tax configuration is validated locally before anything is
locked, allocated or sent, so a missing tax code fails naming the exact
environment variables instead of at the gateway. No tax value is defaulted.

Filing gets its own permission (invoices:eims_register) rather than riding
on invoices:export -- registration is irreversible at MoR and must not
follow from the right to download a PDF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-07 13:22:46 +00:00
parent 2644d5e52d
commit 7573019038
14 changed files with 1555 additions and 8 deletions

View File

@@ -0,0 +1,114 @@
import { BadRequestException } from "@nestjs/common";
import { EimsConfig } from "../../config/eims.config";
import {
EimsMapperContext,
EimsMapperLine,
EimsSellerDetails,
} from "../billing/eims-invoice.mapper";
/**
* Turns configuration into the seller identity and mapper context that `toEimsInvoice` requires.
*
* Everything here is unavailable from the database by construction: EDR's own legal identity is not
* modelled anywhere, and the application has no tax model at all (`invoice.taxAmount` is always 0,
* `invoice_lines` and the rate catalogue carry no fiscal columns). Rather than defaulting any of it,
* a missing value fails **here** — locally, before a single byte reaches the gateway — naming the
* exact environment variables to set.
*/
interface RequiredSpec {
env: string;
value: string | number | null | undefined;
}
const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [
{ env: "EIMS_TIN", value: tin },
{ env: "EIMS_SYSTEM_NUMBER", value: systemNumber },
{ env: "EIMS_SYSTEM_TYPE", value: systemType },
{ env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName },
{ env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber },
{ env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone },
{ env: "EIMS_SELLER_EMAIL", value: invoice.sellerEmail },
{ env: "EIMS_SELLER_REGION", value: invoice.sellerRegion },
{ env: "EIMS_SELLER_WEREDA", value: invoice.sellerWereda },
{ env: "EIMS_TAX_CODE", value: invoice.taxCode },
{ env: "EIMS_TAX_RATE_PERCENT", value: invoice.taxRatePercent },
{ env: "EIMS_INCOME_WITHHOLD_VALUE", value: invoice.incomeWithholdValue },
{ env: "EIMS_TRANSACTION_WITHHOLD_VALUE", value: invoice.transactionWithholdValue },
{ env: "EIMS_TRANSACTION_TYPE", value: invoice.transactionType },
{ env: "EIMS_NATURE_OF_SUPPLIES", value: invoice.natureOfSupplies },
{ env: "EIMS_PAYMENT_MODE", value: invoice.paymentMode },
{ env: "EIMS_PAYMENT_TERM", value: invoice.paymentTerm },
{ env: "EIMS_UNIT_DEFAULT", value: invoice.unitDefault },
];
/** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */
export function assertEimsInvoiceConfig(config: EimsConfig): void {
const missing = REQUIRED(config.invoice, config.tin, config.systemNumber, config.systemType)
.filter(({ value }) => value === null || value === undefined || value === "")
.map(({ env }) => env);
if (missing.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INCOMPLETE",
message:
"EIMS invoice registration is not configured. Set these environment variables " +
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
});
}
}
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
const { invoice } = config;
return {
City: invoice.sellerCity,
Email: invoice.sellerEmail,
HouseNumber: invoice.sellerHouseNumber,
LegalName: invoice.sellerLegalName,
Locality: invoice.sellerLocality,
Phone: invoice.sellerPhone,
Region: invoice.sellerRegion,
SubCity: invoice.sellerSubCity,
Tin: config.tin,
VatNumber: invoice.sellerVatNumber,
Wereda: invoice.sellerWereda,
};
}
export interface EimsContextInput {
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
documentNumber: string;
invoiceCounter: number;
previousIrn: string | null;
/** Required when the invoice currency is not ETB. */
exchangeRate?: number | null;
}
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
const { invoice } = config;
// Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call.
const taxCode = invoice.taxCode;
const ratePercent = invoice.taxRatePercent!;
const exciseTaxValue = invoice.exciseTaxValue ?? 0;
return {
systemNumber: config.systemNumber,
systemType: config.systemType,
documentNumber: input.documentNumber,
invoiceCounter: input.invoiceCounter,
previousIrn: input.previousIrn,
cashierName: invoice.cashierName,
salesPersonName: invoice.salesPersonName,
transactionType: invoice.transactionType,
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
// One treatment for every line today. The mapper resolves tax per line, so a future
// charge-type-specific rule slots in here without touching the mapper.
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
natureOfSupplies: invoice.natureOfSupplies,
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
exchangeRate: input.exchangeRate ?? null,
};
}