mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +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>
150 lines
6.3 KiB
TypeScript
150 lines
6.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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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 },
|
|
// 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,
|
|
buyerRegionCodes: invoice.buyerRegionCodes,
|
|
exchangeRate: input.exchangeRate ?? null,
|
|
};
|
|
}
|