Files
edr-platform/apps/edr-freight-api/src/config/eims.config.ts
Hagernesh 67573d0835 feat(eims): file issued invoices on a cron sweep, off by default
Invoices are produced by the freight workflow rather than by a person, so the
production path for filing is a sweep, not the manual endpoint.

A @Cron picks the oldest never-submitted invoice and hands it to the existing
EimsInvoiceRegistrationService -- no registration logic is duplicated, and the
durable reservation still decides whether the submission may proceed. Sweeping
rather than hooking the eleven places an invoice can be created or issued keeps
the workflow untouched, puts the HTTP call outside the invoice transaction by
construction, and lets a crash or restart be picked up on the next tick.

invoices.eims_status is the queue; nothing new is persisted. Only NOT_SUBMITTED
is eligible: UNKNOWN is never retried automatically because the document may
already be filed, and FAILED waits for an explicit retry policy. The tick also
refuses to start while eims_system_state holds an in-flight submission or a
block, and only one invoice is filed per tick so a misconfiguration costs one
rejected document rather than a burst.

Requires both EIMS_ENABLED and EIMS_AUTO_SUBMIT; the second defaults to false
so authentication can be live long before filing is. Logs carry the invoice
number, status and IRN only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:08:40 +00:00

186 lines
7.6 KiB
TypeScript

import { registerAs } from "@nestjs/config";
/**
* Ethiopian MoR EIMS e-invoicing gateway.
*
* Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS
* service throws a clear error on use, so a deployment without credentials still boots.
*
* Secrets (client secret, API key) and the credential file paths live only here and are never
* logged — validation reports missing variable *names*, never their values.
*/
export interface EimsConfig {
enabled: boolean;
baseUrl: string;
clientId: string;
clientSecret: string;
apiKey: string;
tin: string;
/**
* Optional *expectations* for the source-system identity, not inputs.
*
* The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials
* that authenticated, and those are what registration uses. When these are set they are compared
* against the token and a mismatch fails fast — neither side silently wins. Leave them empty to
* take whatever the gateway says.
*/
systemNumber: string;
systemType: string;
/** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */
privateKeyPath: string;
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
certificatePath: string;
httpTimeoutMs: number;
/** Re-authenticate this many ms before the access token actually expires. */
tokenSkewMs: number;
/**
* Automatic submission of issued invoices, off by default.
*
* Invoices are produced by the workflow, so the production path is a sweep rather than a human
* action — but enabling it starts filing real documents with the tax authority, which is
* irreversible from our side. It therefore needs its own deliberate switch, separate from
* `EIMS_ENABLED`, so that authentication can be live long before filing is.
*/
autoSubmit: boolean;
autoSubmitCron: string;
/** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */
autoSubmitMaxAgeDays: number;
/**
* Seller identity and tax/business treatment for the invoice document.
*
* None of this is derivable from the database: EDR's own legal identity exists nowhere in the
* codebase, and the app models no tax at all. Values are required at registration time and are
* validated there rather than at boot, so a deployment can run with EIMS enabled for
* authentication before finance has signed off on the tax treatment.
*/
invoice: EimsInvoiceConfig;
}
export interface EimsInvoiceConfig {
sellerLegalName: string;
sellerVatNumber: string;
sellerPhone: string;
sellerEmail: string;
/** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */
sellerRegion: string;
sellerWereda: string;
sellerCity: string | null;
sellerSubCity: string | null;
sellerHouseNumber: string | null;
sellerLocality: string | null;
/** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */
taxCode: string;
taxRatePercent: number | null;
exciseTaxValue: number | null;
incomeWithholdValue: number | null;
transactionWithholdValue: number | null;
/** B2B / B2C — a tax classification, so it is configured, not inferred. */
transactionType: string;
natureOfSupplies: string;
paymentMode: string;
paymentTerm: string;
unitDefault: string;
buyerCountryCode: string | null;
cashierName: string | null;
salesPersonName: string | null;
}
const REQUIRED_VARS = [
"EIMS_CLIENT_ID",
"EIMS_CLIENT_SECRET",
"EIMS_API_KEY",
"EIMS_TIN",
"EIMS_PRIVATE_KEY_PATH",
"EIMS_CERTIFICATE_PATH",
] as const;
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
if (raw === undefined || raw === "") return fallback;
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
};
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
const optionalNumber = (raw: string | undefined, name: string): number | null => {
if (raw === undefined || raw === "") return null;
const value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`${name} must be a number`);
return value;
};
export default registerAs("eims", (): EimsConfig => {
const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true";
const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, "");
const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS");
const tokenSkewMs =
positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000;
const base: EimsConfig = {
enabled,
baseUrl,
clientId: process.env.EIMS_CLIENT_ID ?? "",
clientSecret: process.env.EIMS_CLIENT_SECRET ?? "",
apiKey: process.env.EIMS_API_KEY ?? "",
tin: process.env.EIMS_TIN ?? "",
systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "",
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
httpTimeoutMs,
tokenSkewMs,
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
// Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a
// misconfiguration from filing a burst of bad documents before anyone notices.
autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *",
autoSubmitMaxAgeDays: positiveInt(
process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS,
3,
"EIMS_AUTO_SUBMIT_MAX_AGE_DAYS",
),
invoice: {
sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "",
sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "",
sellerPhone: process.env.EIMS_SELLER_PHONE ?? "",
sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "",
sellerRegion: process.env.EIMS_SELLER_REGION ?? "",
sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "",
sellerCity: process.env.EIMS_SELLER_CITY || null,
sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null,
sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null,
sellerLocality: process.env.EIMS_SELLER_LOCALITY || null,
taxCode: process.env.EIMS_TAX_CODE ?? "",
taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"),
exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"),
incomeWithholdValue: optionalNumber(
process.env.EIMS_INCOME_WITHHOLD_VALUE,
"EIMS_INCOME_WITHHOLD_VALUE",
),
transactionWithholdValue: optionalNumber(
process.env.EIMS_TRANSACTION_WITHHOLD_VALUE,
"EIMS_TRANSACTION_WITHHOLD_VALUE",
),
transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "",
natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "",
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
},
};
if (!enabled) return base;
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
);
}
return base;
});