Files
itflow/js/reconcile.js
johnnyq df668d83e6 Accounts Reconcile: reconcile an account against its bank or card statement from the account's menu on Accounts or Transactions - enter the statement's ending date and balance (what is owed, for a card) and notes, starting from where the last statement ended or the opening balance, with lines brought in from bank statements pre-ticked; tick what cleared with the cleared balance and difference updating as you go, each tick saved immediately so a statement can be finished later, pills for money in, money out, not ticked and ticked, search, tick and untick all shown, a bar keeping the difference and Finish in view, and Finish only once the difference is nothing; change the statement, discard it, or add a missing line with New Expense, New Income or New Transfer starting on the account; a history of reconciled statements with ending balance, lines cleared, money in and out and who reconciled it, with Undo on the latest putting it back in progress with its ticks; Transactions marks cleared lines linking to their statement, adds an Uncleared pill and a filter for one statement's lines, the export adds a Cleared column, and Accounts shows where each account is reconciled to; a cleared payment, other income, expense, transfer or reimbursement keeps its amount, date and account and can't be deleted until its statement is undone, which also blocks deleting its invoice or client (including through the API), refunding a cleared Stripe payment, bulk set account and bulk delete on cleared lines, and undoing a cleared reimbursement, with quick looks and edit windows showing where it cleared; the opening balance locks once an account has a reconciled statement and a ticked line moved to another account comes off its statement; reconciling needs write Financial access and undoing full access; new functions/reconcile.php, agent/reconcile.php, agent/post/reconcile.php and js/reconcile.js with ticking through agent/ajax.php; database 3.0.8 adds account_reconciliations and the reconciliation id on payments, revenues and expenses; demo data reconciles Operating Checking and the Business Visa monthly through last month; CHANGELOG updated
2026-10-16 23:12:22 -04:00

220 lines
8.0 KiB
JavaScript

