diff --git a/CHANGELOG.md b/CHANGELOG.md index 980d12cff..5fc11f426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ This file documents all notable changes made to ITFlow. +## [26.09.3] Maint Release + +### Upgrading to 26.09.3 + +Update from Maintenance > Update — Queue Update hands the job to cron and it applies on its own. There is no database change in this release, so nothing else is required. + +### Breaking Changes and Notes + +- New tickets are now assigned to whoever is creating them. The assignee on the ticket form and on the client bulk-add form starts on your own name instead of Unassigned, and you can still pick anyone else or Unassigned before saving. +- API: a ticket created with an assignee now comes in as Open rather than New, matching what happens when an agent assigns a ticket by hand. Anything of yours that watches for New tickets to pick up work will no longer see assigned ones. +- Tax Summary and the dashboard Income by Category chart will show different numbers than they did before. Both were wrong wherever an invoice had been partly paid, and both usually read high. The corrected figures are described under Bug Fixes. + +### New Features & Updates + +- Invoices: a partly paid invoice shows what is still owed in red underneath the invoice total in the list, so you can see the outstanding balance without opening the invoice. + +### Bug Fixes + +- Invoices: the Unpaid figure at the top of the invoice list counted the whole of every partly paid invoice rather than what was left on it, and counted that invoice again for each payment against it. An invoice for $1,000 with three payments of $100 added $3,000 to Unpaid; it now adds the $700 that is actually outstanding. +- Reports: Tax Summary counted an invoice's full tax once per payment recorded against it, so a partly paid invoice with three payments reported three times its tax. Tax is now booked to the month the money came in, in proportion to how much of the invoice that payment covered, and each payment is counted once. Yearly totals also no longer disagree with the months they are made of. +- Dashboard: the Income by Category chart only counted invoices marked Paid, so partly paid invoices contributed nothing and revenues entered outside an invoice never appeared at all. It now counts payments and revenues as they land, matching the Cash Flow chart above it and the Income Summary report. +- Tickets: ticket-created emails told the client the status was Open no matter what the ticket was actually set to. They now carry the real status, on tickets an agent creates and on scheduled tickets from recurring tickets. +- Tickets: the assignee list on the client bulk-add form left out agents on the Accountant role and did not match the list on the normal ticket form. Both lists are now the same. +- Quotes and Recurring Invoices: picking a product from the item autocomplete put the word "undefined" in the item name, while the description and price filled in correctly. Invoices were not affected. The product list behind the box was also missing information on those two pages, so every entry read "No tax", services showed a stock badge, and searching by product code did not match. Reported by @cthompson. + +### Developer Updates + +- `getMonthlyTax()` and `getQuarterlyTax()` in `functions/app.php` are rewritten. They previously joined `invoice_items` to `invoices` to `payments`, which multiplied the line-item rows by the payment rows — the double counting was row multiplication, not a rounding problem. Both now drive off `payments`, join a pre-aggregated per-invoice tax subquery, and scale by `payment_amount / invoice_amount`, with `invoice_amount > 0` guarding the division. `agent/reports/tax_summary.php` also dropped a second loop that recalculated each row total by calling `getMonthlyTax()` another twelve times; the total accumulates in the first loop instead, cutting the queries behind the monthly view in half. +- `agent/invoices.php`: the Partial total no longer selects `SUM(invoice_amount)` across a `payments` join, and payments against partial invoices are subtracted from the unpaid figure. The list query gained a derived `LEFT JOIN (SELECT payment_invoice_id, SUM(payment_amount) ... GROUP BY payment_invoice_id)` for the per-row balance, which keeps it to one query rather than one per row. +- `agent/dashboard.php`: the `TopCategories` temporary table is now built from a `UNION ALL` of payments (carrying their invoice's category) and revenues, keyed on payment and revenue dates rather than `invoice_status = 'Paid'` and `invoice_date`. The Other bucket is built from the same union. +- `api/v1/tickets/create.php` sets `ticket_status = 2` when `assigned_to > 0`, rather than always inserting status 1. +- `agent/modals/client/client_bulk_add_ticket.php` filtered the assignee list on `user_role_id > 1` where every other assignee list uses `user_type = 1`. Role 1 is the built-in Accountant role, so accountant-role agents were missing from it. +- Product autocomplete is consolidated. The three pages each carried their own product `SELECT` and their own copy of the autocomplete JavaScript; the queries drifted, and quote and recurring invoice were still on a four-column version that had no `product_name` or `prod_id`, so the shared `onSelect` wrote `undefined` into `#name`. Its last line also assigned to `#product_id`, which only the invoice form has, so `onSelect` threw a `TypeError` on those two pages and the `input` handler under it threw on every keystroke. The query now lives in `getProductsForAutocomplete($mysqli)` in `functions/app.php` and the JavaScript in `js/product_autocomplete.js`, with the hidden `#product_id` added to the quote and recurring item forms and treated as optional in the JavaScript. Net 218 lines removed for 134 added. Note that `item_product_id` is still only written by `add_invoice_item` and the API, so the hidden field on those two forms is inert until the handlers are wired up. + ## [26.09.2] Maint Release - Updates the App Version to a proper version number. diff --git a/agent/dashboard.php b/agent/dashboard.php index 7355b1925..de218b403 100644 --- a/agent/dashboard.php +++ b/agent/dashboard.php @@ -1025,14 +1025,40 @@ if ($user_config_dashboard_technical_enable == 1) { data: { labels: [ 0 + UNION ALL + SELECT revenue_category_id AS income_category_id, revenue_amount AS amount + FROM revenues + WHERE YEAR(revenue_date) = $year AND revenue_category_id > 0) AS income + INNER JOIN categories ON category_id = income.income_category_id + GROUP BY category_name, category_id + ORDER BY total_income DESC LIMIT 5"); $sql_categories = mysqli_query($mysqli, "SELECT category_name FROM TopCategories"); while ($row = mysqli_fetch_assoc($sql_categories)) { $category_name = json_encode($row['category_name']); echo "$category_name,"; } - $sql_other_categories = mysqli_query($mysqli, "SELECT SUM(invoices.invoice_amount) AS other_income FROM categories LEFT JOIN TopCategories ON categories.category_id = TopCategories.category_id INNER JOIN invoices ON categories.category_id = invoices.invoice_category_id WHERE TopCategories.category_id IS NULL AND invoice_status = 'Paid' AND YEAR(invoice_date) = $year"); + $sql_other_categories = mysqli_query($mysqli, "SELECT SUM(income.amount) AS other_income + FROM (SELECT invoice_category_id AS income_category_id, payment_amount AS amount + FROM payments + INNER JOIN invoices ON invoice_id = payment_invoice_id + WHERE YEAR(payment_date) = $year AND invoice_category_id > 0 + UNION ALL + SELECT revenue_category_id AS income_category_id, revenue_amount AS amount + FROM revenues + WHERE YEAR(revenue_date) = $year AND revenue_category_id > 0) AS income + LEFT JOIN TopCategories ON TopCategories.category_id = income.income_category_id + WHERE TopCategories.category_id IS NULL"); $row = mysqli_fetch_assoc($sql_other_categories); $other_income = floatval($row['other_income']); if ($other_income > 0) { diff --git a/agent/invoice.php b/agent/invoice.php index 4bf4d071b..da87319da 100644 --- a/agent/invoice.php +++ b/agent/invoice.php @@ -180,32 +180,7 @@ if (isset($_GET['invoice_id'])) { $invoice_badge_color = getInvoiceBadgeColor($invoice_status); //Product autocomplete - $products_sql = mysqli_query($mysqli, " - SELECT - IF(product_code IS NULL OR product_code = '', product_name, CONCAT(product_code, ' - ', product_name)) AS label, - product_name, - product_code, - product_type AS type, - product_description AS description, - product_price AS price, - product_tax_id AS tax, - tax_percent, - product_id AS prod_id, - COALESCE(SUM(product_stock.stock_qty), 0) AS available_stock - FROM products - LEFT JOIN product_stock ON product_id = stock_product_id - LEFT JOIN taxes ON product_tax_id = tax_id - WHERE product_archived_at IS NULL - GROUP BY product_id - ORDER BY product_name ASC - "); - - if (mysqli_num_rows($products_sql) > 0) { - while ($row = mysqli_fetch_assoc($products_sql)) { - $products[] = $row; - } - $json_products = json_encode($products); - } + $json_products = getProductsForAutocomplete($mysqli); // Saved Payment Methods $sql_saved_payment_methods = mysqli_query($mysqli, " @@ -815,67 +790,12 @@ require_once "../includes/footer.php"; ?> - + + diff --git a/agent/invoices.php b/agent/invoices.php index 34d837f6d..6c91b2630 100644 --- a/agent/invoices.php +++ b/agent/invoices.php @@ -52,10 +52,13 @@ $sql_total_cancelled_amount = mysqli_query($mysqli, "SELECT SUM(invoice_amount) $row = mysqli_fetch_assoc($sql_total_cancelled_amount); $total_cancelled_amount = floatval($row['total_cancelled_amount']); -$sql_total_partial_amount = mysqli_query($mysqli, "SELECT SUM(invoice_amount) AS total_partial_amount FROM payments, invoices WHERE payment_invoice_id = invoice_id AND invoice_status = 'Partial' $client_query"); +$sql_total_partial_amount = mysqli_query($mysqli, "SELECT SUM(invoice_amount) AS total_partial_amount FROM invoices WHERE invoice_status = 'Partial' $client_query"); $row = mysqli_fetch_assoc($sql_total_partial_amount); $total_partial_amount = floatval($row['total_partial_amount']); -$total_partial_count = mysqli_num_rows($sql_total_partial_amount); + +$sql_total_partial_paid_amount = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS total_partial_paid_amount FROM payments, invoices WHERE payment_invoice_id = invoice_id AND invoice_status = 'Partial' $client_query"); +$row = mysqli_fetch_assoc($sql_total_partial_paid_amount); +$total_partial_paid_amount = floatval($row['total_partial_paid_amount']); $sql_total_overdue_partial_amount = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS total_overdue_partial_amount FROM payments, invoices WHERE payment_invoice_id = invoice_id AND invoice_status = 'Partial' AND invoice_due < CURDATE() $client_query"); $row = mysqli_fetch_assoc($sql_total_overdue_partial_amount); @@ -66,7 +69,7 @@ $row = mysqli_fetch_assoc($sql_total_overdue_amount); $total_overdue_amount = floatval($row['total_overdue_amount']); $real_overdue_amount = $total_overdue_amount - $total_overdue_partial_amount; -$total_unpaid_amount = $total_sent_amount + $total_viewed_amount + $total_partial_amount; +$total_unpaid_amount = $total_sent_amount + $total_viewed_amount + $total_partial_amount - $total_partial_paid_amount; $unpaid_count = $sent_count + $viewed_count + $partial_count; $overdue_query = ''; @@ -98,10 +101,13 @@ $sql = mysqli_query( invoice_amount, invoice_created_at, invoice_currency_code, invoice_date, invoice_discount_amount, invoice_due, invoice_id, invoice_number, invoice_prefix, invoice_scope, invoice_status, recurring_invoice_id, recurring_invoice_number, - recurring_invoice_prefix FROM invoices + recurring_invoice_prefix, IFNULL(invoice_payments.amount_paid, 0) AS amount_paid FROM invoices LEFT JOIN clients ON invoice_client_id = client_id LEFT JOIN categories ON invoice_category_id = category_id LEFT JOIN recurring_invoices ON invoice_recurring_invoice_id = recurring_invoice_id + LEFT JOIN (SELECT payment_invoice_id, SUM(payment_amount) AS amount_paid + FROM payments + GROUP BY payment_invoice_id) AS invoice_payments ON payment_invoice_id = invoice_id WHERE ($status_query) $overdue_query $category_query @@ -339,6 +345,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); $invoice_due = escapeHtml($row['invoice_due']); $invoice_discount = floatval($row['invoice_discount_amount']); $invoice_amount = floatval($row['invoice_amount']); + $amount_paid = floatval($row['amount_paid']); + $invoice_balance = $invoice_amount - $amount_paid; $invoice_currency_code = escapeHtml($row['invoice_currency_code']); $invoice_created_at = escapeHtml($row['invoice_created_at']); $client_id = intval($row['client_id']); @@ -395,7 +403,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));