/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * Shared comparison helpers for maker-checker review screens. * * Extracted verbatim from ProductEditDiffDialogComponent, which had already solved the awkward part: * a naive comparison of a submitted command against the stored entity reports almost every field as * changed, because the two sides encode the same value differently. The normalisation below is what * keeps a diff down to the fields that genuinely moved: * * - dates arrive as `[yyyy, m, d]` from the API but as `"yyyy-MM-dd"` in the payload * - option/enum fields come back as `{ id, value }` but are submitted as a bare id * - `null`, `undefined`, `''`, `[]` and `{}` all mean "not set" * - numeric strings and numbers are the same value * * Product-specific concerns (GL account mappings, `Options` lookups, currency aliases) stay in * the product dialog - they have no meaning for a role or a client. */ /** * Keys that are never a setting a checker reviews, in two groups. * * The first is request plumbing - how the command was encoded, plus the identifiers naming the record * it applies to, both of which the screen already shows. * * The second is values derived from another field rather than entered. An age is computed from * `dateOfBirth` at the moment it is read, so a command submitted last month always disagrees with the * entity read today - a difference caused by the calendar, not by the maker. The field the maker can * actually change (`dateOfBirth`) is still compared, so a real edit is never hidden by this. */ export const COMMAND_NOISE_KEYS: ReadonlySet = new Set([ // request plumbing 'locale', 'dateFormat', 'monthDayFormat', 'id', 'productId', 'savingsProductId', // derived from dateOfBirth 'ageYears', 'ageMonths', 'ageDays', 'isMinor', 'isSeniorCitizen' ]); const MONTH_NAMES = [ 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december' ]; /** * Recognises every date shape these two sides use and returns "yyyy-MM-dd", or null if not a date. * * Three encodings are in play for the same day: * - `[yyyy, m, d]` what the read APIs return * - `"yyyy-MM-dd"` what some forms submit * - `"29 June 2026"` what Fineract commands carry, per the payload's own `dateFormat` * * Missing the third one makes every dated field read as changed on every request, which is exactly * the noise this whole comparison exists to remove. */ export function toIsoDate(value: any): string | null { if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) { const [ year, month, day ] = value; return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } if (typeof value !== 'string') { return null; } if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { return value; } const spelled = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(value.trim()); if (spelled) { const day = Number(spelled[1]); const monthName = spelled[2].toLowerCase(); const monthIndex = MONTH_NAMES.findIndex((name) => name === monthName || name.slice(0, 3) === monthName); if (monthIndex !== -1) { return `${spelled[3]}-${String(monthIndex + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } } return null; } /** * Folds a value into the one form both sides of a comparison can be measured in. See the class * comment above for why each case exists. */ export function normalizeValue(value: any): any { if (value === null || value === undefined || value === '') { return null; } const asDate = toIsoDate(value); if (asDate !== null) { return asDate; } if (Array.isArray(value)) { return value.length === 0 ? null : value.map((item) => normalizeValue(item)); } if (typeof value === 'object') { if (value.id !== undefined) { return normalizeValue(value.id); } return Object.keys(value).length === 0 ? null : JSON.stringify(value); } if (typeof value === 'boolean') { return value; } const asNumber = Number(value); return Number.isNaN(asNumber) ? value : asNumber; } /** * True when two values mean the same thing. * * The structural comparison handles most cases, but the two sides of a maker-checker review come * from different APIs that expand the same option differently: `/audits/{id}` returns an enum as its * label ("Daily") while the entity's own read API returns `{ id, code, value }`. Those normalise to * different things yet are the same setting, so a second pass asks the question that actually * matters to a checker - would these two render identically on screen? If they would, there is * nothing to report. */ export function sameValue(a: any, b: any): boolean { if (JSON.stringify(normalizeValue(a)) === JSON.stringify(normalizeValue(b))) { return true; } return displayScalar(a) === displayScalar(b); } /** camelCase / PascalCase / snake_case field name to a spaced, capitalised label. */ export function humanizeKey(key: string): string { const spaced = String(key ?? '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .replace(/[_-]+/g, ' ') .trim(); return spaced.charAt(0).toUpperCase() + spaced.slice(1); } /** * Renders one value for display, without any entity-specific option lookup. Arrays and objects are * summarised rather than dumped - a review row states *that* a collection changed; the detail belongs * in the entity's own screen. */ export function displayScalar(value: any): string { if (value === null || value === undefined || value === '') { return '—'; } const asDate = toIsoDate(value); if (asDate !== null) { return asDate; } if (Array.isArray(value)) { return value.length === 0 ? '—' : `${value.length} item${value.length === 1 ? '' : 's'}`; } if (typeof value === 'object') { if (Object.keys(value).length === 0) { return '—'; } if (value.name && value.glCode) { return `${value.name} (${value.glCode})`; } return value.value ?? value.name ?? value.code ?? (value.id !== undefined ? String(value.id) : '—'); } if (typeof value === 'boolean') { return value ? 'Yes' : 'No'; } return String(value); } /** * Sections a CREATE payload is bucketed into, so a checker reads a submitted product the way it was * entered rather than as one flat run of fields. Matching is by key name, tried in order, so the * grouping works for any entity - a role or a GL account simply lands everything in "Details". */ const CREATE_SECTIONS: { title: string; matches: RegExp }[] = [ { title: 'Accounting', matches: /account(ing)?|glaccount|writeOff|receivable|payable|suspense|escheat|fund/i }, { title: 'Charges & Fees', matches: /charge|fee|penalt|tax/i }, { title: 'Interest & Terms', matches: /interest|term|period|lockin|compound|annualis|maturity|deposit(Amount|Min|Max)/i }, { title: 'Balances & Limits', matches: /balance|overdraft|limit|amount|minimum|maximum|multiplesOf|decimal/i }, { title: 'Facilities & Features', matches: /facility|facilities|nominee|nomination|alert|banking|card|withdrawal/i } ]; const CREATE_DEFAULT_SECTION = 'General'; /** One named group of payload keys for a CREATE review. */ export interface CreateSection { title: string; keys: string[]; } /** * Buckets a CREATE payload's keys into readable sections. Keys the sections do not recognise fall * into "Details", which is also where everything lands for entities that have no product-like shape. * Empty sections are dropped, and section order is stable so the same entity always reads the same. */ export function groupCreateFields(payload: Record, ignoredKeys?: ReadonlySet): CreateSection[] { const ignored = ignoredKeys ?? COMMAND_NOISE_KEYS; const buckets = new Map(); buckets.set(CREATE_DEFAULT_SECTION, []); CREATE_SECTIONS.forEach((section) => buckets.set(section.title, [])); Object.keys(payload ?? {}) .filter((key) => !ignored.has(key)) .forEach((key) => { const section = CREATE_SECTIONS.find((candidate) => candidate.matches.test(key)); buckets.get(section ? section.title : CREATE_DEFAULT_SECTION).push(key); }); const ordered = [ CREATE_DEFAULT_SECTION, ...CREATE_SECTIONS.map((section) => section.title) ]; return ordered .map((title) => ({ title, keys: buckets.get(title) ?? [] })) .filter((section) => section.keys.length > 0); } /** * Reads the current value for a payload key, allowing for the two naming conventions the read APIs * use that the command payload does not: * * - lifecycle dates live under `timeline` (`timeline.submittedOnDate`) * - a payload `Id` is usually the expanded `` object on the entity * * Returns `undefined` when the key cannot be located at all, which the caller reports honestly * rather than claiming the field was newly added. */ export function currentValueFor(current: any, key: string): any { if (current && key in current) { return current[key]; } if (current?.timeline && key in current.timeline) { return current.timeline[key]; } if (key.endsWith('Id')) { const base = key.slice(0, -2); if (current && base in current) { return current[base]; } } return undefined; } /** * How a single field moved between the stored entity and the submitted command. * * `SUBMITTED` is not a change: it marks a field the command carries but which could not be located * on the entity, so no before-value exists to compare against. Calling those ADDED would tell the * checker something untrue. */ export type ChangeKind = 'ADDED' | 'REMOVED' | 'MODIFIED' | 'SUBMITTED'; export interface CommandChange { label: string; kind: ChangeKind; oldValue: string; newValue: string; } /** * Compares a submitted command payload against the entity as it stands today, returning only the * fields that actually moved. * * Only keys present in the payload are considered: a command says what the maker submitted, so a * field absent from it was not part of the request and must not be reported as removed. */ export function diffCommandAgainstEntity( submitted: Record, current: Record, options?: { ignoredKeys?: ReadonlySet; currentValueFor?: (key: string) => any } ): CommandChange[] { const ignored = options?.ignoredKeys ?? COMMAND_NOISE_KEYS; const readCurrent = options?.currentValueFor ?? ((key: string) => currentValueFor(current, key)); const knownOnEntity = (key: string) => !!current && (key in current || (!!current.timeline && key in current.timeline) || `${key}Id` in current); const rank: Record = { MODIFIED: 0, ADDED: 1, REMOVED: 2, SUBMITTED: 3 }; return ( Object.keys(submitted ?? {}) .filter((key) => !ignored.has(key)) .map((key) => ({ key, before: readCurrent(key), after: submitted[key] })) .filter(({ before, after }) => !sameValue(before, after)) .map(({ key, before, after }) => { const hadBefore = normalizeValue(before) !== null; const hasAfter = normalizeValue(after) !== null; // No before-value *and* no such field on the entity means there is nothing to compare, which is // different from a field that exists and was empty. const kind: ChangeKind = !hadBefore ? knownOnEntity(key) || !!options?.currentValueFor ? 'ADDED' : 'SUBMITTED' : !hasAfter ? 'REMOVED' : 'MODIFIED'; return { label: humanizeKey(key), kind, oldValue: displayScalar(before), newValue: displayScalar(after) }; }) // Real changes first, then the fields that could only be reported as submitted. .sort((a, b) => rank[a.kind] - rank[b.kind] || a.label.localeCompare(b.label)) ); }