Files
itflow/js/income_import.js
johnnyq 47969cd3c2 Income Import Deposits: bring the money in on a bank statement in as payments on invoices, other income or transfers from the Income list's menu, reading the same CSV as the expense statement import with the columns kept on the account for both sides and a link from each side's review to the other without uploading again; each deposit is matched by an invoice number in its text or reference, the payer remembered from last time, a client's name in it, the only invoice or the oldest of one client's invoices owing the amount or everything one client owes together, a payment provider payout or a transfer from another account, and anything else as other income for a category and client; payments pay the client's oldest invoices first with one deposit paying several and a short one leaving the last part paid, picking a client ticks the invoices it pays and each line says what it pays off, two deposits on one invoice don't pay it twice and more than they owe blocks the import; deposits imported before are locked, ones matching a payment, other income, transfer or a day's payments entered by hand within 4 days are left out and linked when left out, and a payout into the payment provider's own account counts as recorded; every ticked deposit is checked before any is saved, invoices get their status and history, receipts can be emailed, the payment method is read from the text, and payments and other income say they came from a bank statement on their quick look; payments need write Sales access and client scope applies; remembered payers window with forget; the statement columns step moved to a shared include and the sign guess now reads deposit and charge words on the lines; new functions/income_import.php, agent/income_import.php, js/income_import.js and agent/post/income_import.php; database 3.0.6 adds payment_import_key, revenue_import_key and the income_import_rules table; CHANGELOG updated
2026-10-16 21:44:14 -04:00

380 lines
16 KiB
JavaScript

