Files
itflow/agent/post/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

220 lines
9.0 KiB
PHP

<?php
/*
* ITFlow - importing the deposits on a bank statement as income
* (agent/income_import.php; functions/income_import.php does the work, and
* functions/expense_import.php reads the file).
*
* Upload reads the CSV into the session with a first guess at its columns -
* or the columns the account's last statement used, from either side, going
* straight to the review. Columns sets them; Import brings in the ticked
* deposits, sent from the review as one JSON field (picks). A statement read
* on one side can go on to the other without uploading it again.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (isset($_POST['income_import_upload'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$account_id = intval($_POST['account'] ?? 0);
$accounts = paymentAccounts();
if (!isset($accounts[$account_id])) {
flashAlert('Pick the account the deposits went into', 'error');
redirect('income_import.php');
}
$method = trim((string) ($_POST['payment_method'] ?? ''));
if ($method !== '' && !in_array($method, paymentMethods(), true)) {
$method = '';
}
$file = $_FILES['file'] ?? null;
if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) {
flashAlert(($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE ? 'That file is too big - export a month or two at a time' : 'Choose the statement file', 'error');
redirect('income_import.php');
}
if (!in_array(strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION)), ['csv', 'txt', 'tsv'], true)) {
flashAlert('Export the statement as a CSV file - that is a ' . escapeHtml(strtoupper(pathinfo((string) $file['name'], PATHINFO_EXTENSION)) ?: 'different') . ' file', 'error');
redirect('income_import.php');
}
$read = expenseImportRead($file['tmp_name'], $error);
if (!$read) {
flashAlert(escapeHtml($error), 'error');
redirect('income_import.php');
}
$map = expenseImportSavedMap($account_id, $read['header']);
$_SESSION['income_import'] = [
'account_id' => $account_id,
'method' => $method,
'file_name' => mb_substr(basename((string) $file['name']), 0, 120),
'header' => $read['header'],
'rows' => $read['rows'],
'map' => $map,
'guess' => $map ?: expenseImportGuess($read['header'], $read['rows']),
'remembered' => $map !== null,
];
redirect('income_import.php?step=' . ($map ? 'review' : 'columns'));
}
if (isset($_POST['income_import_columns'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$import = $_SESSION['income_import'] ?? null;
if (!$import) {
flashAlert('Upload the statement again - the one you started is gone', 'error');
redirect('income_import.php');
}
$map = expenseImportMapClean($_POST, count($import['header']));
if (!$map) {
flashAlert('Pick the columns with the date, the description and the amount', 'error');
redirect('income_import.php?step=columns');
}
if (!array_filter(expenseImportLines($import['rows'], $map), function ($line) { return $line['problem'] === ''; })) {
$_SESSION['income_import']['guess'] = $map;
flashAlert("None of the lines read with those columns - check the date format and the amount", 'error');
redirect('income_import.php?step=columns');
}
$_SESSION['income_import']['map'] = $map;
$_SESSION['income_import']['guess'] = $map;
$_SESSION['income_import']['remembered'] = false;
expenseImportSaveMap($import['account_id'], $import['header'], $map);
redirect('income_import.php?step=review');
}
if (isset($_POST['income_import_save'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$import = $_SESSION['income_import'] ?? null;
if (!$import || !$import['map']) {
flashAlert('Upload the statement again - the one you started is gone', 'error');
redirect('income_import.php');
}
$picks = [];
foreach ((array) json_decode((string) ($_POST['picks'] ?? ''), true) as $pick) {
if (is_array($pick) && isset($pick['n'])) {
$picks[intval($pick['n'])] = [
'take' => !empty($pick['take']),
'action' => (string) ($pick['action'] ?? ''),
'client' => intval($pick['client'] ?? 0),
'invoices' => array_map('intval', (array) ($pick['invoices'] ?? [])),
'category' => intval($pick['category'] ?? 0),
'account' => intval($pick['account'] ?? 0),
];
}
}
$result = incomeImportSave($import, $picks, ['receipts' => !empty($_POST['receipts'])], $error);
if (!$result) {
flashAlert(escapeHtml($error), 'error');
redirect('income_import.php?step=review');
}
unset($_SESSION['income_import']);
$account_name = (string) getFieldById('accounts', intval($import['account_id']), 'account_name');
$currency = (string) getFieldById('accounts', intval($import['account_id']), 'account_currency_code') ?: $session_company_currency;
$count = $result['payments'] + $result['income'] + $result['transfers'];
logAudit("Income", "Import", "$session_name imported deposits from " . escapeSql($import['file_name'] . " for $account_name") . ": " . $result['payments'] . " payment(s), "
. $result['income'] . " other income, " . $result['transfers'] . " transfer(s)");
$parts = [];
if ($result['payments']) {
$parts[] = '<strong>' . escapeHtml(billingMoney($result['paid'], $currency)) . '</strong> paid on ' . ($result['invoices_paid'] + $result['invoices_part']) . ' invoice' . (($result['invoices_paid'] + $result['invoices_part']) === 1 ? '' : 's')
. ($result['invoices_part'] ? ' (' . $result['invoices_part'] . ' part paid)' : '');
}
if ($result['income']) {
$parts[] = '<strong>' . escapeHtml(billingMoney($result['income_total'], $currency)) . '</strong> other income';
}
if ($result['transfers']) {
$parts[] = $result['transfers'] . ' transfer' . ($result['transfers'] === 1 ? '' : 's') . ' in';
}
$notes = [];
if ($result['receipts']) {
$notes[] = $result['receipts'] . ' receipt' . ($result['receipts'] === 1 ? '' : 's') . ' emailed';
}
if ($result['linked']) {
$notes[] = $result['linked'] . ' matched to what was entered already';
}
if ($result['skipped']) {
$notes[] = $result['skipped'] . ' imported before';
}
flashAlert(($count ? implode(', ', $parts) . ' brought in' : 'Nothing brought in') . ($notes ? ' &middot; ' . escapeHtml(implode(', ', $notes)) : ''));
if (!$count) {
redirect('income.php');
}
redirect('income.php?' . http_build_query(['account' => intval($import['account_id']), 'canned_date' => 'custom', 'dtf' => $result['from'], 'dtt' => $result['to']]));
}
/* Start over - the statement in progress is dropped. */
if (isset($_GET['income_import_discard'])) {
validateCSRFToken();
unset($_SESSION['income_import']);
redirect('income_import.php');
}
/* The statement read for expenses, on to its deposits - and back - without uploading it again. */
if (isset($_GET['income_import_from_statement']) || isset($_GET['expense_import_from_statement'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$to_income = isset($_GET['income_import_from_statement']);
$from = $_SESSION[$to_income ? 'expense_import' : 'income_import'] ?? null;
if (!$from) {
flashAlert('Upload the statement again - the one you started is gone', 'error');
redirect($to_income ? 'income_import.php' : 'expense_import.php');
}
$_SESSION[$to_income ? 'income_import' : 'expense_import'] = [
'account_id' => $from['account_id'],
'method' => $to_income ? (string) $config_default_payment_method : (string) $config_default_expense_payment_method,
'file_name' => $from['file_name'],
'header' => $from['header'],
'rows' => $from['rows'],
'map' => $from['map'],
'guess' => $from['guess'],
'remembered' => $from['remembered'],
];
redirect(($to_income ? 'income_import.php' : 'expense_import.php') . '?step=' . ($from['map'] ? 'review' : 'columns'));
}
/* Forget what a payer was matched to. */
if (isset($_GET['income_import_rule_delete'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$rule_id = intval($_GET['income_import_rule_delete']);
$match = getFieldById('income_import_rules', $rule_id, 'import_rule_match');
mysqli_query($mysqli, "DELETE FROM income_import_rules WHERE import_rule_id = $rule_id");
if ($match !== null) {
logAudit("Income", "Import Rule Delete", "$session_name removed the deposit match for " . escapeSql($match));
}
flashAlert($match !== null ? 'Forgotten - <strong>' . escapeHtml($match) . '</strong> will be matched afresh next time' : 'That match was already gone', $match !== null ? 'success' : 'error');
redirect();
}