diff --git a/.gitignore b/.gitignore index f18711d1f..ad22028c1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ uploads/document_templates/* !uploads/document_templates/index.php uploads/expenses/* !uploads/expenses/index.php +!uploads/expenses/.htaccess uploads/recurring_tickets/* !uploads/recurring_tickets/index.php uploads/settings/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a04f8077..e189fbc74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 2.9.8) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 2.9.9) in the same pass. Nothing else is required. -This is a big release, with nineteen database steps in one go, so take a backup before you update. +This is a big release, with twenty database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -21,6 +21,8 @@ It also gives every existing network a type (LAN, WAN, Guest and so on) guessed Payment reminders keep the schedule they had: 1 and 30 days late, then every 30 days until the invoice is paid. If reminders were on, they stay on, and every client is included. The schedule can now be changed in Admin > Settings > Invoice (see Breaking Changes, Billing emails). Monthly statements start switched off. +Expenses already entered take who entered them from the audit log, where it has a record of it. Expense receipts are no longer opened straight from the uploads folder - see Breaking Changes, Expenses. + If you are coming from 26.08 or earlier, the database update still has to be run from the command line one time, exactly as described under Upgrading to 26.09 below. ### Breaking Changes and Notes @@ -74,6 +76,11 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Copying an invoice, or making an invoice from a quote, is refused when a tracked product on it is short, the same as adding the line by hand. Add the stock first. - Invoices made from recurring invoices are now linked to their products, so they count as sold on the product's page and in the reports. They don't move stock. - Stock can be counted in fractions (up to two decimal places), such as cable by the metre. +- Expenses: + - Receipts open through ITFlow, for agents with Financial access to the expense, instead of straight from `uploads/expenses/`. An old link to a receipt file stops working. On Apache the folder is closed by the `.htaccess` that comes with the update. On nginx, deny `/uploads/expenses/` the way you deny `/uploads/tmp/` and `/uploads/backups/`. + - A new expense takes the currency of the account it is paid from. It used to take the company's currency whatever the account. + - The description is optional now. + - Bulk Set Category, Set Account and Set Client are one window each from the same Bulk menu, with Set Payment Method added. Bulk Delete is one button that asks once. - Notifications now follow access too. An agent only sees notifications for clients and areas they can open, so someone without sales access no longer sees invoice and quote notifications. - Drop-downs: Tom Select is gone, replaced by ITFlow's own picker. It works the way you are used to: type to search, tick several in a multi-select, and type a new value in fields that allow it, such as ticket watchers. See Developer Updates if you have custom pages that used Tom Select. - Several small windows are gone because the change is now made where the value is shown: @@ -304,6 +311,26 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Our vendors' pages have a Products tab listing what we buy from them. - Search finds products by part number and opens their page. - The starter catalog in Starter Content comes with how each item is billed. The demo data's products have costs, vendors and part numbers. +- **Expenses** (rebuilt) + - Along the top of the list: what was spent this month against the same days of last month, the year so far with its monthly average, what the running recurring expenses come to a month, and how many expenses in the last 90 days have no receipt. Each one opens the expenses behind it. + - Pills cut the list to expenses for a client, ones with no receipt, and refunds, each with a count. Filter by vendor, category, client and date, and under More filters by account and how it was paid. The total of everything the filters leave sits under the list. + - Each row shows the vendor with what it was for and its reference, the category with its colour, the client, the account and how it was paid, and a paperclip that opens the receipt. + - Clicking an expense opens its quick look: + - the receipt right in the window when it is a photo or a PDF + - who it was paid to, the category, account, payment method, reference and client + - who entered it and when + - the refunds against it + - the stock deliveries it paid for + - With no receipt yet, attach one right there - on a phone, take the picture. + - New Expense, Edit, Copy and Refund are one form with an icon on every field. Picking a vendor fills in the category, account and payment method of the last expense with them, unless you changed those yourself. + - The payment method is saved on each expense, starting from the default in Admin > Settings > Defaults. + - Copy dates the copy today and leaves off the reference and receipt. + - Refund starts at what hasn't come back yet and is linked to the expense, so each shows the other. + - A receipt can be replaced or removed in Edit. + - Bulk actions set the category, account, payment method or client on the ticked expenses, or delete them. Expenses of clients you can't see are left alone. + - Export takes the same filters as the list, adding client, payment method and the pills, with Paid By, Receipt and Entered By columns. + - Search finds expenses by what they were for, their reference or their amount, and opens the quick look. + - The vendor page's Expenses tab, Transactions and a product's stock history open the expense's quick look. Client Profitability links each client's direct costs to their expenses. - **Tickets** - The ticket list has views for Open, Mine, Unassigned, Needs reply, Waiting, Overdue, Scheduled, Closed and All. - Each row shows whose move it is (yours or the client's) along with SLA and due badges, and status, priority and agent change right in the row. @@ -480,6 +507,10 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Billing: the nightly late fee line was saved without its subtotal, and the invoice total was raised by hand instead of from its lines. - Billing: the email telling your team a quote was accepted or declined linked to a page that doesn't exist. It now opens the quote. - Billing: a batch payment's receipt went to the primary contact only, and sending it wrote a PHP warning. +- Expenses: receipts were served straight from `uploads/expenses/`, so anyone with a receipt's address could open it without signing in. +- Expenses: Bulk Delete's Yes, Delete! button did nothing. Bulk Set Client could change expenses of clients the agent is not allowed to see. +- Expenses: the New Expense window opened for agents who could not save one. Deleting an expense with no receipt, or replacing a receipt when there was none, wrote a PHP warning. +- Expenses: there was no way to set how an expense was paid, so the default payment method in Admin > Settings > Defaults went unused. A photo receipt downloaded with .pdf on its name. - Products: Restore in the archived list's bulk actions did nothing. Deleting a product left its stock records behind. - Products: Add Stock could record zero units, or link an expense that no longer existed. - Products: deleting an invoice did not put its stock back. Copying an invoice or making one from a quote lost the link to the product, so the copy took no stock and the product did not count it as sold. @@ -552,6 +583,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 2.9.6: `products.product_asset_type` (one of the asset types, NULL for none), `product_make`, `product_model` and `product_warranty_months`. `assets.asset_product_id`, `asset_invoice_id` and `asset_invoice_item_id`, keyed on the product and the invoice. - 2.9.7: `product_prices` - a product's price and cost history: `price_old`, `price_new`, `price_cost_old`, `price_cost_new`, `price_source` (`edit`, `import` or `receive`), `price_user_id` and `price_product_id`, keyed. - 2.9.8: `settings.config_invoice_reminder_days` ('1,30' - days from the due date, negative before it), `config_invoice_reminder_repeat` (30), `config_statement_monthly` (0), `config_statement_day` (1) and `config_billing_email_attach_pdf` (1), and `clients.client_reminders` (1). + - 2.9.9: `expenses.expense_created_by` (0 for cron and payment providers), backfilled from the audit log's Expense Create entries, and `expense_refund_of_id`. - New shared function files, loaded by `functions.php`: `functions/asset.php`, `asset_interface.php`, `certificate.php`, `client_overview.php`, `contact.php`, `credential.php`, `dashboard.php`, `links.php`, `location.php`, `notifications.php`, `project.php`, `rack.php`, `search.php`, `service.php`, `software.php`, `tags.php`, `ticket.php`, `ticket_fields.php`, `user.php` and `vendor.php`. They hold the queries and rules, so the pages, the API and cron share one definition. - Removed post models: `agent/post/{asset_interface,certificate,credential,domain,event,location,network_ip,network,recurring_ticket,vendor}_model.php`, `admin/post/user_model.php` and `api/v1/certificates/certificate_model.php`. Their handlers call the shared functions above. Interface handlers moved to `agent/post/asset_interface.php`. - New scripts: `js/itflow_select.js`, `quick_pick.js`, `tag_picker.js`, `global_search.js`, `notifications.js`, `dashboard.js`, `client_overview.js`, `client_switcher.js`, `credentials.js` and `autosubmit.js`, and `agent/js/calendar.js`, `calendar_event_modal.js`, `files.js`, `interface_form.js`, `network_form.js`, `project_modal.js` and `ticket_schedule_modal.js`. @@ -687,6 +719,18 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Price history: `productSave($in, $product_id, &$error, $source)` records a change to an existing product's price or cost through `productPriceRecord()` - `edit_product` passes `edit`, `import_products_csv` passes `import` and `product_stock_save` passes `receive` when a delivery sets the cost. Also `productPriceSources()`, `productPrices()` and `productPriceLastChange()`. `productDelete()` and the demo data removal delete the history too. - Repricing: `productRepriceKinds()`, `productRepriceUnionSql()` (every line from a product on recurring invoices not archived, draft invoices and draft quotes, as one union), `productRepriceOldSql()` (at a price in the product's history), `productRepriceFilterSql()`, `productRepriceCount($filters)` (lines, by kind, products, and the change a month on recurring invoices) and `productRepriceLines($filters)` (by product, with `old` and `suggested`). `productRepriceApply($changes, &$error)` checks every line and price, then changes them in one transaction through `billingItemSetPrice($type, $doc_id, $item_id, $price)` (new in `functions/billing.php` - the line's amounts at its tax rate, then `billingRecalc()`), with a history line on each document. - New page `agent/products_reprice.php` (`?product_id=`, `client_id`, `kind` and `doc_id`) posting `products_reprice_save` (`pick[]` of `kind:item_id`, and `price[kind:item_id]`). `dashboardAttention()` adds Lines at an old price, and the invoice, quote and recurring invoice pages link their own. CSS: `.itflow-reprice-*`. +- Expenses, in `functions/expenses.php` (loaded by `functions.php`): + - `expenseRow($id, $scoped = true)` reads an expense with its vendor, category, account, client and creator, `transfer_id` when it is the out side of a transfer, the expense it refunds and `refunded_amount`. It is scoped to the agent's clients unless told otherwise. + - `expenseSave($in, $id, &$error)` is the one path that writes an expense from a form - New, Edit, Copy and Refund. It checks the date, a non-zero amount, one of our vendors, an Expense category, an account, a client in scope and a listed payment method. An archived vendor, category or account is kept only when the expense already has it. `refund_of` makes the amount negative and sets `expense_refund_of_id`. A new expense takes the account's currency and records `expense_created_by`. + - `expenseDelete($id, &$error)` refuses a transfer's expense. Otherwise it removes the receipt, clears `product_stock.stock_expense_id` and refunds' `expense_refund_of_id`, then deletes. + - Receipts: `expenseReceiptTypes()`, `expenseReceiptPath()` (realpath-checked under `uploads/expenses/`), `expenseReceiptKind()`, `expenseReceiptName()`, `expenseReceiptStore($id, $file, &$error)` (replaces and deletes the old file) and `expenseReceiptRemove()`. + - The list and export share `expenseFiltersSql($src, &$summary)` (q, vendor, category, account, client, method, show, dtf, dtt, with the client scope and the vendor-only rule), `expenseJoinsSql()` and `expenseShows()`, the pills. + - Also `expenseStats()`, `expenseRecurringMonthlySql()`, `expenseVendorDefaults()` (the last expense's category, account and method by vendor), `expenseVendors()`, `expenseCategories()`, `expenseCategoryDot()` and `expenseLabel()`. + - New `agent/expense_receipt.php?expense_id=` serves the receipt after the Financial and client checks: inline with `&action=view` for photos and PDFs, otherwise as a download. `uploads/expenses/.htaccess` denies the folder, and `.gitignore` keeps it. + - New windows: the quick look `agent/modals/expense/expense.php?id=`, `expense_form.php` (the fields of `expense_add.php`, `expense_edit.php`, `expense_copy.php` and `expense_refund.php`) and `expense_bulk_edit.php?field=` (category, account, client or method). + - Handlers: `add_expense` (with `refund_of`) and `edit_expense` (with `receipt_remove`) post `vendor`, `amount`, `date`, `category`, `account`, `payment_method`, `client_id`, `reference`, `description` and `file`. New are `expense_receipt_save` (POST `expense_id`, `file`) and `bulk_edit_expenses` (POST `field`, `value`, `expense_ids[]`). `export_expenses` takes `client`, `method` and `show`, and the export adds `expense_payment_method`, `expense_receipt_display` and `user_name` columns. + - `agent/expenses.php` takes `?client=`, `?method=`, `?show=` and `?open=` (opens a quick look, as search does). `globalSearchGroups()` has an `expenses` group under `module_financial`. CSS: `.itflow-category-dot` and `.itflow-expense-receipt-empty`. + - Removed: `agent/post/expense_model.php`, `agent/modals/expense/expense_bulk_edit_category.php`, `expense_bulk_edit_account.php` and `expense_bulk_edit_client.php`, and the `bulk_edit_expense_category`, `bulk_edit_expense_account` and `bulk_edit_expense_client` handlers. - The billing helpers (`billingTimeInvoice()`, `quoteConvert()`, `recurringInvoiceGenerate()` and the rest) take the agent's name as plain text. Their handlers now pass `stripslashes($session_name)`, so a name with an apostrophe no longer shows a backslash in history and the audit log. - Sidebar brand: `agent/css/sidebar_brand_fix.css` and `agent/js/sidebar_brand_fix.js` from 26.10 are replaced by CSS in `css/itflow_custom.css`. `includes/load_company_settings.php` now sets `$session_company_logo`. - `alertMessageHtml()` no longer double-encodes, and save messages built from `escapeSql()` values use `stripslashes()` at the flash. diff --git a/admin/database_updates/2.9.9.php b/admin/database_updates/2.9.9.php new file mode 100644 index 000000000..19c32d4db --- /dev/null +++ b/admin/database_updates/2.9.9.php @@ -0,0 +1,22 @@ + 0 AND log_user_id > 0 + GROUP BY log_entity_id) AS created ON created.log_entity_id = expense_id + SET expense_created_by = created.user_id"); diff --git a/agent/expense_receipt.php b/agent/expense_receipt.php new file mode 100644 index 000000000..823b4121a --- /dev/null +++ b/agent/expense_receipt.php @@ -0,0 +1,50 @@ += 2; +$can_delete = lookupUserPermission('module_financial') >= 3; + +$vendor_filter = intval($_GET['vendor'] ?? 0); +$category_filter = intval($_GET['category'] ?? 0); +$account_filter = intval($_GET['account'] ?? 0); +$client_filter = intval($_GET['client'] ?? 0); +$method_filter = trim((string) ($_GET['method'] ?? '')); +$shows = expenseShows(); +$show = isset($shows[$_GET['show'] ?? '']) ? (string) ($_GET['show'] ?? '') : ''; + +// Only these sort - filter_header.php hands through any lowercase word +$sortable_columns = ['expense_date' => 'expense_date', 'vendor_name' => 'vendor_name', 'category_name' => 'category_name', 'client_name' => 'client_name', + 'account_name' => 'account_name', 'expense_amount' => 'expense_amount', 'expense_created_at' => 'expense_created_at']; +if (!isset($sortable_columns[$sort])) { + $sort = 'expense_date'; } -// Vendor Filter -if (isset($_GET['vendor']) & !empty($_GET['vendor'])) { - $vendor_query = 'AND (vendor_id = ' . intval($_GET['vendor']) . ')'; - $vendor_filter = intval($_GET['vendor']); -} else { - // Default - any - $vendor_query = ''; - $vendor_filter = ''; -} +// Everything but the pill, then the pill - the pill counts are over what the rest leave +$filter_src = array_merge($_GET, ['dtf' => $dtf, 'dtt' => $dtt, 'show' => '']); +$where = expenseFiltersSql($filter_src); +$show_query = $shows[$show]['where'] ? "AND {$shows[$show]['where']}" : ''; +$joins = expenseJoinsSql(); -// Category Filter -if (isset($_GET['category']) & !empty($_GET['category'])) { - $category_query = 'AND (category_id = ' . intval($_GET['category']) . ')'; - $category_filter = intval($_GET['category']); -} else { - // Default - any - $category_query = ''; - $category_filter = ''; -} - -$sql = mysqli_query( - $mysqli, - "SELECT SQL_CALC_FOUND_ROWS account_name, category_name, client_name, expense_account_id, expense_amount, - expense_category_id, expense_client_id, expense_created_at, expense_currency_code, - expense_date, expense_description, expense_id, expense_receipt, expense_reference, - expense_vendor_id, vendor_name FROM expenses - LEFT JOIN categories ON expense_category_id = category_id - LEFT JOIN vendors ON expense_vendor_id = vendor_id - LEFT JOIN accounts ON expense_account_id = account_id - LEFT JOIN clients ON expense_client_id = client_id - WHERE expense_vendor_id > 0 - AND DATE(expense_date) BETWEEN '$dtf' AND '$dtt' - $vendor_query - $category_query - AND (vendor_name LIKE '%$q%' OR client_name LIKE '%$q%' OR category_name LIKE '%$q%' OR account_name LIKE '%$q%' OR expense_description LIKE '%$q%' OR expense_amount LIKE '%$q%') - $account_query - " . clientScopeSql('expense_client_id') . " - ORDER BY $sort $order LIMIT $record_from, $record_to" -); +$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS expenses.*, vendor_name, vendor_archived_at, category_name, category_color, account_name, client_name, user_name + FROM expenses $joins + WHERE $where $show_query + ORDER BY {$sortable_columns[$sort]} $order, expense_id DESC LIMIT $record_from, $record_to"); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +$expenses = []; +while ($row = mysqli_fetch_assoc($sql)) { + $expenses[] = $row; +} + +// The pills, and the total of what is listed +$count_sql = []; +foreach ($shows as $key => $s) { + $count_sql[] = $key === '' ? 'COUNT(*)' : "COALESCE(SUM({$s['where']}), 0)"; +} +$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . ", + COALESCE(SUM(CASE WHEN 1 $show_query THEN expense_amount END), 0) + FROM expenses $joins WHERE $where")); +$counts = []; +foreach (array_keys($shows) as $i => $key) { + $counts[$key] = intval($row[$i]); +} +$listed_total = floatval($row[count($shows)]); + +$stats = expenseStats(); + +$filter_url = function ($changes) { + $params = array_merge($_GET, $changes); + unset($params['page']); + return '?' . http_build_query(array_filter($params, function ($v) { return $v !== '' && $v !== null; })); +}; +$date_url = function ($from, $to, $changes = []) use ($filter_url) { + return $filter_url(array_merge(['canned_date' => 'custom', 'dtf' => $from, 'dtt' => $to], $changes)); +}; +$stat_money = function ($amount) use ($session_company_currency) { + return escapeHtml(billingMoney($amount, $session_company_currency)); +}; + +// More filters open when one of them is set +$more_filters = $account_filter || $method_filter !== ''; +$any_filter = $q || $vendor_filter || $category_filter || $account_filter || $client_filter || $method_filter !== '' || $show !== '' || $dtf !== '1970-01-01'; + +// This month against the same days of last month +$month_change = $stats['last_to_date'] > 0 ? round(($stats['month'] - $stats['last_to_date']) / $stats['last_to_date'] * 100) : null; + ?> -
-
-

Expenses

-
-
- - - + + + +
+
+

Expenses

+
+
+ + + + +
- -
-
-
-
-
- - - -
-
-
-
- -
-
-
-
" id="advancedFilter"> -
-
-
- - - - - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
-
-
- -
- - "> - - - - - - - - - - - - - "; - } - - ?> - - - - - - - - - - - - - - - -
-
- -
-
- - Date - - - - Category - - / - - Description - - - - Vendor - - - - Amount - - - - Account - - - - Client - - Action
-
- -
-
- - - - - - -
-
true]) : '-' ?> - -
-
-
- +
+
+ +
+
+
+ + + +
+
+
+ + + + +
+
+ +
+
+ +
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
Date Vendor Category Client Paid from Amount
+
+ +
+
+ > + + + + + true, 'archived' => !empty($row['vendor_archived_at'])]) ?> +
+ +
+
+ +
+ + - + +
+ +
+
+ +
Refund
+
+ +
+ expense count($expenses) ? ' - the total is for all of them, not just this page' : '' ?> +
+
+ +
+ +
+
+ + +
+ + + + + + + + 'That expense no longer exists.']); + exit; +} + +$expense_id = intval($row['expense_id']); +$client_id = intval($row['expense_client_id']); +$can_edit = lookupUserPermission('module_financial') >= 2; +$can_delete = lookupUserPermission('module_financial') >= 3; +$is_transfer = !empty($row['transfer_id']); +$amount = floatval($row['expense_amount']); +$currency = (string) $row['expense_currency_code']; +$receipt_kind = $row['expense_receipt'] && expenseReceiptPath($row['expense_receipt']) !== '' ? expenseReceiptKind($row['expense_receipt']) : ''; +$receipt_missing = $row['expense_receipt'] && $receipt_kind === '' && expenseReceiptPath($row['expense_receipt']) === ''; +$receipt_url = "expense_receipt.php?expense_id=$expense_id"; + +// Refunds against it, and the stock it paid for +$refunds = []; +$sql = mysqli_query($mysqli, "SELECT expense_id, expense_date, expense_amount, expense_description FROM expenses WHERE expense_refund_of_id = $expense_id ORDER BY expense_date ASC, expense_id ASC"); +while ($refund = mysqli_fetch_assoc($sql)) { + $refunds[] = $refund; +} +$stock = []; +$sql = mysqli_query($mysqli, "SELECT stock_qty, stock_unit_cost, stock_created_at, product_id, product_name FROM product_stock + JOIN products ON product_id = stock_product_id + WHERE stock_expense_id = $expense_id ORDER BY stock_id ASC"); +while ($move = mysqli_fetch_assoc($sql)) { + $stock[] = $move; +} + +ob_start(); + +?> + + + + + + + +
diff --git a/agent/modals/expense/expense_bulk_delete.php b/agent/modals/expense/expense_bulk_delete.php index dab29f6c0..d60779698 100644 --- a/agent/modals/expense/expense_bulk_delete.php +++ b/agent/modals/expense/expense_bulk_delete.php @@ -1,41 +1,46 @@ 'Tick the expenses to delete first.']); + exit; +} -// Generate the HTML form content using output buffering. ob_start(); ?> +
- -