/*
* Import Deposits' review (agent/income_import.php?step=review).
*
* Each deposit starts as it was matched: a payment on a client's invoices,
* other income, or a transfer from another account - the details for what
* it is show beside it. A payment lists the client's invoices still owed;
* picking the client ticks the one owing the amount, the ones that add up
* to it, or the oldest first, and the line says what it pays off - in full,
* part paid, or how much more than they owe. The deposits are applied in
* order, so two deposits on one invoice don't pay it twice. A client or
* category picked for one deposit fills in the same payer's others not
* touched yet. Deposits already in are hidden until asked for. On Import
* every line's choices go as one JSON field.
*
* itflowIncomeImport({ currency: 'USD', locale: 'en-US', invoices: { client_id: [{ id, ref, balance, due, scope, currency }] } });
*/
function itflowIncomeImport(options) {
var opts = options || {};
var form = document.getElementById('incomeImportReview');
if (!form || form.dataset.wired) {
return;
}
form.dataset.wired = '1';
var rows = Array.prototype.slice.call(form.querySelectorAll('[data-import-row]'));
var all = form.querySelector('[data-import-all]');
var showAll = form.querySelector('[data-import-show-all]');
var summary = form.querySelector('[data-import-summary]');
var submit = form.querySelector('[data-import-submit]');
var submitLabel = form.querySelector('[data-import-submit-label]');
var picks = form.querySelector('[data-import-picks]');
var bulkCategory = form.querySelector('[data-income-bulk-category]');
var invoices = opts.invoices || {};
var byId = {};
Object.keys(invoices).forEach(function (client) {
invoices[client].forEach(function (invoice) {
byId[invoice.id] = invoice;
});
});
function money(amount, currency) {
try {
return new Intl.NumberFormat(opts.locale || undefined, { style: 'currency', currency: currency || opts.currency || 'USD' }).format(amount);
} catch (e) {
return amount.toFixed(2);
}
}
function cents(value) {
return Math.round(Number(value) * 100);
}
function part(row, name) {
return row.querySelector('[data-income-' + name + ']');
}
function take(row) {
return row.querySelector('[data-import-take]');
}
function action(row) {
var select = part(row, 'action');
return select ? select.value : '';
}
// The invoices ticked on a line, the oldest due first
function picked(row) {
var box = part(row, 'invoices');
if (!box) {
return [];
}
return Array.prototype.slice.call(box.querySelectorAll('input:checked')).map(function (input) {
return byId[input.value];
}).filter(Boolean).sort(function (a, b) {
return a.due === b.due ? a.id - b.id : (a.due < b.due ? -1 : 1);
});
}
// Which of a client's invoices a deposit pays: the one owing it, the fewest adding up to it, or the oldest first
function suggest(list, amount) {
var want = cents(amount);
var i;
for (i = 0; i < list.length; i++) {
if (cents(list[i].balance) === want) {
return [list[i].id];
}
}
var count = Math.min(list.length, 14);
var best = null;
for (var mask = 1; mask < (1 << count); mask++) {
var sum = 0;
var ids = [];
for (i = 0; i < count; i++) {
if (mask & (1 << i)) {
sum += cents(list[i].balance);
ids.push(list[i].id);
}
}
if (sum === want && (best === null || ids.length < best.length)) {
best = ids;
}
}
if (best) {
return best;
}
var total = 0;
var oldest = [];
for (i = 0; i < list.length; i++) {
oldest.push(list[i].id);
total += cents(list[i].balance);
if (total >= want) {
break;
}
}
return oldest;
}
// The client's invoices as ticks, with those given ticked
function drawInvoices(row, ticked) {
var box = part(row, 'invoices');
var client = part(row, 'payer').value;
var list = invoices[client] || [];
var n = row.getAttribute('data-n');
box.innerHTML = '';
list.forEach(function (invoice) {
var wrap = document.createElement('div');
wrap.className = 'form-check small mb-0';
var input = document.createElement('input');
input.type = 'checkbox';
input.className = 'form-check-input';
input.value = invoice.id;
input.id = 'incomeImport' + n + 'Invoice' + invoice.id;
input.checked = ticked.indexOf(invoice.id) !== -1;
var label = document.createElement('label');
label.className = 'form-check-label text-truncate d-block';
label.htmlFor = input.id;
label.title = invoice.scope || '';
label.textContent = invoice.ref + ' · owes ' + money(invoice.balance, invoice.currency) + ' · due ' + invoice.due;
wrap.appendChild(input);
wrap.appendChild(label);
box.appendChild(wrap);
});
box.hidden = list.length === 0;
}
// One line: the details for what it is, what is required when it is ticked
function tidy(row) {
var kind = action(row);
if (!kind) {
return;
}
var on = take(row).checked && !take(row).disabled;
row.querySelectorAll('[data-income-for]').forEach(function (block) {
block.hidden = block.getAttribute('data-income-for') !== kind;
});
part(row, 'payer').required = on && kind === 'payment';
part(row, 'category').required = on && kind === 'income';
part(row, 'account').required = on && kind === 'transfer';
row.classList.toggle('itflow-unbilled-off', !on);
}
function update() {
var count = 0;
var total = 0;
var kinds = { payment: 0, income: 0, transfer: 0 };
var left = {};
Object.keys(byId).forEach(function (id) {
left[id] = cents(byId[id].balance);
});
rows.forEach(function (row) {
tidy(row);
var on = take(row).checked && !take(row).disabled;
if (on) {
count++;
total += Number(row.getAttribute('data-amount'));
if (kinds[action(row)] !== undefined) {
kinds[action(row)]++;
}
}
if (action(row) !== 'payment') {
return;
}
// What this deposit pays off, after the deposits above it
var applies = part(row, 'applies');
var remaining = cents(row.getAttribute('data-amount'));
var parts = [];
picked(row).forEach(function (invoice) {
if (remaining <= 0 || left[invoice.id] <= 0) {
return;
}
var paying = Math.min(remaining, left[invoice.id]);
parts.push(paying === left[invoice.id] ? invoice.ref + ' in full' : invoice.ref + ' ' + money(paying / 100, invoice.currency) + ' of ' + money(left[invoice.id] / 100, invoice.currency));
if (on) {
left[invoice.id] -= paying;
}
remaining -= paying;
});
row.dataset.over = remaining > 0 ? '1' : '';
applies.className = 'small mt-1 ' + (remaining > 0 ? 'text-danger-emphasis' : 'text-body-secondary');
if (!part(row, 'payer').value) {
applies.textContent = '';
} else if (!parts.length && !picked(row).length) {
applies.textContent = (invoices[part(row, 'payer').value] || []).length ? 'Tick the invoices it pays' : 'Nothing of theirs is owed - bring it in as other income';
} else {
applies.textContent = (parts.length ? 'Pays ' + parts.join(', ') : '') + (remaining > 0 ? (parts.length ? ' - ' : '') + money(remaining / 100) + ' more than they owe' : '');
}
});
var said = [];
if (kinds.payment) {
said.push(kinds.payment + ' payment' + (kinds.payment === 1 ? '' : 's'));
}
if (kinds.income) {
said.push(kinds.income + ' other income');
}
if (kinds.transfer) {
said.push(kinds.transfer + ' transfer' + (kinds.transfer === 1 ? '' : 's'));
}
summary.textContent = count ? count + ' deposit' + (count === 1 ? '' : 's') + ' · ' + money(Math.round(total * 100) / 100) + ' - ' + said.join(', ') : 'Tick the deposits to import';
submitLabel.textContent = count ? 'Import ' + count : 'Import';
submit.disabled = count === 0;
if (all) {
var shown = rows.filter(function (row) { return !row.hidden && !take(row).disabled; });
var on = shown.filter(function (row) { return take(row).checked; }).length;
all.checked = on > 0 && on === shown.length;
all.indeterminate = on > 0 && on < shown.length;
}
}
// Start each pick where it was matched
rows.forEach(function (row) {
['action', 'payer', 'category', 'client', 'account'].forEach(function (name) {
var select = part(row, name);
if (select && select.getAttribute('data-value') !== null) {
var value = select.getAttribute('data-value');
if (value !== '' && select.querySelector('option[value="' + CSS.escape(value) + '"]:not([disabled])')) {
select.value = value;
}
}
});
if (part(row, 'invoices')) {
var given = (part(row, 'invoices').getAttribute('data-value') || '').split(',').filter(Boolean).map(Number);
drawInvoices(row, given);
}
if (row.hasAttribute('data-import-extra')) {
row.hidden = true;
}
});
// The same payer's other deposits not touched yet take what was picked for this one
function follow(row, name, value) {
var key = row.getAttribute('data-key');
if (!key) {
return;
}
rows.forEach(function (other) {
if (other === row || other.getAttribute('data-key') !== key || other.dataset.touched || !part(other, name) || part(other, name).value !== '' && part(other, name).value !== '0') {
return;
}
if (name === 'payer' && action(other) !== 'payment') {
return;
}
part(other, name).value = value;
if (name === 'payer') {
drawInvoices(other, suggest(invoices[value] || [], other.getAttribute('data-amount')));
}
});
}
form.addEventListener('change', function (e) {
var row = e.target.closest('[data-import-row]');
if (row && e.target.matches('[data-income-payer]')) {
row.dataset.touched = '1';
drawInvoices(row, suggest(invoices[e.target.value] || [], row.getAttribute('data-amount')));
follow(row, 'payer', e.target.value);
} else if (row && e.target.matches('[data-income-category]')) {
row.dataset.touched = '1';
follow(row, 'category', e.target.value);
} else if (row && e.target.matches('[data-income-action], [data-income-client], [data-income-account], [data-income-invoices] input')) {
row.dataset.touched = '1';
if (e.target.matches('[data-income-action]') && e.target.value !== 'payment' && part(row, 'invoices')) {
part(row, 'invoices').querySelectorAll('input').forEach(function (input) { input.checked = false; });
}
if (e.target.matches('[data-income-action]') && e.target.value === 'payment' && part(row, 'payer').value && !picked(row).length) {
drawInvoices(row, suggest(invoices[part(row, 'payer').value] || [], row.getAttribute('data-amount')));
}
}
if (e.target === bulkCategory) {
if (e.target.value !== '') {
rows.forEach(function (r) {
if (take(r).checked && action(r) === 'income') {
part(r, 'category').value = e.target.value;
}
});
}
e.target.value = '';
}
if (row && !e.target.matches('[data-import-take]') && !take(row).disabled && !take(row).checked && e.isTrusted) {
take(row).checked = true;
}
update();
});
if (all) {
all.addEventListener('change', function () {
rows.forEach(function (row) {
if (!row.hidden && !take(row).disabled) {
take(row).checked = all.checked;
}
});
update();
});
}
if (showAll) {
showAll.addEventListener('change', function () {
rows.forEach(function (row) {
if (row.hasAttribute('data-import-extra')) {
row.hidden = !showAll.checked;
if (row.hidden) {
take(row).checked = false;
}
}
});
update();
});
}
// Clicking a line (not its picks) ticks it
rows.forEach(function (row) {
row.addEventListener('click', function (e) {
if (e.target.closest('a, input, button, select, label, textarea, [data-income-invoices]')) {
return;
}
if (!take(row).disabled) {
take(row).checked = !take(row).checked;
update();
}
});
});
form.addEventListener('submit', function (e) {
if (!form.reportValidity()) {
e.preventDefault();
return;
}
// A payment needs its invoices, and no more than they owe
var wrong = rows.filter(function (row) {
return take(row).checked && !take(row).disabled && action(row) === 'payment' && (!picked(row).length || row.dataset.over);
});
rows.forEach(function (row) {
var box = part(row, 'invoices');
if (box) {
box.classList.toggle('is-invalid', wrong.indexOf(row) !== -1);
}
});
if (wrong.length) {
e.preventDefault();
wrong[0].scrollIntoView({ block: 'center' });
var applies = part(wrong[0], 'applies');
if (!picked(wrong[0]).length) {
applies.className = 'small mt-1 text-danger-emphasis';
applies.textContent = 'Tick the invoices it pays';
}
return;
}
var list = rows.map(function (row) {
return {
n: Number(row.getAttribute('data-n')),
take: take(row).checked && !take(row).disabled,
action: action(row),
client: action(row) === 'payment' ? (part(row, 'payer') ? part(row, 'payer').value : '') : (part(row, 'client') ? part(row, 'client').value : '0'),
invoices: action(row) === 'payment' ? picked(row).map(function (invoice) { return invoice.id; }) : [],
category: part(row, 'category') ? part(row, 'category').value : '',
account: part(row, 'account') ? part(row, 'account').value : ''
};
});
picks.value = JSON.stringify(list);
});
update();
}