mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
220 lines
9.3 KiB
TypeScript
220 lines
9.3 KiB
TypeScript
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<string, string> }[] = [
|
|
{ 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,
|
|
buyerRegionCodes: invoice.buyerRegionCodes,
|
|
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
|
// 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,
|
|
};
|
|
}
|