333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
/**
|
|
* 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, `<field>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<string> = 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<string, any>, ignoredKeys?: ReadonlySet<string>): CreateSection[] {
|
|
const ignored = ignoredKeys ?? COMMAND_NOISE_KEYS;
|
|
const buckets = new Map<string, string[]>();
|
|
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 `<field>Id` is usually the expanded `<field>` 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<string, any>,
|
|
current: Record<string, any>,
|
|
options?: { ignoredKeys?: ReadonlySet<string>; 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<ChangeKind, number> = { 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))
|
|
);
|
|
}
|