Files
itflow/functions/income_import.php
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

822 lines
41 KiB
PHP

<?php
/*
* ITFlow - importing the deposits on a bank statement as income.
*
* The statement is read, and its columns worked out and remembered on the
* account, by functions/expense_import.php - the same file can be brought in
* on both sides: its charges as expenses, its deposits here. Only the money
* in is a deposit.
*
* Each deposit becomes one of three things:
*
* - payment on one or more of a client's open invoices, the oldest due
* first, the last part paid when the deposit is short of them
* - income other income, with a category and the client it came from
* - transfer money moved in from another of our accounts - a payout of
* card payments from the payment provider's account, or savings
*
* It is matched, in this order, by an invoice number in its text; by what
* was picked for the same payer last time (income_import_rules, keyed on the
* start of the cleaned-up text, like the expense side's merchants); by a
* client's name in it; by being the only invoice, or the one client's open
* invoices together, owing exactly that much; and by looking like a payout
* or a transfer. A client's invoices are picked as the one owing the amount,
* the ones that add up to it, or the oldest first.
*
* Every deposit has a key, as the expense side's lines do, kept on the
* payments (payment_import_key) or the revenue (revenue_import_key) it came
* in as, so the same statement can't bring it in twice. A deposit not
* imported before but with the amount of a payment, other income or transfer
* entered by hand on the account within a few days - or of a day's payments
* together, one check paying several invoices - looks entered already: left
* out unless ticked, and if left out those take its key. A payout into the
* account the payment provider's payments are recorded in is recorded
* already.
*/
const INCOME_IMPORT_DUPLICATE_DAYS = 4;
/** May this agent record payments on invoices from a statement - the rule for adding a payment (Sales and Financial at 2). */
function incomeImportCanPay() {
return lookupUserPermission('module_sales') >= 2 && lookupUserPermission('module_financial') >= 2;
}
/** What a deposit can be brought in as. */
function incomeImportActions() {
return [
'payment' => 'Payment on invoices',
'income' => 'Other income',
'transfer' => 'Transfer from an account',
];
}
/**
* The deposits on a statement by a mapping - the lines with money in, each
* [n, date, description, match_text, amount (what came in, positive),
* reference, problem]. $charges gets how many lines are money out.
*/
function incomeImportLines(array $rows, array $map, &$charges = 0) {
$charges = 0;
$deposits = [];
foreach (expenseImportLines($rows, $map) as $line) {
if ($line['amount'] !== null && $line['amount'] > 0) {
$charges++;
continue;
}
if ($line['amount'] === null && $line['problem'] === '') {
continue;
}
$line['amount'] = $line['amount'] === null ? null : round(-$line['amount'], 2);
if ($line['problem'] === 'The amount is zero') {
continue;
}
$line['n'] = count($deposits);
$deposits[] = $line;
}
return $deposits;
}
/**
* Every invoice still owed that the agent may see - id => [invoice_id,
* client_id, client_name, ref, scope, due, currency, balance], the oldest
* due first.
*/
function incomeImportOpenInvoices() {
global $mysqli;
$invoices = [];
$sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_due, invoice_currency_code, invoice_client_id, client_name,
invoice_amount - COALESCE((SELECT SUM(payment_amount) FROM payments WHERE payment_invoice_id = invoice_id), 0) AS balance
FROM invoices JOIN clients ON client_id = invoice_client_id
WHERE invoice_status IN ('Sent', 'Viewed', 'Partial') AND invoice_archived_at IS NULL " . clientScopeSql('invoices.invoice_client_id') . "
HAVING balance > 0.004
ORDER BY invoice_due ASC, invoice_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$invoices[intval($row['invoice_id'])] = [
'invoice_id' => intval($row['invoice_id']),
'client_id' => intval($row['invoice_client_id']),
'client_name' => (string) $row['client_name'],
'prefix' => (string) $row['invoice_prefix'],
'number' => intval($row['invoice_number']),
'ref' => $row['invoice_prefix'] . $row['invoice_number'],
'scope' => (string) $row['invoice_scope'],
'due' => (string) $row['invoice_due'],
'currency' => (string) $row['invoice_currency_code'],
'balance' => round(floatval($row['balance']), 2),
];
}
return $invoices;
}
/** The remembered payers - match => [id, action, client_id, category_id, account_id]. */
function incomeImportRules() {
global $mysqli;
$rules = [];
$sql = mysqli_query($mysqli, "SELECT import_rule_id, import_rule_match, import_rule_action, import_rule_client_id, import_rule_category_id, import_rule_account_id FROM income_import_rules");
while ($row = mysqli_fetch_assoc($sql)) {
$rules[$row['import_rule_match']] = ['id' => intval($row['import_rule_id']), 'action' => (string) $row['import_rule_action'], 'client_id' => intval($row['import_rule_client_id']),
'category_id' => intval($row['import_rule_category_id']), 'account_id' => intval($row['import_rule_account_id'])];
}
return $rules;
}
/** How a deposit was paid, from its text - one of the payment methods set up when it says, else $default. */
function incomeImportMethod($text, $default = '') {
$methods = paymentMethods();
$text = mb_strtolower((string) $text);
$wants = [
'/\b(ach|direct dep|direct deposit|ccd|ppd|eft)\b/' => ['ACH', 'Bank Transfer', 'EFT'],
'/\b(wire|fedwire|swift)\b/' => ['Wire', 'Wire Transfer', 'Bank Transfer'],
'/\b(check|cheque|chk|mobile deposit|remote deposit|rdc|counter deposit)\b/' => ['Check', 'Cheque'],
'/\b(zelle|venmo|cash app)\b/' => ['Zelle', 'Bank Transfer'],
'/\b(cash)\b/' => ['Cash'],
];
foreach ($wants as $pattern => $names) {
if (preg_match($pattern, $text)) {
foreach ($names as $name) {
foreach ($methods as $method) {
if (strcasecmp($method, $name) === 0) {
return $method;
}
}
}
}
}
return in_array((string) $default, $methods, true) ? (string) $default : '';
}
/**
* The invoice numbers a deposit's text names - after an invoice prefix in
* use ("INV-1042", "inv 1042") or the word invoice ("Invoice #1042"). A bare
* number is a check number as often as not, so it doesn't count.
*/
function incomeImportInvoiceNumbers($text, array $prefixes) {
$stems = ['invoices', 'invoice', 'inv'];
foreach ($prefixes as $prefix) {
$stem = trim(mb_strtolower((string) $prefix), " -#:_/.");
if ($stem !== '' && preg_match('/^[\p{L}\d]+$/u', $stem)) {
$stems[] = $stem;
}
}
$stems = array_unique($stems);
usort($stems, function ($a, $b) { return mb_strlen($b) <=> mb_strlen($a); });
$alternation = implode('|', array_map(function ($s) { return preg_quote($s, '/'); }, $stems));
$numbers = [];
if (preg_match_all('/\b(?:' . $alternation . ')\s*(?:no\.?|number)?\s*[-#:_\/.]?\s*(\d{1,9}(?:\s*(?:,|&|and)\s*\d{1,9})*)\b/iu', (string) $text, $matches)) {
foreach ($matches[1] as $list) {
foreach (preg_split('/\s*(?:,|&|and)\s*/i', $list) as $number) {
if (ctype_digit($number) && intval($number) > 0) {
$numbers[intval($number)] = intval($number);
}
}
}
}
return array_values($numbers);
}
/**
* Which of a client's open invoices (oldest due first) a deposit pays - [ids,
* how]: the one owing exactly the amount ('exact'), the fewest that add up
* to it ('sum'), the oldest first until it is used up, the last part paid
* ('oldest'), or all of them when it is more than they owe ('over').
*/
function incomeImportPickInvoices(array $invoices, $amount) {
$invoices = array_values($invoices);
if (!$invoices || $amount <= 0) {
return [[], ''];
}
$cents = (int) round($amount * 100);
foreach ($invoices as $invoice) {
if ((int) round($invoice['balance'] * 100) === $cents) {
return [[$invoice['invoice_id']], 'exact'];
}
}
// The fewest that add up to it exactly - a check paying several
$count = min(count($invoices), 14);
$best = null;
for ($mask = 1; $mask < (1 << $count); $mask++) {
$sum = 0;
$ids = [];
for ($i = 0; $i < $count; $i++) {
if ($mask & (1 << $i)) {
$sum += (int) round($invoices[$i]['balance'] * 100);
$ids[] = $invoices[$i]['invoice_id'];
}
}
if ($sum === $cents && ($best === null || count($ids) < count($best))) {
$best = $ids;
}
}
if ($best) {
return [$best, 'sum'];
}
$total = 0;
$ids = [];
foreach ($invoices as $invoice) {
$ids[] = $invoice['invoice_id'];
$total += (int) round($invoice['balance'] * 100);
if ($total >= $cents) {
return [$ids, 'oldest'];
}
}
return [$ids, 'over'];
}
/**
* The names to look for in a deposit's text - id => ' its words ', the
* longest first, from expenseImportWords() (so "Ravenwood Dental Group, LLC"
* is " ravenwood dental group ").
*/
function incomeImportNameWords(array $names) {
$out = [];
foreach ($names as $id => $name) {
$words = expenseImportWords($name);
if ($words && mb_strlen(implode(' ', $words)) >= 4) {
$out[$id] = ' ' . implode(' ', $words) . ' ';
}
}
uasort($out, function ($a, $b) { return mb_strlen($b) <=> mb_strlen($a); });
return $out;
}
/** The id of the name that appears in a line's words - whole, or its first words with the last cut short ("RAVENWOOD DENTAL GRP"). 0 for none. */
function incomeImportNameMatch(array $line_words, array $name_words) {
$text = ' ' . implode(' ', $line_words) . ' ';
foreach ($name_words as $id => $words) {
if (strpos($text, $words) !== false) {
return $id;
}
}
foreach ($name_words as $id => $words) {
$want = explode(' ', trim($words));
if (count($want) < 2) {
continue;
}
for ($at = 0; $at < count($line_words); $at++) {
$ok = 0;
foreach ($want as $j => $word) {
$have = $line_words[$at + $j] ?? null;
if ($have === null || !($have === $word || (mb_strlen($have) >= 3 && strpos($word, $have) === 0))) {
break;
}
$ok++;
}
if ($ok >= 2 && ($ok === count($want) || !isset($line_words[$at + $ok]) || $ok >= 3)) {
return $id;
}
}
}
return 0;
}
/** The accounts payment providers record online payments in. */
function incomeImportProviderAccounts() {
global $mysqli;
$accounts = [];
$sql = mysqli_query($mysqli, "SELECT payment_provider_account, payment_provider_name FROM payment_providers WHERE payment_provider_active = 1 AND payment_provider_account > 0");
while ($row = mysqli_fetch_row($sql)) {
$accounts[intval($row[0])] = (string) $row[1];
}
return $accounts;
}
/** Does a deposit look like a payout of card payments, or a transfer between accounts? 'payout', 'transfer' or ''. */
function incomeImportKind($text) {
$text = mb_strtolower((string) $text);
if (preg_match('/\b(stripe|paypal|square ?inc|squareup|gocardless|intuit|qbms|quickbooks payments|authorize ?net|bankcard|merchant (deposit|settlement|services|bankcd)|payout)\b/u', $text)) {
return 'payout';
}
if (preg_match('/\b(transfer|xfer|xfr|trnsfr|tfr)\b|\bfrom (savings|checking|share|sav|chk)\b/u', $text)) {
return 'transfer';
}
return '';
}
/**
* Match each deposit - sets key (the match key), action, client_id,
* invoice_ids, how (exact, sum, oldest or over), category_id,
* from_account_id, method, matched ('number', 'rule', 'name', 'amount',
* 'payout', 'transfer' or '') and match_note to say why.
*/
function incomeImportMatch(array $lines, $account_id, $default_method = '') {
global $mysqli;
$account_id = intval($account_id);
$can_pay = incomeImportCanPay();
$open = $can_pay ? incomeImportOpenInvoices() : [];
$by_client = [];
$prefixes = [];
foreach ($open as $invoice) {
$by_client[$invoice['client_id']][] = $invoice;
$prefixes[$invoice['prefix']] = $invoice['prefix'];
}
$rules = incomeImportRules();
uksort($rules, function ($a, $b) { return mb_strlen($b) <=> mb_strlen($a); });
$clients = [];
$client_names = [];
$sql = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_archived_at IS NULL " . clientScopeSql('clients.client_id'));
while ($row = mysqli_fetch_row($sql)) {
$clients[intval($row[0])] = (string) $row[1];
}
$client_names = incomeImportNameWords($clients);
$accounts = paymentAccounts();
$account_names = [];
foreach ($accounts as $id => $account) {
if ($id === $account_id) {
continue;
}
// Words that tell one of our accounts from another - not "business" or "account"
$words = array_values(array_diff(expenseImportWords($account['account_name']), ['business', 'account', 'accounts', 'bank', 'main', 'company', 'our']));
if ($words) {
$account_names[$id] = $words;
}
}
$providers = incomeImportProviderAccounts();
$categories = incomeCategories();
// Clients whose open invoices together owe one amount - when no other client's owe the same
$client_totals = [];
foreach ($by_client as $client_id => $invoices) {
$cents = 0;
foreach ($invoices as $invoice) {
$cents += (int) round($invoice['balance'] * 100);
}
$client_totals[$cents][] = $client_id;
}
$balances = [];
foreach ($open as $invoice) {
$balances[(int) round($invoice['balance'] * 100)][] = $invoice['invoice_id'];
}
$money = function ($amount, $currency = '') {
return billingMoney($amount, $currency);
};
foreach ($lines as $i => $line) {
$text = trim($line['match_text'] . ' ' . $line['reference']);
$key = expenseImportMatchKey($line['match_text']);
$line += ['key' => $key, 'action' => 'income', 'client_id' => 0, 'invoice_ids' => [], 'how' => '', 'category_id' => 0, 'from_account_id' => 0, 'matched' => '', 'match_note' => ''];
$line['key'] = $key;
$line['method'] = incomeImportMethod($text, $default_method);
if ($line['problem'] !== '' || $line['amount'] === null) {
$lines[$i] = $line;
continue;
}
$amount = $line['amount'];
$cents = (int) round($amount * 100);
$pay = function ($client_id, $invoices, $matched, $note) use (&$line, $amount) {
[$ids, $how] = incomeImportPickInvoices($invoices, $amount);
$line['action'] = 'payment';
$line['client_id'] = $client_id;
$line['invoice_ids'] = $ids;
$line['how'] = $how;
$line['matched'] = $matched;
$line['match_note'] = $note;
};
// 1. An invoice number in it
$named = [];
foreach (incomeImportInvoiceNumbers($text, array_values($prefixes)) as $number) {
foreach ($open as $invoice) {
if ($invoice['number'] === $number) {
$named[] = $invoice;
}
}
}
if ($named) {
$client_id = $named[0]['client_id'];
$named = array_values(array_filter($named, function ($inv) use ($client_id) { return $inv['client_id'] === $client_id; }));
$refs = implode(', ', array_column($named, 'ref'));
$line['action'] = 'payment';
$line['client_id'] = $client_id;
$line['invoice_ids'] = array_column($named, 'invoice_id');
$owed = (int) round(array_sum(array_column($named, 'balance')) * 100);
$line['how'] = $owed === $cents ? (count($named) > 1 ? 'sum' : 'exact') : ($owed > $cents ? 'oldest' : 'over');
$line['matched'] = 'number';
$line['match_note'] = "$refs in the text";
$lines[$i] = $line;
continue;
}
// 2. What this payer was last time
$rule = null;
if ($key !== '') {
foreach ($rules as $match => $candidate) {
if ($key === $match || strpos($key, $match . ' ') === 0) {
$rule = $candidate;
break;
}
}
}
$kind = incomeImportKind($text);
if ($rule) {
$note = 'As last time';
if ($rule['action'] === 'transfer' && isset($accounts[$rule['account_id']]) && $rule['account_id'] !== $account_id) {
$line['action'] = 'transfer';
$line['from_account_id'] = $rule['account_id'];
$line['matched'] = 'rule';
$line['match_note'] = $note;
} elseif ($rule['action'] === 'payment' && isset($clients[$rule['client_id']]) && !empty($by_client[$rule['client_id']])) {
$pay($rule['client_id'], $by_client[$rule['client_id']], 'rule', $note);
} elseif (in_array($rule['action'], ['payment', 'income'], true) && ($rule['client_id'] === 0 || isset($clients[$rule['client_id']]))) {
$line['action'] = 'income';
$line['client_id'] = $rule['client_id'];
$line['category_id'] = isset($categories[$rule['category_id']]) ? $rule['category_id'] : 0;
$line['matched'] = 'rule';
$line['match_note'] = $rule['action'] === 'payment' ? 'From them before - nothing of theirs is owed now' : $note;
}
if ($line['matched'] !== '') {
$lines[$i] = $line;
continue;
}
}
// 3. A payout of card payments, or a transfer from another of our accounts
if ($kind === 'payout') {
$line['matched'] = 'payout';
$line['action'] = 'transfer';
if (isset($providers[$account_id])) {
$line['recorded'] = true;
$line['match_note'] = 'A payout of ' . $providers[$account_id] . ' payments, recorded in this account already';
} elseif ($providers) {
$line['from_account_id'] = array_key_first($providers);
$line['match_note'] = 'A payout from ' . reset($providers);
} else {
$line['match_note'] = 'Looks like a payout of card payments';
}
$lines[$i] = $line;
continue;
}
if ($kind === 'transfer') {
$line['matched'] = 'transfer';
$line['action'] = 'transfer';
$line_words = expenseImportWords($text);
foreach ($account_names as $id => $words) {
if (array_intersect($words, $line_words)) {
$line['from_account_id'] = $id;
break;
}
}
$line['match_note'] = $line['from_account_id'] ? 'Looks like a transfer from ' . $accounts[$line['from_account_id']]['account_name'] : 'Looks like a transfer';
$lines[$i] = $line;
continue;
}
// 4. A client's name in it
$client_id = incomeImportNameMatch(expenseImportWords($text), $client_names);
if ($client_id) {
if (!empty($by_client[$client_id])) {
$pay($client_id, $by_client[$client_id], 'name', $clients[$client_id] . ' is named in it');
} else {
$line['client_id'] = $client_id;
$line['matched'] = 'name';
$line['match_note'] = $clients[$client_id] . ' is named in it - nothing of theirs is owed';
}
$lines[$i] = $line;
continue;
}
// 5. The only invoice owing exactly that, or the one client whose open invoices add up to it
if (count($balances[$cents] ?? []) === 1) {
$invoice = $open[$balances[$cents][0]];
$line['action'] = 'payment';
$line['client_id'] = $invoice['client_id'];
$line['invoice_ids'] = [$invoice['invoice_id']];
$line['how'] = 'exact';
$line['matched'] = 'amount';
$line['match_note'] = 'The only invoice owing ' . $money($amount, $invoice['currency']);
} elseif (count($client_totals[$cents] ?? []) === 1 && count($by_client[$client_totals[$cents][0]]) > 1) {
$client_id = $client_totals[$cents][0];
$line['action'] = 'payment';
$line['client_id'] = $client_id;
$line['invoice_ids'] = array_column($by_client[$client_id], 'invoice_id');
$line['how'] = 'sum';
$line['matched'] = 'amount';
$line['match_note'] = 'All ' . $clients[$client_id] . ' owes';
} elseif (count($balances[$cents] ?? []) > 1 && count(array_unique(array_map(function ($id) use ($open) { return $open[$id]['client_id']; }, $balances[$cents]))) === 1) {
// Several of one client's invoices owe it - the oldest due
$invoice = $open[$balances[$cents][0]];
$line['action'] = 'payment';
$line['client_id'] = $invoice['client_id'];
$line['invoice_ids'] = [$invoice['invoice_id']];
$line['how'] = 'exact';
$line['matched'] = 'amount';
$line['match_note'] = 'The oldest of ' . count($balances[$cents]) . ' ' . $clients[$invoice['client_id']] . ' invoices owing ' . $money($amount, $invoice['currency']);
} elseif (count($balances[$cents] ?? []) > 1) {
$line['match_note'] = count($balances[$cents]) . ' invoices owe ' . $money($amount) . ' - pick the one';
}
$lines[$i] = $line;
}
return $lines;
}
/**
* Deposits already in - sets imported ('payment' or 'revenue' with
* imported_id, the first thing it came in as) or entered (entered_type and
* entered_ids: a payment, other income or transfer entered by hand on the
* account for the amount within INCOME_IMPORT_DUPLICATE_DAYS days, the
* closest, or a day's payments that add up to it), with entered_label.
*/
function incomeImportDuplicates($account_id, array $lines) {
global $mysqli;
$account_id = intval($account_id);
$hashes = array_filter(array_column($lines, 'key_hash'));
$imported = [];
if ($hashes) {
$in = "('" . implode("','", array_map('escapeSql', $hashes)) . "')";
$sql = mysqli_query($mysqli, "SELECT 'payment', payment_id, payment_import_key FROM payments WHERE payment_import_key IN $in
UNION ALL SELECT 'revenue', revenue_id, revenue_import_key FROM revenues WHERE revenue_import_key IN $in");
while ($row = mysqli_fetch_row($sql)) {
if (!isset($imported[$row[2]])) {
$imported[$row[2]] = [$row[0], intval($row[1])];
}
}
}
// What was entered by hand on the account around the statement's dates
$dates = array_filter(array_column($lines, 'date'));
$singles = [];
$days = [];
if ($dates) {
$from = date('Y-m-d', strtotime(min($dates) . ' -' . INCOME_IMPORT_DUPLICATE_DAYS . ' days'));
$to = date('Y-m-d', strtotime(max($dates) . ' +' . INCOME_IMPORT_DUPLICATE_DAYS . ' days'));
$sql = mysqli_query($mysqli, "SELECT payment_id, payment_date, payment_amount, CONCAT(COALESCE(invoice_prefix, ''), COALESCE(invoice_number, '')) AS what FROM payments
LEFT JOIN invoices ON invoice_id = payment_invoice_id
WHERE payment_account_id = $account_id AND payment_import_key IS NULL AND payment_archived_at IS NULL AND payment_date BETWEEN '$from' AND '$to'
ORDER BY payment_date, payment_id");
while ($row = mysqli_fetch_assoc($sql)) {
$singles[] = ['type' => 'payment', 'ids' => [intval($row['payment_id'])], 'date' => $row['payment_date'], 'cents' => (int) round($row['payment_amount'] * 100),
'label' => 'Payment on ' . ($row['what'] !== '' ? $row['what'] : 'an invoice') . ' on ' . $row['payment_date']];
$days[$row['payment_date']][] = ['id' => intval($row['payment_id']), 'cents' => (int) round($row['payment_amount'] * 100), 'what' => $row['what']];
}
$sql = mysqli_query($mysqli, "SELECT revenue_id, revenue_date, revenue_amount, revenue_description, category_name, transfer_id FROM revenues
LEFT JOIN categories ON category_id = revenue_category_id
LEFT JOIN transfers ON transfer_revenue_id = revenue_id
WHERE revenue_account_id = $account_id AND revenue_import_key IS NULL AND revenue_archived_at IS NULL AND revenue_date BETWEEN '$from' AND '$to'
ORDER BY revenue_date, revenue_id");
while ($row = mysqli_fetch_assoc($sql)) {
$what = $row['transfer_id'] !== null ? 'A transfer in' : (trim(strtok((string) $row['revenue_description'], "\n")) ?: ($row['category_name'] ?: 'Other income'));
$singles[] = ['type' => 'revenue', 'ids' => [intval($row['revenue_id'])], 'date' => $row['revenue_date'], 'cents' => (int) round($row['revenue_amount'] * 100),
'label' => $what . ' on ' . $row['revenue_date'], 'transfer' => $row['transfer_id'] !== null];
}
}
// A day's payments together - one check for several invoices
$groups = [];
foreach ($days as $date => $payments) {
if (count($payments) > 1) {
$groups[] = ['type' => 'payment', 'ids' => array_column($payments, 'id'), 'date' => $date, 'cents' => array_sum(array_column($payments, 'cents')),
'label' => 'Payments on ' . implode(', ', array_filter(array_column($payments, 'what'))) . ' on ' . $date];
}
}
$used = [];
foreach ($lines as $i => $line) {
$lines[$i]['imported'] = $imported[$line['key_hash']][0] ?? '';
$lines[$i]['imported_id'] = $imported[$line['key_hash']][1] ?? 0;
$lines[$i]['entered_type'] = '';
$lines[$i]['entered_ids'] = [];
$lines[$i]['entered_label'] = '';
if ($lines[$i]['imported'] !== '' || $line['problem'] !== '') {
continue;
}
$cents = (int) round($line['amount'] * 100);
foreach ([$singles, $groups] as $pool) {
$best = null;
foreach ($pool as $j => $candidate) {
if ($candidate['cents'] !== $cents || array_intersect_key(array_flip(array_map(function ($id) use ($candidate) { return $candidate['type'] . $id; }, $candidate['ids'])), $used)) {
continue;
}
$gap = abs((strtotime($candidate['date']) - strtotime($line['date'])) / 86400);
if ($gap <= INCOME_IMPORT_DUPLICATE_DAYS && ($best === null || $gap < $best[1])) {
$best = [$j, $gap];
}
}
if ($best) {
$candidate = $pool[$best[0]];
foreach ($candidate['ids'] as $id) {
$used[$candidate['type'] . $id] = true;
}
$lines[$i]['entered_type'] = $candidate['type'];
$lines[$i]['entered_ids'] = $candidate['ids'];
$lines[$i]['entered_label'] = $candidate['label'];
break;
}
}
}
return $lines;
}
/**
* Everything about a statement's deposits for the review - read, matched,
* keyed and checked for what is in already, each with a state: new,
* imported, entered, recorded (a payout into the payment provider's own
* account) or problem. $charges gets how many lines are money out.
*/
function incomeImportReview(array $import, &$charges = 0) {
$lines = incomeImportLines($import['rows'], $import['map'], $charges);
$lines = incomeImportMatch($lines, $import['account_id'], (string) ($import['method'] ?? ''));
$lines = expenseImportKeys($import['account_id'], $lines);
$lines = incomeImportDuplicates($import['account_id'], $lines);
foreach ($lines as $i => $line) {
$lines[$i]['state'] = $line['problem'] !== '' ? 'problem' : ($line['imported'] !== '' ? 'imported' : ($line['entered_type'] !== '' ? 'entered' : (!empty($line['recorded']) ? 'recorded' : 'new')));
}
return $lines;
}
/** Bring a transfer in from another of our accounts - the money out of one, the money into the other, and the transfer joining them. The revenue's id. */
function incomeImportTransfer($from_account_id, $to_account_id, $date, $amount, $currency, $method, $notes, $key) {
global $mysqli, $session_user_id;
$amount = number_format($amount, 2, '.', '');
$currency = escapeSql($currency);
mysqli_query($mysqli, "INSERT INTO expenses SET expense_date = '$date', expense_amount = $amount, expense_currency_code = '$currency', expense_vendor_id = 0,
expense_category_id = 0, expense_account_id = " . intval($from_account_id) . ", expense_created_by = " . intval($session_user_id));
$expense_id = mysqli_insert_id($mysqli);
mysqli_query($mysqli, "INSERT INTO revenues SET revenue_date = '$date', revenue_amount = $amount, revenue_currency_code = '$currency', revenue_category_id = 0,
revenue_account_id = " . intval($to_account_id) . ", revenue_created_by = " . intval($session_user_id) . ", revenue_import_key = '" . escapeSql($key) . "'");
$revenue_id = mysqli_insert_id($mysqli);
mysqli_query($mysqli, "INSERT INTO transfers SET transfer_expense_id = $expense_id, transfer_revenue_id = $revenue_id, transfer_method = '" . escapeSql($method) . "',
transfer_notes = '" . escapeSql(mb_substr($notes, 0, 1000)) . "'");
return $revenue_id;
}
/**
* Bring in the ticked deposits. $picks is line n => [take, action, client,
* invoices (ids), category, account]; $opts receipts (email each client
* their receipt). Every ticked line is checked before any comes in: a
* payment needs Sales access, the client's open invoices and no more than
* they owe (the oldest due paid first, the last part paid); other income a
* category; a transfer another of our accounts. Each payer is remembered,
* and a deposit left out that looked entered already gives its key to what
* was entered. Returns [payments, invoices_paid, invoices_part, paid,
* income, income_total, transfers, transfer_total, receipts, linked,
* skipped, from, to] or null with $error.
*/
function incomeImportSave(array $import, array $picks, array $opts, &$error = '') {
global $mysqli, $session_user_id, $session_company_currency, $session_name;
$error = '';
$account_id = intval($import['account_id']);
$accounts = paymentAccounts();
if (!isset($accounts[$account_id])) {
$error = 'The account the statement is for no longer exists';
return null;
}
$currency = (string) ($accounts[$account_id]['account_currency_code'] ?: $session_company_currency);
$lines = incomeImportReview($import);
$can_pay = incomeImportCanPay();
$open = $can_pay ? incomeImportOpenInvoices() : [];
$categories = incomeCategories();
$clients = [];
$sql = mysqli_query($mysqli, "SELECT client_id FROM clients WHERE client_archived_at IS NULL " . clientScopeSql('clients.client_id'));
while ($row = mysqli_fetch_row($sql)) {
$clients[intval($row[0])] = true;
}
$who = escapeSql(stripslashes((string) $session_name));
// Check every ticked deposit first, working out what each payment leaves owing
$bring = [];
$balances = array_map(function ($invoice) { return (int) round($invoice['balance'] * 100); }, $open);
foreach ($lines as $line) {
$pick = $picks[$line['n']] ?? null;
if (!$pick || empty($pick['take']) || !in_array($line['state'], ['new', 'entered', 'recorded'], true)) {
continue;
}
$what = date('M j', strtotime($line['date'])) . ' ' . billingMoney($line['amount'], $currency);
$action = (string) ($pick['action'] ?? '');
$client = intval($pick['client'] ?? 0);
if ($client && !isset($clients[$client])) {
$error = "The client picked for $what isn't one you can open";
return null;
}
$item = ['line' => $line, 'action' => $action, 'client' => $client, 'category' => 0, 'account' => 0, 'payments' => []];
if ($action === 'payment') {
if (!$can_pay) {
$error = 'Recording payments on invoices takes write access to Sales - ' . $what . ' can come in as other income';
return null;
}
$ids = array_values(array_unique(array_filter(array_map('intval', (array) ($pick['invoices'] ?? [])))));
if (!$client || !$ids) {
$error = "Pick the client and the invoices $what pays";
return null;
}
$invoices = [];
foreach ($ids as $id) {
if (!isset($open[$id]) || $open[$id]['client_id'] !== $client) {
$error = "An invoice picked for $what isn't one of the client's still owed";
return null;
}
$invoices[] = $open[$id];
}
usort($invoices, function ($a, $b) { return [$a['due'], $a['invoice_id']] <=> [$b['due'], $b['invoice_id']]; });
$left = (int) round($line['amount'] * 100);
foreach ($invoices as $invoice) {
$owed = $balances[$invoice['invoice_id']];
if ($left <= 0 || $owed <= 0) {
continue;
}
$take = min($left, $owed);
$item['payments'][] = ['invoice' => $invoice, 'cents' => $take];
$balances[$invoice['invoice_id']] -= $take;
$left -= $take;
}
if ($left > 0) {
$error = "$what is " . billingMoney($left / 100, $currency) . ' more than the invoices picked owe - pick another, or bring it in as other income';
return null;
}
} elseif ($action === 'income') {
$category = intval($pick['category'] ?? 0);
if (!isset($categories[$category])) {
$error = "Pick the category for $what";
return null;
}
$item['category'] = $category;
} elseif ($action === 'transfer') {
$from = intval($pick['account'] ?? 0);
if (!isset($accounts[$from]) || $from === $account_id) {
$error = "Pick the account $what came from";
return null;
}
$item['account'] = $from;
} else {
$error = "Pick what $what is";
return null;
}
$bring[] = $item;
}
$statuses = [];
$result = ['payments' => 0, 'invoices_paid' => 0, 'invoices_part' => 0, 'paid' => 0.0, 'income' => 0, 'income_total' => 0.0, 'transfers' => 0, 'transfer_total' => 0.0,
'receipts' => 0, 'linked' => 0, 'from' => null, 'to' => null, 'skipped' => count(array_filter($lines, function ($l) { return $l['state'] === 'imported'; }))];
foreach ($bring as $item) {
$line = $item['line'];
$date = $line['date'];
$key = escapeSql($line['key_hash']);
$method = (string) $line['method'];
// The bank's text says who paid when it is more than "DEPOSIT" - it is the payment's reference when the statement has none
$words = array_diff(expenseImportWords($line['description']), ['deposit', 'deposits', 'mobile', 'remote', 'branch', 'counter', 'teller', 'incoming', 'item', 'items']);
$reference = mb_substr($line['reference'] !== '' ? $line['reference'] : ($words ? $line['description'] : ''), 0, 200);
if ($item['action'] === 'payment') {
foreach ($item['payments'] as $part) {
$invoice = $part['invoice'];
$invoice_id = $invoice['invoice_id'];
$amount = number_format($part['cents'] / 100, 2, '.', '');
mysqli_query($mysqli, "INSERT INTO payments SET payment_date = '$date', payment_amount = $amount, payment_currency_code = '" . escapeSql($invoice['currency'] ?: $currency) . "',
payment_account_id = $account_id, payment_method = " . ($method === '' ? 'NULL' : "'" . escapeSql($method) . "'") . ", payment_reference = '" . escapeSql($reference) . "',
payment_invoice_id = $invoice_id, payment_import_key = '$key'");
$status = updateInvoiceStatusFromPayments($invoice_id);
mysqli_query($mysqli, "INSERT INTO history SET history_status = '" . escapeSql((string) $status) . "', history_description = 'Payment added from a bank statement', history_invoice_id = $invoice_id");
logAudit("Invoice", "Payment", "$who added a payment of " . escapeSql(billingMoney($amount, $invoice['currency'])) . " to invoice " . escapeSql($invoice['ref']) . " from a bank statement", $invoice['client_id'], $invoice_id);
if (!empty($opts['receipts'])) {
$sent = billingPaymentSend($invoice_id, floatval($amount), ['date' => $date, 'method' => $method, 'reference' => $reference]);
if ($sent) {
$result['receipts']++;
logAudit("Invoice", "Payment", "Payment receipt for invoice " . escapeSql($invoice['ref']) . " queued to " . escapeSql(implode(', ', $sent)), $invoice['client_id'], $invoice_id);
}
}
triggerCustomAction('invoice_pay', $invoice_id);
$result['payments']++;
$statuses[$invoice_id] = $status;
}
$result['paid'] += $line['amount'];
} elseif ($item['action'] === 'income') {
mysqli_query($mysqli, "INSERT INTO revenues SET revenue_date = '$date', revenue_amount = " . number_format($line['amount'], 2, '.', '') . ", revenue_currency_code = '" . escapeSql($currency) . "',
revenue_account_id = $account_id, revenue_category_id = {$item['category']}, revenue_client_id = {$item['client']},
revenue_payment_method = " . ($method === '' ? 'NULL' : "'" . escapeSql($method) . "'") . ", revenue_reference = '" . escapeSql(mb_substr($line['reference'], 0, 200)) . "',
revenue_description = '" . escapeSql($line['description']) . "', revenue_created_by = " . intval($session_user_id) . ", revenue_import_key = '$key'");
$revenue_id = mysqli_insert_id($mysqli);
logAudit("Revenue", "Create", "$who added other income " . escapeSql(mb_substr($line['description'], 0, 80) . ' - ' . billingMoney($line['amount'], $currency)) . " from a bank statement", $item['client'], $revenue_id);
$result['income']++;
$result['income_total'] += $line['amount'];
} else {
incomeImportTransfer($item['account'], $account_id, $date, $line['amount'], $currency, $method !== '' ? $method : 'Bank Transfer', $line['description'], $line['key_hash']);
logAudit("Account Transfer", "Create", "$who brought in a transfer of " . escapeSql(billingMoney($line['amount'], $currency)) . " from " . escapeSql($accounts[$item['account']]['account_name'])
. " to " . escapeSql($accounts[$account_id]['account_name']) . " from a bank statement");
$result['transfers']++;
$result['transfer_total'] += $line['amount'];
}
$result['from'] = $result['from'] === null || $date < $result['from'] ? $date : $result['from'];
$result['to'] = $result['to'] === null || $date > $result['to'] ? $date : $result['to'];
if ($line['key'] !== '') {
mysqli_query($mysqli, "INSERT INTO income_import_rules SET import_rule_match = '" . escapeSql($line['key']) . "', import_rule_action = '{$item['action']}',
import_rule_client_id = {$item['client']}, import_rule_category_id = {$item['category']}, import_rule_account_id = {$item['account']}, import_rule_used_count = 1
ON DUPLICATE KEY UPDATE import_rule_action = VALUES(import_rule_action), import_rule_client_id = VALUES(import_rule_client_id),
import_rule_category_id = VALUES(import_rule_category_id), import_rule_account_id = VALUES(import_rule_account_id), import_rule_used_count = import_rule_used_count + 1");
}
}
// Deposits left out that were entered by hand already - those take their keys
foreach ($lines as $line) {
$pick = $picks[$line['n']] ?? null;
if ($line['state'] === 'entered' && (!$pick || empty($pick['take']))) {
$table = $line['entered_type'] === 'payment' ? 'payments' : 'revenues';
$column = $line['entered_type'] === 'payment' ? 'payment' : 'revenue';
mysqli_query($mysqli, "UPDATE $table SET {$column}_import_key = '" . escapeSql($line['key_hash']) . "' WHERE {$column}_id IN (" . implode(',', array_map('intval', $line['entered_ids'])) . ") AND {$column}_import_key IS NULL");
$result['linked'] += mysqli_affected_rows($mysqli) ? 1 : 0;
}
}
// Each invoice once, as it was left
$result['invoices_paid'] = count(array_filter($statuses, function ($status) { return $status === 'Paid'; }));
$result['invoices_part'] = count($statuses) - $result['invoices_paid'];
foreach (['paid', 'income_total', 'transfer_total'] as $total) {
$result[$total] = round($result[$total], 2);
}
return $result;
}