/*
* Reconcile (agent/reconcile.php): tick the lines the statement shows. Each
* tick is saved as it is made (agent/ajax.php's reconcile_tick); the
* cleared balance and the difference follow, and Finish opens once the
* difference is nothing. The pills and the search box only change what is
* shown - Tick all shown and Untick all shown act on what they leave.
*
* itflowReconcile({ id: 12, csrf: '...', starting: 1000.00, ending: 1250.00,
* card: false, currency: 'USD', locale: 'en-US' });
*
* Money is counted in cents, so a long statement adds up exactly.
*/
function itflowReconcile(opts) {
var root = document.getElementById('reconcile');
if (!root || root.dataset.wired) {
return;
}
root.dataset.wired = '1';
var rows = Array.prototype.slice.call(root.querySelectorAll('tr[data-reconcile-line]'));
var format = new Intl.NumberFormat(opts.locale || undefined, { style: 'currency', currency: opts.currency || 'USD' });
var money = function (cents) {
return format.format(cents / 100);
};
// A card's balance below zero is what is owed on it
var balance = function (cents) {
return opts.card && cents < 0 ? money(-cents) + ' owed' : money(cents);
};
var toCents = function (value) {
return Math.round(parseFloat(value || 0) * 100);
};
var starting = toCents(opts.starting);
var ending = toCents(opts.ending);
var show = '';
var query = '';
var sent = 0;
var pending = 0;
var finishes = document.querySelectorAll('[data-reconcile-finish]');
var finishForm = document.getElementById('reconcileFinish');
var cleared = document.querySelector('[data-reconcile-cleared]');
var ticked = document.querySelector('[data-reconcile-ticked]');
var differences = document.querySelectorAll('[data-reconcile-difference]');
var differenceCard = document.querySelector('[data-reconcile-difference-card]');
var differenceNote = document.querySelector('[data-reconcile-difference-note]');
function box(row) {
return row.querySelector('[data-reconcile-tick]');
}
function matches(row, key) {
var amount = toCents(row.dataset.amount);
var on = box(row).checked;
return key === '' || (key === 'in' && amount > 0) || (key === 'out' && amount < 0)
|| (key === 'ticked' && on) || (key === 'unticked' && !on);
}
function searched(row) {
return query === '' || (row.dataset.search || '').indexOf(query) !== -1;
}
// What shows, the pill counts, and where the statement stands
function redraw() {
var counts = { '': 0, 'in': 0, 'out': 0, 'ticked': 0, 'unticked': 0 };
var inCount = 0;
var inCents = 0;
var outCount = 0;
var outCents = 0;
rows.forEach(function (row) {
var on = box(row).checked;
var amount = toCents(row.dataset.amount);
row.classList.toggle('itflow-reconcile-ticked', on);
if (on && amount > 0) {
inCount++;
inCents += amount;
} else if (on && amount < 0) {
outCount++;
outCents -= amount;
}
if (searched(row)) {
Object.keys(counts).forEach(function (key) {
if (matches(row, key)) {
counts[key]++;
}
});
}
row.hidden = !(searched(row) && matches(row, show));
});
Object.keys(counts).forEach(function (key) {
var badge = root.querySelector('[data-reconcile-count="' + key + '"]');
if (badge) {
badge.textContent = counts[key];
}
});
var clearedCents = starting + inCents - outCents;
var differenceCents = ending - clearedCents;
if (cleared) {
cleared.textContent = balance(clearedCents);
}
if (ticked) {
ticked.textContent = (inCount + outCount) + ' ticked - ' + money(inCents) + ' in, ' + money(outCents) + ' out';
}
differences.forEach(function (difference) {
difference.textContent = money(differenceCents);
difference.classList.toggle('text-success-emphasis', differenceCents === 0);
difference.classList.toggle('text-danger-emphasis', differenceCents !== 0);
});
if (differenceCard) {
differenceCard.classList.toggle('itflow-reconcile-balanced', differenceCents === 0);
}
if (differenceNote) {
differenceNote.textContent = differenceCents === 0
? 'It balances - Finish it'
: (differenceCents > 0 ? 'What is ticked comes to less than the statement' : 'What is ticked comes to more than the statement');
}
finishes.forEach(function (finish) {
finish.disabled = differenceCents !== 0 || pending > 0;
});
}
// Save ticks; on a refusal put them back as they were
function save(changed, tick) {
if (!changed.length) {
return;
}
var ticket = ++sent;
pending++;
redraw();
var undo = function (message) {
changed.forEach(function (row) {
box(row).checked = !tick;
});
pending--;
redraw();
itflowToast(message || 'That tick was not saved - try again.', 'error');
};
itflowPost('ajax.php', {
reconcile_tick: 1,
csrf_token: opts.csrf,
reconciliation_id: opts.id,
tick: tick ? 1 : 0,
lines: changed.map(function (row) {
return row.dataset.reconcileLine;
})
}, function (text) {
var result;
try {
result = JSON.parse(text);
} catch (e) {
result = null;
}
if (!result || result.error || !result.totals) {
undo(result && result.error);
return;
}
pending--;
// The latest answer is the one to trust for where it stands
if (ticket === sent && pending === 0) {
starting = toCents(result.totals.starting);
ending = toCents(result.totals.ending);
}
redraw();
}, function () {
undo();
});
}
root.addEventListener('change', function (e) {
var input = e.target.closest('[data-reconcile-tick]');
if (!input) {
return;
}
save([input.closest('tr')], input.checked);
});
root.querySelectorAll('[data-reconcile-all]').forEach(function (button) {
button.addEventListener('click', function () {
var tick = button.dataset.reconcileAll === '1';
var changed = rows.filter(function (row) {
return !row.hidden && box(row).checked !== tick && !box(row).disabled;
});
changed.forEach(function (row) {
box(row).checked = tick;
});
save(changed, tick);
});
});
root.querySelectorAll('[data-reconcile-show]').forEach(function (pill) {
pill.addEventListener('click', function (e) {
e.preventDefault();
show = pill.dataset.reconcileShow;
root.querySelectorAll('[data-reconcile-show]').forEach(function (other) {
other.classList.toggle('active', other === pill);
});
redraw();
});
});
var search = root.querySelector('[data-reconcile-search]');
if (search) {
search.addEventListener('input', function () {
query = search.value.trim().toLowerCase().replace(/\$/g, '');
redraw();
});
}
// Finishing with a tick still being saved would finish on the old ones
if (finishForm) {
finishForm.addEventListener('submit', function (e) {
if (pending > 0) {
e.preventDefault();
}
});
}
redraw();
}