feat(billing): render EIMS documents in the MoR tax-document layout

This commit is contained in:
Hagernesh
2026-09-01 04:28:48 +00:00
parent fe3d398757
commit ed839ebb92
10 changed files with 1137 additions and 37 deletions

View File

@@ -3,6 +3,7 @@ import { EimsConfig } from "../../config/eims.config";
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { EimsSessionContext } from "./eims-auth.service";
import {
EimsLineTax,
EimsMapperContext,
EimsMapperLine,
EimsSellerDetails,
@@ -172,12 +173,35 @@ export interface EimsContextInput {
relatedDocument?: string | null;
}
/**
* Tax treatment of one charge type: its per-`chargeType` override when one is configured
* (validated symmetric in `assertChargeTypeOverrides`), else the single invoice-wide default.
*
* Exported because the printed tax document has to state the same Tax Code, Excise and Discount
* per line that was filed with MoR, and it must be able to do so without a live EIMS session —
* `buildEimsContext` needs a system number from an access token, printing does not.
*/
export function resolveLineTax(config: EimsConfig, chargeType: string): EimsLineTax {
const { invoice } = config;
return {
code: invoice.taxCodeByChargeType[chargeType] ?? invoice.taxCode,
ratePercent:
chargeType in invoice.taxRateByChargeType
? Number(invoice.taxRateByChargeType[chargeType])
: invoice.taxRatePercent!,
exciseTaxValue:
chargeType in invoice.exciseByChargeType
? Number(invoice.exciseByChargeType[chargeType])
: (invoice.exciseTaxValue ?? 0),
discount:
chargeType in invoice.discountByChargeType
? Number(invoice.discountByChargeType[chargeType])
: 0,
};
}
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,
@@ -191,23 +215,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
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 };
},
taxForLine: (line: EimsMapperLine) => resolveLineTax(config, line.chargeType),
natureOfSupplies: invoice.natureOfSupplies,
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,

View File

@@ -1,8 +1,11 @@
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import {
InvoiceDocumentModel,
MorPartyDetails,
pngDataUrl,
} from "../billing/documents/invoice-document.service";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types";
@@ -20,7 +23,11 @@ import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-rece
* would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw
* surface as a 400 — there is nothing sensible to render instead.
*/
export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel {
export function toReceiptDocumentModel(
receipt: EimsReceipt,
invoice: Invoice,
config?: EimsConfig,
): InvoiceDocumentModel {
if (receipt.status !== EimsReceiptStatus.Registered) {
throw new Error(
`Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`,
@@ -45,6 +52,34 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice):
// if that default changes for an unrelated reason.
sealText: "EDR PAID",
extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }],
mor: config?.invoice
? {
titleAm: "የገንዘብ መቀበያ ደረሰኝ",
titleEn: "Cash Receipt Voucher",
saleType: config.invoice.transactionType,
systemNumber: req.SourceSystemNumber || config.systemNumber || null,
...parties(config, invoice),
payment: {
mode: req.TransactionDetails.ModeOfPayment,
typeMethod: config.invoice.paymentTerm,
receiverName: invoice.company?.name ?? null,
},
receipt: {
rrn: receipt.rrn ?? "",
reason: req.Reason,
collectedAmount: req.CollectedAmount,
// One row per invoice the payment covers — MoR's receipt is invoice-linked, so the
// printed voucher has to show which document(s) the money was applied to.
invoices: req.Invoices.map((line) => ({
irn: line.InvoiceIRN,
paymentCoverage: line.PaymentCoverage,
totalAmount: line.TotalAmount,
remainingAmount: line.RemainingAmount ?? 0,
paidAmount: line.InvoicePaidAmount,
})),
},
}
: null,
});
}
@@ -59,9 +94,63 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice):
// wrong here, so this is the one case that MUST override it.
sealText: "EDR",
extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }],
mor: config?.invoice
? {
titleAm: "ከተከፋይ ሒሳብ ላይ ለተቀነሰ ግብር የተሰጠ ደረሰኝ",
titleEn: "Withholding tax on payment",
...parties(config, invoice),
systemNumber: req.SourceSystemNumber || config.systemNumber || null,
withholding: {
receiptNumber: receipt.receiptNumber,
counter: req.ReceiptCounter,
reason: req.Reason,
type: req.WithholdDetail.Type,
invoiceCurrency: req.InvoiceDetail.Currency,
preTaxAmount: req.WithholdDetail.PreTaxAmount,
withheldAmount: req.WithholdDetail.WithholdingAmount,
systemType: req.SourceSystemType,
systemNumber: req.SourceSystemNumber || config.systemNumber || "",
},
}
: null,
});
}
/**
* `ከ / From` and `ለ / To` for a receipt. On a withholding receipt the seller is the withholding
* agent and the buyer the taxpayer, which is the same pair of blocks in the same order — the
* layout relabels them, so the mapping does not change.
*/
function parties(
config: EimsConfig,
invoice: Invoice,
): { seller: MorPartyDetails; buyer: MorPartyDetails } {
const seller = buildEimsSeller(config);
const company = invoice.company;
return {
seller: {
name: config.invoice.sellerLegalName || seller.LegalName,
city: seller.City,
subCity: seller.SubCity,
woreda: seller.Wereda,
kebele: seller.Locality,
houseNo: seller.HouseNumber,
tin: seller.Tin,
vatNumber: seller.VatNumber,
},
buyer: {
name: company?.name ?? "N/A",
city: company?.zone ?? null,
subCity: company?.zone ?? null,
woreda: company?.woreda ?? null,
kebele: company?.kebele ?? null,
houseNo: company?.houseNo ?? null,
tin: company?.tin ?? null,
vatNumber: company?.vatNumber ?? null,
},
};
}
function build(
receipt: EimsReceipt,
invoice: Invoice,
@@ -73,6 +162,7 @@ function build(
amount: number;
sealText: string;
extraSummary: Array<{ label: string; value: string | null }>;
mor?: InvoiceDocumentModel["mor"];
},
): InvoiceDocumentModel {
return {

View File

@@ -196,7 +196,7 @@ export class EimsReceiptService {
let model: ReturnType<typeof toReceiptDocumentModel>;
try {
model = toReceiptDocumentModel(receipt, invoice);
model = toReceiptDocumentModel(receipt, invoice, this.cfg);
} catch (err) {
// Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 —
// a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService