Files
edr-platform/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts

179 lines
6.0 KiB
TypeScript

/**
* 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);
}