mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
Three live attempts turned six guesses into facts. Each fix below is the
gateway's own words, not a reading of the collection.
DocumentNumber and InvoiceCounter move differently, because MoR constrains
them differently. The counter must not skip -- "Invoice counter is not
correct. expected : 1" -- so a definitively refused document hands it back.
The document number must not repeat, so the attempt burns it. Both stay spent
after an ambiguous result, where MoR may have stored the document.
NatureOfSupplies is normalised to MoR's exact lowercase constant and rejected
outright if it is neither 'goods' nor 'service'; its schema branches on this
as a oneOf, so "Service" invalidated the whole ItemList.
Buyer region resolves through a name->code map and now FAILS locally when
unmapped. MoR validates Region against ^[0-9]{1,3}$ on both the seller and
buyer sides, so a name can never be sent and a guessed code on a tax document
is worse than refusing to file.
Seller phone, email, region and wereda are checked against MoR's own regexes
before anything is sent, so a placeholder like "_" fails locally instead of
costing a request and a counter.
EIMS_TAX_CODE stays required and unset in .env.example: the choice between
VAT0 (zero-rated) and VATEX (exempt) is a tax position awaiting finance, and
MoR's enum is recorded there for whoever decides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
8.3 KiB
TypeScript
203 lines
8.3 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;
|
|
/**
|
|
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
|
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
|
* locally rather than being filed with a guessed one.
|
|
*/
|
|
buyerRegionCodes: Record<string, string>;
|
|
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;
|
|
};
|
|
|
|
/** "Addis Ababa=13,Oromia=4" → { "Addis Ababa": "13", Oromia: "4" }. */
|
|
const parseRegionCodes = (raw: string | undefined): Record<string, string> => {
|
|
const map: Record<string, string> = {};
|
|
for (const pair of (raw ?? "").split(",")) {
|
|
const [name, code] = pair.split("=");
|
|
if (name?.trim() && code?.trim()) map[name.trim()] = code.trim();
|
|
}
|
|
return map;
|
|
};
|
|
|
|
/** 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,
|
|
buyerRegionCodes: parseRegionCodes(process.env.EIMS_BUYER_REGION_CODES),
|
|
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;
|
|
});
|