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

386 lines
15 KiB
PHP

<?php
/*
* ITFlow - GET/POST request handler for expenses
*
* New, Edit, Copy and Refund all post through expenseSave() in
* functions/expenses.php, with the receipt as `file`. The checks there keep
* an expense to one of our vendors, an expense category, an account and a
* client the agent can see.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (isset($_POST['add_expense'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$expense_id = expenseSave($_POST, 0, $error);
if (!$expense_id) {
flashAlert($error, 'error');
redirect();
}
$receipt_error = '';
expenseReceiptStore($expense_id, $_FILES['file'] ?? null, $receipt_error);
$row = expenseRow($expense_id, false);
$label = expenseLabel($row);
$refund = intval($row['expense_refund_of_id']) > 0;
logAudit("Expense", $refund ? "Refund" : "Create", "$session_name " . ($refund ? 'recorded a refund from ' : 'added the expense ') . escapeSql($label), intval($row['expense_client_id']), $expense_id);
if ($receipt_error) {
flashAlert(($refund ? 'Refund' : 'Expense') . " <strong>" . escapeHtml($label) . "</strong> added, but " . lcfirst(escapeHtml($receipt_error)), 'warning');
} else {
flashAlert(($refund ? 'Refund' : 'Expense') . " <strong>" . escapeHtml($label) . "</strong> added" . ($row['expense_receipt'] ? ' with its receipt' : ''));
}
redirect();
}
if (isset($_POST['edit_expense'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$expense_id = intval($_POST['expense_id'] ?? 0);
if (!expenseSave($_POST, $expense_id, $error)) {
flashAlert($error, 'error');
redirect();
}
$receipt_error = '';
if (!empty($_POST['receipt_remove'])) {
expenseReceiptRemove($expense_id);
} else {
expenseReceiptStore($expense_id, $_FILES['file'] ?? null, $receipt_error);
}
$row = expenseRow($expense_id, false);
$label = expenseLabel($row);
logAudit("Expense", "Edit", "$session_name edited the expense " . escapeSql($label), intval($row['expense_client_id']), $expense_id);
if ($receipt_error) {
flashAlert("Expense <strong>" . escapeHtml($label) . "</strong> saved, but " . lcfirst(escapeHtml($receipt_error)), 'warning');
} else {
flashAlert("Expense <strong>" . escapeHtml($label) . "</strong> saved");
}
redirect();
}
// A receipt added from the expense's quick look
if (isset($_POST['expense_receipt_save'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$row = expenseRow(intval($_POST['expense_id'] ?? 0));
if (!$row || $row['transfer_id']) {
flashAlert('That expense no longer exists', 'error');
redirect();
}
$expense_id = intval($row['expense_id']);
$receipt_error = '';
if (!expenseReceiptStore($expense_id, $_FILES['file'] ?? null, $receipt_error)) {
flashAlert($receipt_error ?: 'Choose the receipt to attach', 'error');
redirect();
}
logAudit("Expense", "Receipt", "$session_name attached a receipt to the expense " . escapeSql(expenseLabel($row)), intval($row['expense_client_id']), $expense_id);
flashAlert("Receipt attached to <strong>" . escapeHtml(expenseLabel($row)) . "</strong>");
redirect();
}
if (isset($_GET['delete_expense'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 3);
$row = expenseDelete(intval($_GET['delete_expense']), $error);
if (!$row) {
flashAlert($error, 'error');
redirect();
}
logAudit("Expense", "Delete", "$session_name deleted the expense " . escapeSql(expenseLabel($row)), intval($row['expense_client_id']));
flashAlert("Expense <strong>" . escapeHtml(expenseLabel($row)) . "</strong> deleted", 'error');
redirect();
}
/*
* Set one thing on the ticked expenses: field = category, account, client
* (0 takes the client off), method ('' clears it) or billable (yes, with
* markup, or no), to value. Expenses already billed on an invoice keep
* their client and stay billed.
*/
if (isset($_POST['bulk_edit_expenses'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$field = (string) ($_POST['field'] ?? '');
$value = trim((string) ($_POST['value'] ?? ''));
$expense_ids = array_unique(array_filter(array_map('intval', (array) ($_POST['expense_ids'] ?? []))));
// What the value is, and that it can be picked
$set = '';
$label = '';
if ($field === 'category') {
$categories = expenseCategories();
if (isset($categories[intval($value)])) {
$set = "expense_category_id = " . intval($value);
$label = "the category to " . $categories[intval($value)]['category_name'];
}
} elseif ($field === 'account') {
$accounts = paymentAccounts();
if (isset($accounts[intval($value)])) {
$set = "expense_account_id = " . intval($value) . ", expense_paid_by = 0";
$label = "the account to " . $accounts[intval($value)]['account_name'];
}
} elseif ($field === 'client') {
$client_name = intval($value) ? getFieldById('clients', intval($value), 'client_name') : '';
if (!intval($value)) {
$set = "expense_client_id = 0";
$label = "no client";
} elseif ($client_name !== null && mysqli_num_rows(mysqli_query($mysqli, "SELECT client_id FROM clients WHERE client_id = " . intval($value) . " " . clientScopeSql('client_id')))) {
$set = "expense_client_id = " . intval($value);
$label = "the client to $client_name";
}
if (!intval($value)) {
$set .= ", expense_billable = 0";
}
} elseif ($field === 'billable') {
$markup = billingNumberInput($_POST['markup'] ?? '');
if ($value === 'no') {
$set = "expense_billable = 0";
$label = "them not to bill on";
} elseif ($value === 'yes' && $markup !== null && $markup >= 0 && $markup <= 1000) {
$set = "expense_billable = 1, expense_markup = $markup";
$label = "them to bill on at " . rtrim(rtrim(number_format($markup, 2, '.', ''), '0'), '.') . "% markup";
} elseif ($value === 'yes') {
flashAlert('The markup is a percent, from 0 to 1000', 'error');
redirect();
}
} elseif ($field === 'method') {
if ($value === '') {
$set = "expense_payment_method = NULL";
$label = "no payment method";
} elseif (in_array($value, paymentMethods(), true)) {
$set = "expense_payment_method = '" . escapeSql($value) . "'";
$label = "the payment method to $value";
}
}
if ($set === '' || !$expense_ids) {
flashAlert($expense_ids ? 'Pick what to set them to' : 'Tick the expenses to change first', 'error');
redirect();
}
$count = 0;
$skipped = 0;
$cleared = 0;
foreach ($expense_ids as $expense_id) {
$row = expenseRow($expense_id);
if (!$row || $row['transfer_id'] || !intval($row['expense_vendor_id'])) {
continue;
}
// Cleared on a reconciled statement - it stays on its account
if ($field === 'account' && !reconcileCheckChange('expense', $expense_id, null, null, intval($value), $cleared_error)) {
$cleared++;
continue;
}
// Billed ones keep their client and stay billed; only a client's expenses can be billed on
if (($row['expense_invoice_id'] && in_array($field, ['client', 'billable'], true))
|| ($row['expense_reimbursement_id'] && $field === 'account')
|| ($field === 'billable' && $value === 'yes' && (!intval($row['expense_client_id']) || floatval($row['expense_amount']) <= 0))) {
$skipped++;
continue;
}
mysqli_query($mysqli, "UPDATE expenses SET $set WHERE expense_id = $expense_id");
logAudit("Expense", "Edit", "$session_name set " . escapeSql($label) . " on the expense " . escapeSql(expenseLabel($row)), intval($row['expense_client_id']), $expense_id);
$count++;
}
logAudit("Expense", "Bulk Edit", "$session_name set " . escapeSql($label) . " on $count expense" . ($count === 1 ? '' : 's'));
flashAlert("Set " . escapeHtml($label) . " on <strong>$count</strong> expense" . ($count === 1 ? '' : 's')
. ($skipped ? " - $skipped left as " . ($skipped === 1 ? 'it was' : 'they were') . ($field === 'billable' && $value === 'yes' ? ' (on an invoice already, or not for a client)' : ($field === 'account' ? ' (paid back to an agent already)' : ' (on an invoice already)')) : '')
. ($cleared ? " - $cleared left on " . ($cleared === 1 ? 'its account (it' : 'their accounts (they') . ' cleared on a reconciled statement)' : ''),
$count && !$cleared ? 'success' : 'warning');
redirect();
}
/*
* Take a billed expense off its invoice - the line comes off and it waits
* to be billed again. Not once the invoice is paid or cancelled.
*/
if (isset($_GET['expense_unbill'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
enforceUserPermission('module_sales', 2);
$expense_id = intval($_GET['expense_unbill']);
$row = expenseRow($expense_id);
$number = $row ? expenseUnbill($expense_id, $error) : '';
if (!$row || $number === '') {
flashAlert($row ? $error : 'That expense no longer exists', 'error');
redirect();
}
logAudit("Expense", "Unbill", "$session_name took the expense " . escapeSql(expenseLabel($row)) . " off invoice " . escapeSql($number), intval($row['expense_client_id']), $expense_id);
if ($row['invoice_status'] !== null) {
billingLog('invoice', intval($row['expense_invoice_id']), $row['invoice_status'], 'Expense taken off by ' . stripslashes($session_name) . ' - ' . expenseLabel($row));
}
flashAlert("Expense <strong>" . escapeHtml(expenseLabel($row)) . "</strong> taken off " . escapeHtml($number) . " - it is to bill again");
redirect();
}
if (isset($_POST['bulk_delete_expenses'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 3);
$count = 0;
$cleared = 0;
foreach (array_unique(array_filter(array_map('intval', (array) ($_POST['expense_ids'] ?? [])))) as $expense_id) {
$row = expenseDelete($expense_id, $delete_error);
if (!$row) {
$cleared += reconcileLock('expense', $expense_id) ? 1 : 0;
continue;
}
logAudit("Expense", "Delete", "$session_name deleted the expense " . escapeSql(expenseLabel($row)), intval($row['expense_client_id']));
$count++;
}
logAudit("Expense", "Bulk Delete", "$session_name deleted $count expense" . ($count === 1 ? '' : 's'));
flashAlert("Deleted <strong>$count</strong> expense" . ($count === 1 ? '' : 's') . ($cleared ? " - $cleared kept, cleared on a reconciled statement" : ''), 'error');
redirect();
}
/*
* Pay an agent back for what they paid personally - the ticked expenses
* (expense_ids[]) of one agent (user_id), from an account, on a date.
*/
if (isset($_POST['expense_reimburse'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 2);
$user_id = intval($_POST['user_id'] ?? 0);
$result = expenseReimburse($user_id, array_unique(array_filter(array_map('intval', (array) ($_POST['expense_ids'] ?? [])))), [
'account_id' => intval($_POST['account'] ?? 0),
'date' => (string) ($_POST['date'] ?? ''),
'payment_method' => (string) ($_POST['payment_method'] ?? ''),
'reference' => (string) ($_POST['reference'] ?? ''),
'by' => stripslashes($session_name),
], $error);
if (!$result) {
flashAlert(escapeHtml($error), 'error');
redirect();
}
flashAlert('<strong>' . escapeHtml($result['user_name']) . '</strong> paid back ' . escapeHtml(billingMoney($result['amount'], $result['currency']))
. ' for ' . ($result['count'] === 1 ? '1 expense' : $result['count'] . ' expenses'));
redirect();
}
/* Undo a reimbursement - its money out of the account goes, and the expenses are to reimburse again. */
if (isset($_GET['expense_reimbursement_delete'])) {
validateCSRFToken();
enforceUserPermission('module_financial', 3);
$row = reimbursementDelete(intval($_GET['expense_reimbursement_delete']), $error);
if (!$row) {
flashAlert(escapeHtml($error), 'error');
redirect();
}
$what = billingMoney($row['reimbursement_amount'], $row['reimbursement_currency_code']) . ' paid back to ' . $row['user_name'] . ' on ' . $row['reimbursement_date'];
logAudit("Expense", "Reimbursement Undo", "$session_name undid the " . escapeSql($what), 0, intval($row['reimbursement_id']));
flashAlert('Undone: <strong>' . escapeHtml($what) . '</strong> - its expenses are to reimburse again', 'error');
redirect();
}
if (isExportRequest('export_expenses')) {
validateCSRFToken();
// Exports are reads - see CONTRIBUTING.md
enforceUserPermission('module_financial');
$format = resolveExportFormat($_POST['export_expenses']);
// The same filters as the expenses list (functions/expenses.php)
$where = expenseFiltersSql($_POST, $filter_summary);
$sql = mysqli_query($mysqli, "SELECT expenses.*, vendor_name, category_name, account_name, client_name, user_name, invoice_prefix, invoice_number,
(SELECT payer.user_name FROM users AS payer WHERE payer.user_id = expense_paid_by) AS paid_by_name,
(SELECT reimbursement_date FROM reimbursements WHERE reimbursement_id = expense_reimbursement_id) AS reimbursement_date
FROM expenses " . expenseJoinsSql() . "
LEFT JOIN invoices ON invoice_id = expense_invoice_id
WHERE $where
ORDER BY expense_date ASC, expense_id ASC");
$num_rows = mysqli_num_rows($sql);
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
$export = beginExport('expenses', $format, "$session_company_name-Expenses", 'Expenses', summarizeExportFilters($filter_summary));
while ($row = mysqli_fetch_assoc($sql)) {
$row['expense_receipt_display'] = $row['expense_receipt'] ? 'Yes' : 'No';
$markup = rtrim(rtrim(number_format(floatval($row['expense_markup']), 2, '.', ''), '0'), '.') . '%';
$row['expense_billing_display'] = $row['expense_invoice_id'] ? "Billed, $markup markup" : (intval($row['expense_billable']) ? "To bill, $markup markup" : '');
$row['expense_billed_on'] = $row['expense_invoice_id'] && $row['invoice_number'] !== null ? $row['invoice_prefix'] . $row['invoice_number'] : '';
$row['expense_paid_by_display'] = intval($row['expense_paid_by']) ? ($row['paid_by_name'] ?: 'An agent') . ($row['reimbursement_date'] ? ', paid back ' . $row['reimbursement_date'] : ', to reimburse') : '';
addExportRow($export, $row);
}
finishExport($export);
}
logAudit("Expense", "Export", "$session_name exported $num_rows expense(s) to a " . strtoupper($format) . " file");
exit;
}