mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 08:33:39 +00:00
179 lines
6.0 KiB
TypeScript
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);
|
|
}
|