/** * Presentation helpers for MoR EIMS tax documents (ADD-P001 print layout). * * The layout these serve is modelled on the Ministry's own portal rendering of a registered EDR * invoice (portal.mor.gov.et), which is the authoritative source for the bilingual field labels — * not on any one vendor's template. */ /** Ethiopian month names, index 0 = መስከረም. */ const ETHIOPIAN_MONTHS = [ "መስከረም", "ጥቅምት", "ኅዳር", "ታኅሣሥ", "ጥር", "የካቲት", "መጋቢት", "ሚያዝያ", "ግንቦት", "ሰኔ", "ሐምሌ", "ነሐሴ", "ጳጉሜ", ] as const; export interface EthiopianDate { year: number; month: number; day: number; } /** * Gregorian → Ethiopian, via Julian Day Number. * * JDN rather than day-of-year arithmetic because the Ethiopian new year drifts against September * 11/12 on the Gregorian leap cycle; JDN is the same conversion the passenger portal already uses. */ export function gregorianToEthiopian(date: Date): EthiopianDate { const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); const a = Math.floor((14 - month) / 12); const y = year + 4800 - a; const m = month + 12 * a - 3; const jdn = day + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045; // 1723856 is the JDN of 1 መስከረም 1 E.C. const r = (jdn - 1723856) % 1461; const n = (r % 365) + 365 * Math.floor(r / 1460); const ethYear = 4 * Math.floor((jdn - 1723856) / 1461) + Math.floor(r / 365) - Math.floor(r / 1460); const ethMonth = Math.floor(n / 30) + 1; const ethDay = (n % 30) + 1; return { year: ethYear, month: ethMonth, day: ethDay }; } /** `25-12-2018 ዓ/ም` — the numeric form the MoR portal prints beside the Gregorian date. */ export function formatEthiopianDate(value: Date | string | null | undefined): string { const date = value ? new Date(value) : null; if (!date || Number.isNaN(date.getTime())) return "-"; const { year, month, day } = gregorianToEthiopian(date); const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(day)}-${pad(month)}-${year} ዓ/ም`; } /** `ሐምሌ 25, 2018` — the long form, when a document has room for it. */ export function formatEthiopianDateLong(value: Date | string | null | undefined): string { const date = value ? new Date(value) : null; if (!date || Number.isNaN(date.getTime())) return "-"; const { year, month, day } = gregorianToEthiopian(date); return `${ETHIOPIAN_MONTHS[month - 1] ?? ""} ${day}, ${year}`; } /** `31-08-2026 G.C` — Gregorian, labelled the way the MoR portal labels it. */ export function formatGregorianDate(value: Date | string | null | undefined): string { const date = value ? new Date(value) : null; if (!date || Number.isNaN(date.getTime())) return "-"; const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} G.C`; } /** `10:58:30`, 24-hour, to match the portal's `ሰአት/Time` row. */ export function formatDocumentTime(value: Date | string | null | undefined): string { const date = value ? new Date(value) : null; if (!date || Number.isNaN(date.getTime())) return "-"; const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } const ONES = [ "", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", ]; const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"]; const SCALES: [number, string][] = [ [1_000_000_000, "billion"], [1_000_000, "million"], [1_000, "thousand"], ]; /** 0-999 in words. */ function underThousand(value: number): string { if (value < 20) return ONES[value]; if (value < 100) { const rest = value % 10; return TENS[Math.floor(value / 10)] + (rest ? `-${ONES[rest]}` : ""); } const rest = value % 100; return `${ONES[Math.floor(value / 100)]} hundred${rest ? ` and ${underThousand(rest)}` : ""}`; } /** Whole number in words. Returns "zero" for 0. */ export function numberToWords(value: number): string { const n = Math.floor(Math.abs(value)); if (n === 0) return "zero"; const parts: string[] = []; let remaining = n; for (const [scale, name] of SCALES) { const count = Math.floor(remaining / scale); if (count > 0) { parts.push(`${numberToWords(count)} ${name}`); remaining %= scale; } } if (remaining > 0) { // "and" only before a trailing sub-hundred group, matching how the amount reads aloud // ("three thousand seven hundred and fifty-nine", not "three thousand and seven hundred"). parts.push(parts.length > 0 && remaining < 100 ? `and ${underThousand(remaining)}` : underThousand(remaining)); } return parts.join(" "); } /** * `Total including Tax (in words)` — the legally required spelling-out of the payable amount. * * Computed here rather than read back from MoR: the Ministry renders its own copy on the portal, * but returns nothing carrying it on `/v1/register`, and the line has to print on a document that * may not be registered yet. */ export function amountInWords(value: number, currencyLabel = "Birr", fractionLabel = "Cents"): string { const amount = Number.isFinite(value) ? Math.abs(value) : 0; const birr = Math.floor(amount); // Round the remainder rather than truncate: 0.155 must read as sixteen cents, not fifteen. const cents = Math.round((amount - birr) * 100); // Rounding cents can carry into the next Birr (x.999 -> 100 cents). const [wholeBirr, wholeCents] = cents === 100 ? [birr + 1, 0] : [birr, cents]; const head = `${numberToWords(wholeBirr)} ${currencyLabel}`; const text = wholeCents > 0 ? `${head} and ${numberToWords(wholeCents)} ${fractionLabel}` : head; return text.charAt(0).toUpperCase() + text.slice(1); }