import { BadRequestException } from "@nestjs/common"; import { EimsConfig } from "../../config/eims.config"; import { EimsSessionContext } from "./eims-auth.service"; 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; } // `systemNumber` / `systemType` are absent by design: they come from the access token, which is // MoR's own statement of who we are. See EimsAuthService.getSessionContext. const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [ { env: "EIMS_TIN", value: tin }, { 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) .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(", ")}`, }); } assertSellerFormats(config.invoice); assertChargeTypeOverrides(config.invoice); } /** * Per-`chargeType` tax overrides must be internally consistent before anything is filed: * `EIMS_TAX_CODE_BY_CHARGE_TYPE` and `EIMS_TAX_RATE_BY_CHARGE_TYPE` must name the same charge * types (a code with no rate, or vice versa, is a half-finished override), and every rate/excise/ * discount value must parse as a number — checked once here rather than once per line at * registration time. */ function assertChargeTypeOverrides(invoice: EimsConfig["invoice"]): void { const codeKeys = Object.keys(invoice.taxCodeByChargeType); const rateKeys = Object.keys(invoice.taxRateByChargeType); const mismatched = [...new Set([...codeKeys, ...rateKeys])].filter( (k) => !(codeKeys.includes(k) && rateKeys.includes(k)), ); if (mismatched.length > 0) { throw new BadRequestException({ code: "EIMS_INVOICE_CONFIG_INVALID", message: `EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge ` + `types; mismatched: ${mismatched.join(", ")}`, }); } const numericMaps: { env: string; map: Record }[] = [ { env: "EIMS_TAX_RATE_BY_CHARGE_TYPE", map: invoice.taxRateByChargeType }, { env: "EIMS_EXCISE_BY_CHARGE_TYPE", map: invoice.exciseByChargeType }, { env: "EIMS_DISCOUNT_BY_CHARGE_TYPE", map: invoice.discountByChargeType }, ]; const badNumbers = numericMaps.flatMap(({ env, map }) => Object.entries(map) .filter(([, value]) => !Number.isFinite(Number(value))) .map(([chargeType]) => `${env}[${chargeType}]`), ); if (badNumbers.length > 0) { throw new BadRequestException({ code: "EIMS_INVOICE_CONFIG_INVALID", message: `EIMS charge-type overrides must be numbers: ${badNumbers.join(", ")}`, }); } } /** * MoR's own patterns for the seller fields, checked here rather than at the gateway. * * A placeholder like `_` is "set" but unfilable, and finding that out costs a real request and a * consumed counter — these are the exact regexes its 400 SCHEMA ERROR quoted back at us. */ const SELLER_FORMATS: { env: string; value: (i: EimsConfig["invoice"]) => string; pattern: RegExp }[] = [ { env: "EIMS_SELLER_PHONE", value: (i) => i.sellerPhone, pattern: /^\+?[0-9]{6,}$/ }, { env: "EIMS_SELLER_EMAIL", value: (i) => i.sellerEmail, pattern: /^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/, }, { env: "EIMS_SELLER_REGION", value: (i) => i.sellerRegion, pattern: /^[0-9]{1,3}$/ }, { env: "EIMS_SELLER_WEREDA", value: (i) => i.sellerWereda, pattern: /^[0-9A-Za-z]{1,10}$/ }, ]; function assertSellerFormats(invoice: EimsConfig["invoice"]): void { const bad = SELLER_FORMATS.filter(({ value, pattern }) => !pattern.test(value(invoice))).map( ({ env, pattern }) => `${env} (must match ${pattern.source})`, ); if (bad.length > 0) { throw new BadRequestException({ code: "EIMS_INVOICE_CONFIG_INVALID", message: `EIMS seller details would be rejected by MoR: ${bad.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; /** Source-system identity from the access token, never from configuration. */ session: EimsSessionContext; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; /** `DocumentDetails.Type` — defaults to "INV" in the mapper when omitted. */ documentType?: EimsMapperContext["documentType"]; /** Required (by the mapper) when documentType is DEB/CRE. */ reason?: string | null; /** `ReferenceDetails.RelatedDocument` — the original invoice's IRN, required for DEB/CRE. */ relatedDocument?: string | 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: input.session.systemNumber, systemType: input.session.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 }, // Per-`chargeType` override when one is configured (validated symmetric in // assertChargeTypeOverrides), else the single invoice-wide default. taxForLine: (line: EimsMapperLine) => { const { chargeType } = line; const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode; const rate = chargeType in invoice.taxRateByChargeType ? Number(invoice.taxRateByChargeType[chargeType]) : ratePercent; const excise = chargeType in invoice.exciseByChargeType ? Number(invoice.exciseByChargeType[chargeType]) : exciseTaxValue; const discount = chargeType in invoice.discountByChargeType ? Number(invoice.discountByChargeType[chargeType]) : 0; return { code, ratePercent: rate, exciseTaxValue: excise, discount }; }, natureOfSupplies: invoice.natureOfSupplies, unitDefault: invoice.unitDefault, incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, buyerCountryCodes: invoice.buyerCountryCodes, buyerRegionCodes: invoice.buyerRegionCodes, buyerWeredaCodes: invoice.buyerWeredaCodes, buyerCityCodes: invoice.buyerCityCodes, // TEMPORARY — see EimsInvoiceConfig.buyerIdType. buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, exchangeRate: input.exchangeRate ?? null, documentType: input.documentType, reason: input.reason ?? null, relatedDocument: input.relatedDocument ?? null, }; }