diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b44d06a5..ba02f3c93 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 3.0.4) 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 3.0.5) in the same pass. Nothing else is required. -This is a big release, with twenty-five database steps in one go, so take a backup before you update. +This is a big release, with twenty-six 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: @@ -27,6 +27,8 @@ Recurring expenses keep their next date and from then on run on that day of the Trips already logged keep the miles they were entered with (see Breaking Changes, Trips). Each takes who logged it from the audit log, or its driver where the log has no record. A trip for a client whose destination is one of that client's locations, by name or by address, is linked to that location. Trips already logged count as driven in a company vehicle and not billable, so nothing is paid back or billed for them until you say so (see New Features, Trips). No mileage rate or trip charge is set, and mileage goes under an expense category called Mileage, or Travel, when there is one. +Other income already entered (money in that isn't for an invoice, which used to be called revenue) takes who entered it from the audit log, where it has a record of it. Income with no category now counts in the dashboard and reports, so their totals can go up by that much (see Breaking Changes, Income). + 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 @@ -94,6 +96,13 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Every agent can open Trips when accounting is on, and log their own driving. An agent without Financial access sees only the trips they drove or logged, and can change or delete only those. Logging a trip for someone else, or changing theirs, needs write access to Financial, and deleting theirs needs full access. Before, any agent who opened the page saw every trip of their clients and could change any of them. - A trip's miles are the whole trip. The form asks for the miles one way and doubles them for a round trip. The old form saved the number as typed, whether Round Trip was ticked or not, so an older round trip entered as the miles one way still counts once. Edit those to correct them. - The database column `trips.trip_end_odmeter` is now `trip_end_odometer`. Anything of your own that reads it, such as a custom report, needs the new name. +- Income: + - Money in that isn't for an invoice is now called other income (it was revenue). Adding and changing it needs write access to Financial, and deleting it full access. It used to need Sales. Payments keep the invoice's rules, full access to Sales and Financial. + - Other income counts whether it has a category or not, on the Income list, the dashboard, the client overview and every report, as No category when it has none. The dashboard and reports used to leave it out until it had a category, so their income can go up after the update by what had none. Money coming in from a transfer between your own accounts is still not income. + - New other income takes the currency of the account it goes into. It used to take the company's currency whatever the account. + - The Income list's Bulk menu has Set Category, Set Account, Set Payment Method and the new Set Client, a window each, and Delete. A payment's category is its invoice's, so Set Category on a payment sets its invoice's category, as before. A payment's client is its invoice's, so Set Client leaves payments alone, and payments are deleted from their invoice, not here. + - The export's Type column says Invoice payment or Other income (it was Payment or Revenue), and Source is now What for, with new Invoice and Paid Online columns. + - In Transactions, a payment's date opens its quick look instead of the invoice. The quick look has Open Invoice. - 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: @@ -575,6 +584,24 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Once billed, a trip keeps its distance, client and billable, and can't be deleted. - The dashboard shows Trips to bill and Mileage to pay back, the client overview Trips to bill, and the Unbilled Time count in the menu counts trips. - The demo data logs a trip to the site for each onsite ticket fixed in the last six months. The last six weeks of driving are in the agent's own vehicle, and a billable ticket's trip in the last month is billable when trips have a charge or rate. +- **Income** (rebuilt) + - Income is counted the same way everywhere - the Income list, the dashboard, the client overview, Income Summary, Profit & Loss, Income by Client and Client Profitability: invoice payments, and other income (money in that isn't for an invoice) whether it has a category or not. Money moved between your own accounts is left out. + - Along the top of the list: what came in this month against the same days of last month, the year so far with its monthly average, what the running recurring invoices bill a month, and what is still owed on invoices with how much of it is late. Each one opens what is behind it. + - Pills cut the list to invoice payments, other income, what was paid online and what has no category, each with a count. Filter by client, category and date, and under More filters by the account it went into and how it was paid. Search finds what it was for, an invoice number, a reference such as a check number, an amount, the client, category or account. The total of everything the filters leave sits under the list. + - Each row shows the invoice and what it was for (or what the other income was), how it was paid and its reference, the category with its colour, the client, the account, and the amount with a mark when it was paid online. + - Clicking a payment opens its quick look: + - the invoice it paid, its status and due date, what it came to, what has been paid on it in all and what is still owed + - the other payments on the same invoice, each opening its own + - the client, the invoice's category, the account, how it was paid and the reference + - for a payment made online, the payment provider's fee and what came in after it, when the fee is in the expenses + - Open Invoice, Email Receipt (to the client's billing contacts again), Edit and Delete + - Clicking other income opens its quick look: what it was with the rest of the description as notes, the client, category, account, how it was paid, the reference, who entered it and when, and what has come in from that client this year. Edit, Copy and Delete. + - New Income, Edit and Copy are one form with an icon on every field. Pick the client it came from - a rebate passed on to a client, a deposit held for them - and it counts for them in Income by Client and Client Profitability. The amount shows the account's currency, and can be negative for money that went back out. Every field is checked when it is saved: a real date, an amount, an account and an income category. Copy dates the copy today and leaves off the reference. + - A client's Income page has the same cards and pills for that client, with New Income starting with them. + - Bulk actions set the category, account, payment method or client on the ticked rows, or delete the other income among them. Each window says what it will leave alone. + - Export takes the same filters as the list, including the pills, with Invoice and Paid Online columns. + - Income Summary and Profit & Loss list No category as a line, opening those on the list, and their Invoice payments and Other income figures open those pills. Accounts link to Transactions and the income into each account. Search finds other income by what it was, its reference or its amount, and payments by their reference, opening the quick look. + - The demo data adds the money an MSP takes in besides invoices: a distributor's partner rebate each quarter, interest on the savings account and the odd referral fee. ### Bug Fixes @@ -630,6 +657,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Users: Your account > Activity showed other agents' sign-ins. A password with a space at the start or end was saved inconsistently, so the agent either could not sign in or could sign in but not open credentials. Passwords are now saved exactly as typed. - Users: disabling a user left a blank name in the audit log. - Income on the dashboard and in the reports counted money moved between your own accounts as income when the transfer had been given a category. +- Income: the Invoice payments and Other revenue figures on the Income Summary report opened the whole Income list, as the list didn't understand their links. Sorting the Income list by a column it doesn't have gave a database error. +- Income: the revenue form saved whatever was typed, such as no account, no category or an amount of nothing. A revenue's client couldn't be set, so it never counted for the client. - Client Portal: a ticket rating could be saved as any value. Only Good and Bad, the two buttons the portal shows, are accepted now. The demo data rated tickets Great and Ok, which the portal never offers, and now uses Good and Bad. - Notifications that linked to a page did not open correctly from Admin pages. - Messages showed a backslash before apostrophes and quotes in names, or showed `&` instead of `&`. @@ -642,7 +671,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run ### Developer Updates -- Database 2.8.0 to 3.0.4, one file per step under `admin/database_updates/`, with `db.sql` matching 3.0.4: +- Database 2.8.0 to 3.0.5, one file per step under `admin/database_updates/`, with `db.sql` matching 3.0.5: - 2.8.0: `racks.rack_units_descending`, `rack_power_capacity` and `rack_photo_rear`. On `rack_units`: `unit_face`, `unit_full_depth`, `unit_type`, `unit_power_watts`, `unit_notes` and a `unit_asset_id` key. Unlinked items move from asset id 0 to NULL. - 2.8.1: `location_documents` and `location_files`. - 2.8.2: `vendor_tags` (tag type 6), and `vendor_contact_phone_country_code`, `vendor_contact_phone` and `vendor_contact_email`. @@ -680,6 +709,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 3.0.2: `expenses.expense_import_key` (keyed), `expense_paid_by` (0) and `expense_reimbursement_id` (keyed). `expense_import_rules` (`import_rule_match` unique, `import_rule_vendor_id`, `import_rule_category_id`, `import_rule_client_id`, `import_rule_billable`, `import_rule_used_count`). `reimbursements` (`reimbursement_user_id`, `reimbursement_date`, `reimbursement_amount`, `reimbursement_currency_code`, `reimbursement_account_id`, `reimbursement_payment_method`, `reimbursement_reference`, `reimbursement_expense_id`, `reimbursement_created_by`). `accounts.account_import_map`. - 3.0.3: `trips.trip_end_odmeter` renamed `trip_end_odometer`, and `trip_miles` from `float(15,1)` to `decimal(10,1)` (0.0). New `trip_location_id`, `trip_ticket_id` and `trip_created_by` (0), keyed with `trip_user_id` (`trip_user_id`, `trip_date`). `trip_created_by` is backfilled from the audit log's Trip Create entries, else the driver, and `trip_location_id` where the destination matches one of the client's locations by name or by `formatAddress()` of its address, case and spacing aside. - 3.0.4: `trips.trip_own_vehicle` (0), `trip_expense_id` (0, keyed), `trip_billable` (0), `trip_invoice_id` (0, keyed) and `trip_invoice_item_id` (0). `settings.config_mileage_unit` ('mi' or 'km'), `config_mileage_rate` (`decimal(10,4)`, 0), `config_mileage_category_id` (set to an Expense category named Mileage, else Travel), `config_mileage_vendor_id`, `config_trip_charge` (`decimal(15,2)`), `config_trip_bill_rate` (`decimal(10,4)`), `config_trip_included` (`decimal(10,1)`) and `config_trip_item_name` ('Travel'). + - 3.0.5: `revenues.revenue_description` becomes `text`, with `revenue_created_by` (0, backfilled from the audit log's Revenue Create entries) and a `revenue_date` key. `payments` gets a `payment_date` key. - 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`. @@ -732,7 +762,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `agent/reports/includes/inc_all_reports.php` is kept for custom report pages and now loads the two new includes. - Tax Collected works out every tax for the period in one query (`taxCollected()` in the page), with the same figures as `getMonthlyTax()`. - Budgets: `agent/post/budget.php` handles `save_budget` and `copy_budget`, needing Financial at level 2 and a CSRF token. A blank or zero amount removes that month's budget rather than storing 0. - - `dashboardRevenueSql()` in `functions/dashboard.php` is the one rule for which revenues count as income, used by the dashboard and the reports. It leaves out the incoming side of transfers. + - `dashboardRevenueSql()` in `functions/dashboard.php` is the one rule for which revenues count as income, used by the dashboard and the reports. It leaves out the incoming side of transfers. It now returns `incomeRevenueSql('revenues.revenue_id')` (see Income below), so uncategorised revenues count. - CSS: the `.itflow-report-*` classes in `css/itflow_custom.css`, with print rules that keep side-by-side cards together. - Client portal: `add_ticket_feedback` stores only `Good` or `Bad`. Demo data rates some closed tickets and leaves a few older Windows machines on Windows 10, so the satisfaction and lifecycle reports have something to show. - The client actions menu links to the Client Review for that client, and the credentials list's rotation report link keeps the client. @@ -884,6 +914,21 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Settings: the Trips and Mileage section of `admin/settings_invoice.php` (`#mileage`), saved by `edit_invoice_settings`. - `get_side_nav_counts.php` and `inc_all_client.php` set `$num_unbilled_trips` for the Unbilled Time badge. `dashboardAttention()` adds Trips to bill and Mileage to pay back, and `clientOverviewAttention()` Trips to bill. `dashboardTrips()` returns `unit` for the chart, and the Mileage report's `rate` defaults to the mileage rate. - Seeds: `demoBuildClient()` and `demoBuildCompanyFinancials()` set `trip_own_vehicle` for the last 45 days, and `trip_billable` for a billable ticket's trip in the last 30 when `tripBillingOn()`. +- Income, in the new `functions/income.php` (loaded by `functions.php` after `trips.php`): + - `incomeRevenueSql($column)` is the one rule for which revenues are income - those that aren't the in side of a transfer (`NOT EXISTS` on `transfers.transfer_revenue_id`), categorised or not. `dashboardRevenueSql()` returns it, and `clientOverviewMoney()`, `clientOverviewMonthly()` and `dashboardTopClients()` use that in place of `revenue_category_id > 0`. + - Access: `incomeCanEdit()` (Financial at 2), `incomeCanDelete()` (Financial at 3) and `incomeCanEditPayments()` (Sales and Financial at 3, as `edit_payment` and `delete_payment`). + - `incomeUnionSql()` is every payment and non-transfer revenue as one row set, in the agent's client scope (`clientScopeSql('COALESCE(invoice_client_id, 0)')` for payments), with `income_type` (`payment` or `revenue`), `income_id`, `income_date`, `income_created_at`, `income_invoice_id`, `invoice_prefix`, `invoice_number`, `invoice_status`, `income_description` (the invoice's scope for a payment), `income_category_id`, `category_name`, `category_color`, `income_client_id`, `client_name`, `income_amount`, `income_currency_code`, `income_method`, `income_reference`, `income_account_id`, `account_name`, `account_archived_at` and `income_online` (`incomeOnlineSql()`: a reference of `Stripe - pi_...`). Select from it as a derived table `AS income`. + - The list and export share `incomeFiltersSql($src, &$summary, $with_show = true)` (q, client, category, account, method, show or type, dtf and dtt; q also matches an amount) and `incomeShows()`, the pills (`payments`, `other`, `online`, `uncategorised`). `incomeShowFrom($src)` maps the older `?type=payment` and `revenue`, either case. Also `incomeTotal($from, $to, $client_id)`, `incomeStats($client_id)` (`month`, `last_to_date`, `change`, `year`, `year_average`, `recurring`, `recurring_count`, `owed`, `owed_count`, `overdue`, `overdue_count`) and `incomeCategories($keep_id)`, which leaves out Account Transfer. + - Other income: `incomeRevenueRow($id, $scoped = true)` (with `client_name`, `category_name`, `account_name`, `account_currency_code` and `created_by_name`; null for a transfer's leg), `incomeRevenueLabel($row)`, `incomeRevenueSave($in, $id, &$error)` - the one path that writes it, taking `date`, `amount`, `account`, `category`, `client_id`, `payment_method`, `reference` and `description`, checking each, taking the account's currency and setting `revenue_created_by` on insert - and `incomeRevenueDelete($id, &$error)`. + - Payments: `incomePaymentRow($id)` (the invoice's number, status, amount, dates, scope, client and category, `invoice_paid`, `others` - the other payments on the invoice - and `fee`, an expense whose reference starts with an online payment's reference) and `incomePaymentLabel($row)`. + - `incomeBulkIds($refs)` turns the list's `payment-12` and `revenue-7` (either case, so the old `Payment-12` too) into `['payment' => ids, 'revenue' => ids]`. + - `agent/income.php` is rebuilt on these, taking `?client=` (or the client pages' `?client_id=`), `?category=`, `?account=`, `?method=`, `?show=`, `?type=`, the date filter, `?open=payment-12` or `revenue-7`, and sorting on a whitelist only. + - Handlers: `agent/post/revenue.php` - `add_revenue` (New and Copy) and `edit_revenue` (with `revenue_id`) through `incomeRevenueSave()` at Financial 2, GET `delete_revenue` through `incomeRevenueDelete()` at Financial 3, logged with the client. `agent/post/income.php` - `bulk_edit_income` (`field` = category, account, method or client, `value`, `income_ids[]`; Financial at 2, payments only with `incomeCanEditPayments()`), `bulk_delete_income` (Financial at 3, other income only) and `export_income` through `incomeFiltersSql()`, with `income_invoice` and `income_online` added to the `income` export columns. `agent/post/payment.php` adds GET `payment_send_receipt` (Sales at 2), resending the receipt through `billingPaymentSend()`. + - New windows: `agent/modals/payment/payment.php?id=` and `agent/modals/revenue/revenue.php?id=` (quick looks), `revenue_form.php` (the fields of `revenue_add.php`, which takes `?client_id=`, `revenue_edit.php` and the new `revenue_copy.php`), `agent/modals/income/income_bulk_edit.php?field=` and `income_bulk_delete.php`, with the menu in `agent/includes/income_bulk_menu.php`. `income_export.php` posts `show`, `client`, `dtf` and `dtt` in place of `type`, `date_from` and `date_to`. + - `globalSearchGroups()` has an Income group over `incomeUnionSql()`. `reportCategoryYearTable()` takes a sixth argument, the query that opens the No category row (`show=uncategorised` on the Income Summary). `agent/transactions.php` opens `payment.php` and `revenue.php`, and `agent/accounts.php` links each account's Transactions and Income. + - Seeds: `demoBuildClient()` sets `revenue_created_by`, and `demoBuildCompanyFinancials()` adds the company's rebates, interest and referral fees (references `RBT-`, `INT-` and `REF-`, removed with the demo data). + - CSS: `.itflow-income-from`. + - Removed: `agent/post/income_model.php`, and `agent/modals/income/income_bulk_edit_account.php`, `income_bulk_edit_category.php` and `income_bulk_edit_method.php` (`bulk_edit_income_account`, `bulk_edit_income_category` and `bulk_edit_income_method` are replaced by `bulk_edit_income`). - 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/3.0.5.php b/admin/database_updates/3.0.5.php new file mode 100644 index 000000000..bf9d19d74 --- /dev/null +++ b/admin/database_updates/3.0.5.php @@ -0,0 +1,20 @@ + 0"); diff --git a/admin/post/demo_data_model.php b/admin/post/demo_data_model.php index d621bf26b..47574e3f6 100644 --- a/admin/post/demo_data_model.php +++ b/admin/post/demo_data_model.php @@ -980,6 +980,11 @@ function demoDataRemove($mysqli) { mysqli_query($mysqli, "DELETE FROM transfers WHERE transfer_id = $transfer_id"); } + // The company's other income - rebates, interest and referral fees on our accounts + mysqli_query($mysqli, "DELETE FROM revenues WHERE revenue_client_id = 0 AND revenue_account_id IN ($owned_ids) + AND (revenue_reference LIKE 'RBT-%' OR revenue_reference LIKE 'INT-%' OR revenue_reference LIKE 'REF-%') + AND NOT EXISTS (SELECT 1 FROM transfers WHERE transfer_revenue_id = revenue_id)"); + } // The transfer categories are ours - the Categories pack has no equivalent. @@ -3267,6 +3272,7 @@ function demoBuildBilling($mysqli, $profile, $index, $client_id, $vendor_id, $mo 'revenue_category_id' => $support_category_id, 'revenue_account_id' => $bank_account_id, 'revenue_client_id' => $client_id, + 'revenue_created_by' => intval($context['user_id']), 'revenue_created_at' => demoMonthDateTime($month, 22, 10, 15), ]); $counts['billing']++; @@ -3521,6 +3527,41 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { } + // Money in that never went through an invoice - the distributor's partner + // rebate each quarter, interest on the savings, and the odd referral fee. + // The RBT-, INT- and REF- references are how removal finds them again. + $other_income = []; + for ($month = 24; $month >= 0; $month--) { + if ($month % 3 === 1) { + $other_income[] = [$month, 12, 380.00 + (($month % 5) * 45), 'Rebates', 'ACH', 'RBT-', "Distributor partner rebate for last quarter's licensing", $operating_id]; + } + if ($savings_id) { + $other_income[] = [$month, 28, 18.40 + ((24 - $month) * 0.65), 'Interest', 'ACH', 'INT-', 'Interest on the tax reserve', $savings_id]; + } + if ($month === 9 || $month === 2) { + $other_income[] = [$month, 17, 250.00, 'Referral Fees', 'Check', 'REF-', "Referral fee - passed a website rebuild to a design partner", $operating_id]; + } + } + foreach ($other_income as [$month, $day, $amount, $category, $method, $prefix, $description, $account_id]) { + if (demoIsFuture(demoMonthDate($month, $day))) { + continue; + } + starterInsert($mysqli, 'revenues', [ + 'revenue_date' => demoMonthDate($month, $day), + 'revenue_amount' => number_format($amount, 2, '.', ''), + 'revenue_currency_code' => $currency, + 'revenue_payment_method' => $method, + 'revenue_reference' => $prefix . demoSerial($category . $month . $day, 8), + 'revenue_description' => $description, + 'revenue_category_id' => demoCategoryId($mysqli, $category, 'Income'), + 'revenue_account_id' => $account_id, + 'revenue_client_id' => 0, + 'revenue_created_by' => intval($context['user_id']), + 'revenue_created_at' => demoMonthDateTime($month, $day, 11, 5), + ]); + $counts['company']++; + } + } // ------------------------------ diff --git a/agent/accounts.php b/agent/accounts.php index dc2dd0c50..5a97cf3de 100644 --- a/agent/accounts.php +++ b/agent/accounts.php @@ -98,6 +98,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); data-modal-url="modals/account/account_edit.php?id="> Edit + + Transactions + + + Income into it + diff --git a/agent/includes/income_bulk_menu.php b/agent/includes/income_bulk_menu.php new file mode 100644 index 000000000..d2f30d807 --- /dev/null +++ b/agent/includes/income_bulk_menu.php @@ -0,0 +1,24 @@ + + diff --git a/agent/income.php b/agent/income.php index 4b252d2cb..9a7ea780b 100644 --- a/agent/income.php +++ b/agent/income.php @@ -1,5 +1,24 @@ = 1; + +$client_filter = $client_page ? intval($client_id) : intval($_GET['client'] ?? 0); +$category_filter = intval($_GET['category'] ?? 0); +$account_filter = intval($_GET['account'] ?? 0); +$method_filter = (string) ($_GET['method'] ?? ''); +$shows = incomeShows(); +$show = incomeShowFrom($_GET); +$more_filters = $account_filter || $method_filter !== ''; + +// Only these sort - filter_header.php hands through any lowercase word +$sortable_columns = ['income_date' => 'income.income_date', 'income_amount' => 'income.income_amount', 'client_name' => 'income.client_name', + 'category_name' => 'income.category_name', 'account_name' => 'income.account_name', 'income_type' => 'income.income_type']; +if (!isset($sortable_columns[$sort])) { + $sort = 'income_date'; } -// Account Filter -if (isset($_GET['account']) && !empty($_GET['account'])) { - $account_query = 'AND (income_account_id = ' . intval($_GET['account']) . ')'; - $account_filter = intval($_GET['account']); -} else { - // Default - any - $account_query = ''; - $account_filter = ''; -} - -// Category Filter - a revenue carries its own category, a payment inherits the one on the -// invoice it was paid against. Both come from the same 'Income' category pool. -if (isset($_GET['category']) && !empty($_GET['category'])) { - $category_query = 'AND (income_category_id = ' . intval($_GET['category']) . ')'; - $category_filter = intval($_GET['category']); -} else { - // Default - any - $category_query = ''; - $category_filter = ''; -} - -// Payment Method Filter -if (isset($_GET['method']) && !empty($_GET['method'])) { - $method_query = "AND (income_method = '" . escapeSql($_GET['method']) . "')"; - $method_filter = escapeHtml($_GET['method']); -} else { - // Default - any - $method_query = ''; - $method_filter = ''; -} - -// Money in comes from two places - payments applied to an invoice, and standalone revenues. -// Transfers between accounts are stored as a linked expense + revenue pair, so the revenue leg -// is excluded here (transfer_id IS NULL) - moving your own money is not income. -$income_query = - "SELECT - 'Payment' AS income_type, - payment_id AS income_id, - payment_date AS income_date, - payment_created_at AS income_created_at, - payment_invoice_id AS income_invoice_id, - CONCAT(invoice_prefix, invoice_number) AS income_source, - category_name AS income_category, - IFNULL(invoice_category_id, 0) AS income_category_id, - invoice_client_id AS income_client_id, - client_name AS income_client, - payment_amount AS income_amount, - payment_currency_code AS income_currency_code, - payment_method AS income_method, - payment_reference AS income_reference, - payment_account_id AS income_account_id, - account_name AS income_account, - account_archived_at AS income_account_archived - FROM payments - LEFT JOIN invoices ON payment_invoice_id = invoice_id - LEFT JOIN clients ON invoice_client_id = client_id - LEFT JOIN accounts ON payment_account_id = account_id - LEFT JOIN categories ON invoice_category_id = category_id - WHERE payment_archived_at IS NULL - $payment_client_query - " . clientScopeSql('invoice_client_id') . " - - UNION ALL - - SELECT - 'Revenue', - revenue_id, - revenue_date, - revenue_created_at, - 0, - revenue_description, - category_name, - revenue_category_id, - revenue_client_id, - client_name, - revenue_amount, - revenue_currency_code, - revenue_payment_method, - revenue_reference, - revenue_account_id, - account_name, - account_archived_at - FROM revenues - LEFT JOIN categories ON revenue_category_id = category_id - LEFT JOIN clients ON revenue_client_id = client_id - LEFT JOIN accounts ON revenue_account_id = account_id - LEFT JOIN transfers ON transfer_revenue_id = revenue_id - WHERE revenue_archived_at IS NULL - AND transfer_id IS NULL - $revenue_client_query - " . clientScopeSql('revenue_client_id') . ""; - -$income_filter_query = - "WHERE DATE(income_date) BETWEEN '$dtf' AND '$dtt' - AND (income_source LIKE '%$q%' OR income_category LIKE '%$q%' OR income_client LIKE '%$q%' OR income_account LIKE '%$q%' OR income_method LIKE '%$q%' OR income_reference LIKE '%$q%' OR income_amount LIKE '%$q%') - $type_query - $category_query - $account_query - $method_query"; - -$sql = mysqli_query( - $mysqli, - "SELECT SQL_CALC_FOUND_ROWS * FROM ( - $income_query - ) AS income - $income_filter_query - ORDER BY $sort $order LIMIT $record_from, $record_to" -); +// 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, 'client' => $client_filter, 'show' => $show, 'type' => '']); +$where = incomeFiltersSql($filter_src, $summary, false); +$show_query = $show !== '' ? 'AND ' . $shows[$show]['where'] : ''; +$union = incomeUnionSql(); +$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS income.* FROM ($union) AS income + WHERE $where $show_query + ORDER BY {$sortable_columns[$sort]} $order, income.income_created_at DESC, income.income_id DESC LIMIT $record_from, $record_to"); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +$rows = []; +while ($row = mysqli_fetch_assoc($sql)) { + $rows[] = $row; +} -// Summary - totals for the current filtered view, split by where the money came from -$sql_summary = mysqli_query( - $mysqli, - "SELECT - SUM(CASE WHEN income_type = 'Payment' THEN income_amount ELSE 0 END) AS total_payments, - SUM(CASE WHEN income_type = 'Revenue' THEN income_amount ELSE 0 END) AS total_revenues, - SUM(income_amount) AS total_income - FROM ( - $income_query - ) AS income - $income_filter_query" -); -$row = mysqli_fetch_assoc($sql_summary); -$summary_total_payments = floatval($row['total_payments']); -$summary_total_revenues = floatval($row['total_revenues']); -$summary_total_income = floatval($row['total_income']); +// 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 income.income_amount END), 0) + FROM ($union) AS income WHERE $where")); +$counts = []; +foreach (array_keys($shows) as $i => $key) { + $counts[$key] = intval($row[$i]); +} +$listed_total = floatval($row[count($shows)]); + +$stats = incomeStats($client_page ? $client_id : 0); +$money = function ($amount, $currency = '') { + return escapeHtml(billingMoney($amount, $currency)); +}; +$filter_url = function ($changes) use ($client_page, $client_id) { + $params = array_merge($_GET, $changes); + unset($params['page'], $params['type']); + if ($client_page) { + $params['client_id'] = $client_id; + } + 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)); +}; +$any_filter = $q || ($client_filter && !$client_page) || $category_filter || $account_filter || $method_filter !== '' || $show !== '' || $dtf !== '1970-01-01'; +$add_url = 'modals/revenue/revenue_add.php' . ($client_page ? "?client_id=$client_id" : ''); +$invoices_url = 'invoices.php?' . ($client_page ? "client_id=$client_id&" : '') . 'status=Unpaid'; ?> -
-
-

Income

- -
-
- - +
+
+

Income

+
+
+ + -
-
-
- - - + + +
+
+
+ +
+ + + + + +
+
+
+ + + +
+
+
+ + + + +
+
+
+
+ +
+ + + +
+
+ +
+
+
+ +
+ + +
-
+ +
+
+
+
- + + $account) { ?> +
-
+
- + - - - - -
-
-
-
- -
-
- -
-
-
- = 3 && lookupUserPermission("module_financial") >= 3) { ?> -
-
-
- -
-
-
- -
" id="advancedFilter"> -
-
-
- - - - - -
-
-
-
- -
- -
-
- -
-
-

-

Invoice Payments

-
-
- -
-
- - -
- -
-
-

-

Other Revenue

-
-
- -
-
-
- - -
- -
-
-

-

Total Income

-
-
- -
-
-
- -
- -
- - text-nowrap table-light border-top"> - - = 3 && lookupUserPermission("module_financial") >= 3) { ?> - - - - - - - - - - - - - - - - - - - - - = 3 && lookupUserPermission("module_financial") >= 3) { ?> - - - - - - - - - - - - - - - - - - - -
-
- -
-
- - Date - - - - Type - - - - Source - - - - Category - - - - Client - - - - Amount - - - - Method - - - - Reference - - - - Account - - Action
-
- -
-
- - - - - - - - - - - - - - - - - - - - = 3) { ?> - - -
-
- + + +
- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
Date From Category Client Paid into Amount
+
+ +
+
> + +
+ + + + - + + An invoice since deleted + +
+ +
+ +
+ + Online +
+
+ +
+ + No category + +
+ +
+ + - + +
+
+
Archived
+
+ + + +
+ + count($rows) ? ' - the total is for all of them, not just this page' : '' ?> +
+
+ +
+ +
+ +
A payment is added on its invoice. Money in that isn't for an invoice is New Income here.
+ +
+ + +
- + + + + + + $payments ? "A payment is deleted from its invoice - tick other income to delete it here." : 'Tick the income to delete first.']); + exit; +} +$total = []; +$sql = mysqli_query($mysqli, "SELECT revenue_currency_code, SUM(revenue_amount) FROM revenues WHERE revenue_id IN (" . implode(',', $ids['revenue']) . ") AND revenue_archived_at IS NULL + AND " . incomeRevenueSql('revenue_id') . ' ' . clientScopeSql('revenue_client_id') . " GROUP BY revenue_currency_code"); +while ($row = mysqli_fetch_row($sql)) { + $total[] = billingMoney($row[1], $row[0]); +} + +ob_start(); + +?> + + +
+ + + + + + +
+ + ['label' => 'Category', 'icon' => 'fa-tag'], + 'account' => ['label' => 'Account', 'icon' => 'fa-piggy-bank'], + 'method' => ['label' => 'Payment Method', 'icon' => 'fa-credit-card'], + 'client' => ['label' => 'Client', 'icon' => 'fa-building'], +]; +if (!isset($fields[$field])) { + echo json_encode(['error' => 'Pick what to set from the Bulk Action menu.']); + exit; +} +if (!$count) { + echo json_encode(['error' => 'Tick the income to change first.']); + exit; +} + +// What the ticked rows are, in words - and what of it this leaves alone +$ticked = implode(' and ', array_filter([ + $payments ? $payments . ' invoice payment' . ($payments === 1 ? '' : 's') : '', + $others ? $others . ' other income' : '', +])); +$notes = []; +if ($payments && $field === 'client') { + $notes[] = "A payment's client is its invoice's - the " . ($payments === 1 ? 'payment stays' : "$payments payments stay") . ' as ' . ($payments === 1 ? 'it is' : 'they are') . '.'; +} elseif ($payments && !$can_payments) { + $notes[] = 'Changing a payment takes full access to Sales and Financial - the ' . ($payments === 1 ? 'payment stays' : "$payments payments stay") . ' as ' . ($payments === 1 ? 'it is' : 'they are') . '.'; +} elseif ($payments && $field === 'category') { + $notes[] = "A payment's category is its invoice's - the invoice" . ($payments === 1 ? '' : 's') . ' behind the ' . ($payments === 1 ? 'payment takes' : 'payments take') . ' it, and with it every payment on ' . ($payments === 1 ? 'it' : 'them') . '.'; +} +if ($field === 'account') { + $notes[] = 'The currency stays as it was entered.'; +} +$changes = $field === 'client' || !$can_payments ? $others : $count; + +ob_start(); + +?> + + +
+ + + + "> + + + +
+ + - - -
- - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- - +
@@ -74,119 +46,99 @@ ob_start();
- +
- - + +
-
- -
- - +
+
+ +
+ + +
-
- -
- -
+ +
+ +
+ + +
+
+ +
+ +
- + +
-
- -
- -
+
+ +
- + +
-
- -
- -
+
+ +
- + +
-
- -
- -
+
+ +
- + +
-
- -
- -
+
+ +
- + +
-
diff --git a/agent/modals/payment/payment.php b/agent/modals/payment/payment.php new file mode 100644 index 000000000..774efdaf5 --- /dev/null +++ b/agent/modals/payment/payment.php @@ -0,0 +1,179 @@ + 'That payment no longer exists, or is not one you can see.']); + exit; +} + +$payment_id = intval($row['payment_id']); +$client_id = intval($row['invoice_client_id']); +$amount = floatval($row['payment_amount']); +$currency = (string) ($row['payment_currency_code'] ?: $row['invoice_currency_code']); +$has_invoice = $row['invoice_number'] !== null; +$can_sales = lookupUserPermission('module_sales') >= 1; +$can_edit = incomeCanEditPayments() && $has_invoice; +$online = preg_match('/^Stripe - pi_/', (string) $row['payment_reference']) === 1; +$state = $has_invoice ? invoiceState($row, $row['invoice_paid']) : null; +$can_receipt = $has_invoice && lookupUserPermission('module_sales') >= 2 && $client_id; +$receipt_to = $can_receipt ? array_column(billingEmailRecipients($client_id), 'email') : []; +$money = function ($value, $code = null) use ($currency) { + return escapeHtml(billingMoney($value, $code ?? $currency)); +}; + +ob_start(); + +?> + + + + + + + + 'That income no longer exists, or is not one you can see.']); + exit; +} + +$revenue_id = intval($row['revenue_id']); +$client_id = intval($row['revenue_client_id']); +$amount = floatval($row['revenue_amount']); +$currency = (string) $row['revenue_currency_code']; +$description = trim((string) $row['revenue_description']); +$title = trim(strtok($description, "\n")); +if ($title === '') { + $title = $row['category_name'] ?: 'Other income'; +} +$rest = trim(substr($description, strlen(strtok($description, "\n")))); +$can_edit = incomeCanEdit(); +$can_delete = incomeCanDelete(); +$client_year = $client_id && $row['client_name'] !== null ? incomeTotal(date('Y-01-01'), date('Y-m-d'), $client_id) : null; + +ob_start(); + +?> + + + + + + + + diff --git a/agent/modals/revenue/revenue_copy.php b/agent/modals/revenue/revenue_copy.php new file mode 100644 index 000000000..a21cc6221 --- /dev/null +++ b/agent/modals/revenue/revenue_copy.php @@ -0,0 +1,34 @@ + 'That income no longer exists, or is not one you can see.'])); +} +$form_mode = 'copy'; + +ob_start(); + +?> + +
+ + + +
+ + 'That income no longer exists, or is not one you can see.'])); +} +$form_mode = 'edit'; ob_start(); ?> -
- +
- - + +
diff --git a/agent/reports/profit_loss.php b/agent/reports/profit_loss.php index 90b023c5e..7d525db74 100644 --- a/agent/reports/profit_loss.php +++ b/agent/reports/profit_loss.php @@ -5,8 +5,8 @@ * quarter, with the net profit and margin under it and last year beside it. * * Cash basis, as the dashboard: income is invoice payments (filed under the - * invoice's category) and revenues with a category; spending is vendor - * expenses. Transfers between accounts are neither. In the current year it + * invoice's category) and other income, categorised or not (No category); + * spending is vendor expenses. Transfers between accounts are neither. In the current year it * runs to today and is compared with the same stretch of last year. */ @@ -124,9 +124,9 @@ $render = function ($line, $class = '') use ($columns, $year) { . escapeHtml($line['name']) . (!empty($line['archived']) ? ' Archived' : '') . ''; foreach ($columns as $i => [$label, $first, $last]) { $url = ''; - if ($list && $cat) { + if ($list && ($cat || $kind === 'income')) { [$dtf, $dtt] = reportColumnDates($year, $first, $last); - $url = "/agent/$list?category=$cat&canned_date=custom&dtf=$dtf&dtt=$dtt"; + $url = "/agent/$list?" . ($cat ? "category=$cat" : 'show=uncategorised') . "&canned_date=custom&dtf=$dtf&dtt=$dtt"; } $amount = $line['cells'][$i]; $html .= '' . reportGridAmount($amount, $url) . ''; diff --git a/agent/transactions.php b/agent/transactions.php index 6c9fd4b82..a16cb174b 100644 --- a/agent/transactions.php +++ b/agent/transactions.php @@ -504,7 +504,9 @@ if ($account_filter) { } elseif ($transaction_type == 'Reimbursement') { $transaction_modal_url = "modals/expense/expense_reimbursement.php?id=$transaction_id"; } elseif ($transaction_type == 'Revenue') { - $transaction_modal_url = "modals/revenue/revenue_edit.php?id=$transaction_id"; + $transaction_modal_url = "modals/revenue/revenue.php?id=$transaction_id"; + } elseif ($transaction_type == 'Payment') { + $transaction_modal_url = "modals/payment/payment.php?id=$transaction_id"; } else { $transaction_modal_url = ""; } @@ -513,17 +515,11 @@ if ($account_filter) { - - - - - - - - - + + + diff --git a/css/itflow_custom.css b/css/itflow_custom.css index b8236a872..f0835c5f3 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -6368,3 +6368,9 @@ a.itflow-billing-stat:hover { max-width: 0; min-width: 14rem; } +/* Income - what it was takes the room left and cuts short, rather than push the amount off the side (agent/income.php) */ +.itflow-income-from { + width: 100%; + max-width: 0; + min-width: 14rem; +} diff --git a/db.sql b/db.sql index 4929eeea3..78a3bdc22 100644 --- a/db.sql +++ b/db.sql @@ -1789,7 +1789,8 @@ CREATE TABLE `payments` ( `payment_account_id` int(11) NOT NULL, `payment_invoice_id` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`payment_id`), - KEY `payment_invoice_id` (`payment_invoice_id`) + KEY `payment_invoice_id` (`payment_invoice_id`), + KEY `payment_date` (`payment_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -2345,15 +2346,17 @@ CREATE TABLE `revenues` ( `revenue_currency_code` varchar(200) NOT NULL, `revenue_payment_method` varchar(200) DEFAULT NULL, `revenue_reference` varchar(200) DEFAULT NULL, - `revenue_description` varchar(200) DEFAULT NULL, + `revenue_description` text DEFAULT NULL, `revenue_created_at` datetime NOT NULL DEFAULT current_timestamp(), `revenue_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), `revenue_archived_at` datetime DEFAULT NULL, `revenue_category_id` int(11) NOT NULL DEFAULT 0, `revenue_account_id` int(11) NOT NULL, `revenue_client_id` int(11) NOT NULL DEFAULT 0, + `revenue_created_by` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`revenue_id`), - KEY `revenue_client_id` (`revenue_client_id`,`revenue_archived_at`) + KEY `revenue_client_id` (`revenue_client_id`,`revenue_archived_at`), + KEY `revenue_date` (`revenue_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/functions.php b/functions.php index d92c6ff2b..c73d88a46 100644 --- a/functions.php +++ b/functions.php @@ -56,3 +56,4 @@ require_once __DIR__ . '/functions/billing_email.php'; require_once __DIR__ . '/functions/expenses.php'; require_once __DIR__ . '/functions/expense_import.php'; require_once __DIR__ . '/functions/trips.php'; +require_once __DIR__ . '/functions/income.php'; diff --git a/functions/client_overview.php b/functions/client_overview.php index 3628dcae9..0886ccef9 100644 --- a/functions/client_overview.php +++ b/functions/client_overview.php @@ -544,7 +544,7 @@ function clientOverviewMoney($client_id, $balance, $recurring_monthly) { return dashboardSum("SELECT SUM(payment_amount) FROM payments JOIN invoices ON payment_invoice_id = invoice_id WHERE invoice_client_id = $client_id AND payment_date BETWEEN '$from' AND '$to'") + dashboardSum("SELECT SUM(revenue_amount) FROM revenues WHERE revenue_client_id = $client_id - AND revenue_category_id > 0 AND revenue_date BETWEEN '$from' AND '$to'"); + AND " . dashboardRevenueSql() . " AND revenue_date BETWEEN '$from' AND '$to'"); }; $paid_ytd = $paid($year_start, $today); $paid_last_ytd = $paid($last_year_start, $last_year_today); @@ -625,7 +625,7 @@ function clientOverviewMonthly($client_id) { JOIN invoices ON payment_invoice_id = invoice_id WHERE invoice_client_id = $client_id AND payment_date BETWEEN '$from' AND '$to' GROUP BY m", $months), dashboardMonthly("SELECT DATE_FORMAT(revenue_date, '%Y-%m') AS m, SUM(revenue_amount) AS v FROM revenues - WHERE revenue_client_id = $client_id AND revenue_category_id > 0 AND revenue_date BETWEEN '$from' AND '$to' GROUP BY m", $months) + WHERE revenue_client_id = $client_id AND " . dashboardRevenueSql() . " AND revenue_date BETWEEN '$from' AND '$to' GROUP BY m", $months) ); return [ diff --git a/functions/dashboard.php b/functions/dashboard.php index 55dbe1873..940198860 100644 --- a/functions/dashboard.php +++ b/functions/dashboard.php @@ -642,17 +642,16 @@ function dashboardMoney($amount) { } /** - * SQL: a revenue that is income - it has a category, and it is not the in - * side of a transfer between accounts. The transfer handler leaves both - * halves without a category; one that has been given one since (the demo data - * files them as "Account Transfer") is still only money moving between - * accounts. The out side is never spending: it has no vendor. + * SQL: a revenue that is income - any that is not the in side of a transfer + * between accounts, categorised or not, as the Income list counts it + * (incomeRevenueSql()). The out side of a transfer is never spending: it has + * no vendor. */ function dashboardRevenueSql() { - return "revenue_category_id > 0 AND revenue_id NOT IN (SELECT transfer_revenue_id FROM transfers)"; + return incomeRevenueSql('revenues.revenue_id'); } -/** Cash basis, as the Income reports: invoice payments plus categorised revenues. */ +/** Cash basis, as the Income list and reports: invoice payments plus other income. */ function dashboardIncome($start, $end) { return dashboardSum("SELECT SUM(payment_amount) FROM payments WHERE payment_date BETWEEN '$start' AND '$end'") + dashboardSum("SELECT SUM(revenue_amount) FROM revenues WHERE " . dashboardRevenueSql() . " AND revenue_date BETWEEN '$start' AND '$end'"); @@ -932,7 +931,7 @@ function dashboardTopClients($period, $limit = 8) { WHERE payment_date BETWEEN '$start' AND '$end' " . clientScopeSql('invoice_client_id') . " UNION ALL SELECT revenue_client_id, revenue_amount FROM revenues - WHERE revenue_category_id > 0 AND revenue_client_id > 0 AND revenue_date BETWEEN '$start' AND '$end' " . clientScopeSql('revenue_client_id') . " + WHERE " . dashboardRevenueSql() . " AND revenue_client_id > 0 AND revenue_date BETWEEN '$start' AND '$end' " . clientScopeSql('revenue_client_id') . " ) AS income JOIN clients USING (client_id) GROUP BY client_id, client_name diff --git a/functions/export.php b/functions/export.php index 7fc0850dc..4ed204004 100644 --- a/functions/export.php +++ b/functions/export.php @@ -396,7 +396,8 @@ function getExportColumns($export_type) { 'income' => [ 'income_date' => ['label' => 'Date'], 'income_type' => ['label' => 'Type'], - 'income_source' => ['label' => 'Source', 'weight' => 3], + 'income_invoice' => ['label' => 'Invoice'], + 'income_source' => ['label' => 'What for', 'weight' => 3], 'income_category' => ['label' => 'Category'], 'income_client' => ['label' => 'Client', 'weight' => 2], 'income_amount' => ['label' => 'Amount', 'format' => 'money'], @@ -404,6 +405,7 @@ function getExportColumns($export_type) { 'income_method' => ['label' => 'Payment Method'], 'income_reference' => ['label' => 'Reference'], 'income_account' => ['label' => 'Account'], + 'income_online' => ['label' => 'Paid Online'], ], 'transactions' => [ diff --git a/functions/income.php b/functions/income.php new file mode 100644 index 000000000..f5007fb8d --- /dev/null +++ b/functions/income.php @@ -0,0 +1,388 @@ += 2; +} + +/** May this agent delete other income? */ +function incomeCanDelete() { + return lookupUserPermission('module_financial') >= 3; +} + +/** May this agent change and delete payments - the invoice's own rule. */ +function incomeCanEditPayments() { + return lookupUserPermission('module_sales') >= 3 && lookupUserPermission('module_financial') >= 3; +} + +/** Was a payment made online - through a payment provider? */ +function incomeOnlineSql($reference = 'payment_reference') { + return "$reference LIKE 'Stripe - pi\\_%'"; +} + +/** + * Every payment and every piece of other income as one list, in the agent's + * client scope - a row each with income_type (payment or revenue), + * income_id, income_date, income_created_at, income_invoice_id, + * invoice_prefix, invoice_number, invoice_status, income_description, + * income_category_id, category_name, category_color, income_client_id, + * client_name, income_amount, income_currency_code, income_method, + * income_reference, income_account_id, account_name, account_archived_at + * and income_online. Select from it as a derived table. + */ +function incomeUnionSql() { + return "SELECT 'payment' AS income_type, payment_id AS income_id, payment_date AS income_date, payment_created_at AS income_created_at, + payment_invoice_id AS income_invoice_id, invoice_prefix, invoice_number, invoice_status, invoice_scope AS income_description, + COALESCE(invoice_category_id, 0) AS income_category_id, category_name, category_color, + COALESCE(invoice_client_id, 0) AS income_client_id, client_name, payment_amount AS income_amount, payment_currency_code AS income_currency_code, + payment_method AS income_method, payment_reference AS income_reference, payment_account_id AS income_account_id, account_name, account_archived_at, + IF(" . incomeOnlineSql() . ", 1, 0) AS income_online + FROM payments + LEFT JOIN invoices ON invoice_id = payment_invoice_id + LEFT JOIN clients ON client_id = invoice_client_id + LEFT JOIN accounts ON account_id = payment_account_id + LEFT JOIN categories ON category_id = invoice_category_id + WHERE payment_archived_at IS NULL " . clientScopeSql('COALESCE(invoice_client_id, 0)') . " + UNION ALL + SELECT 'revenue', revenue_id, revenue_date, revenue_created_at, + 0, NULL, NULL, NULL, revenue_description, + revenue_category_id, category_name, category_color, + revenue_client_id, client_name, revenue_amount, revenue_currency_code, + revenue_payment_method, revenue_reference, revenue_account_id, account_name, account_archived_at, + 0 + FROM revenues + LEFT JOIN clients ON client_id = revenue_client_id + LEFT JOIN accounts ON account_id = revenue_account_id + LEFT JOIN categories ON category_id = revenue_category_id + WHERE revenue_archived_at IS NULL AND " . incomeRevenueSql() . ' ' . clientScopeSql('revenue_client_id'); +} + +/** The ways the list can be cut - ?show= (and the old ?type=Payment / Revenue). */ +function incomeShows() { + return [ + '' => ['label' => 'All', 'where' => ''], + 'payments' => ['label' => 'Invoice payments', 'icon' => 'fa-file-invoice-dollar', 'where' => "income.income_type = 'payment'", 'title' => 'Paid against an invoice'], + 'other' => ['label' => 'Other income', 'icon' => 'fa-hand-holding-usd', 'where' => "income.income_type = 'revenue'", 'title' => "Money in that isn't for an invoice"], + 'online' => ['label' => 'Paid online', 'icon' => 'fa-globe', 'where' => 'income.income_online = 1', 'title' => 'Paid by card through the payment provider'], + 'uncategorised' => ['label' => 'No category', 'icon' => 'fa-question-circle', 'where' => 'income.income_category_id = 0', 'title' => 'Not in a category - the reports show it as No category'], + ]; +} + +/** The ?show= a request asks for - ?show=, or the ?type=payment / revenue older links use. */ +function incomeShowFrom(array $src) { + $show = (string) ($src['show'] ?? ''); + $type = strtolower((string) ($src['type'] ?? '')); + if ($show === '' && $type !== '') { + $show = $type === 'payment' ? 'payments' : ($type === 'revenue' ? 'other' : ''); + } + return isset(incomeShows()[$show]) ? $show : ''; +} + +/** + * The WHERE over incomeUnionSql() AS income for a set of filters, as the list + * and the export take them: q, client, category, account, method, show (or + * type), dtf and dtt. $summary gets what each is, by name, for the export. + * $with_show false leaves the pill out, for the pills' own counts. + */ +function incomeFiltersSql(array $src, &$summary = [], $with_show = true) { + $summary = []; + $sql = '1 = 1'; + $q = trim((string) ($src['q'] ?? '')); + if ($q !== '') { + $like = escapeSql($q); + $amount_text = str_replace([',', '$'], '', $q); + $amount = preg_match('/^-?\d+(\.\d{1,2})?$/', $amount_text) ? round(floatval($amount_text), 2) : null; + $sql .= " AND (income.income_description LIKE '%$like%' OR income.income_reference LIKE '%$like%' OR income.client_name LIKE '%$like%' + OR income.category_name LIKE '%$like%' OR income.account_name LIKE '%$like%' OR income.income_method LIKE '%$like%' + OR CONCAT(COALESCE(income.invoice_prefix, ''), COALESCE(income.invoice_number, '')) LIKE '%$like%'" . ($amount !== null ? " OR income.income_amount = $amount" : '') . ")"; + $summary['Search'] = $q; + } + foreach (['client' => ['income.income_client_id', 'clients', 'client_name', 'Client'], 'category' => ['income.income_category_id', 'categories', 'category_name', 'Category'], + 'account' => ['income.income_account_id', 'accounts', 'account_name', 'Account']] as $key => $f) { + $id = intval($src[$key] ?? 0); + if ($id) { + $sql .= " AND {$f[0]} = $id"; + $summary[$f[3]] = (string) getFieldById($f[1], $id, $f[2]); + } + } + $method = trim((string) ($src['method'] ?? '')); + if ($method !== '') { + $sql .= " AND income.income_method = '" . escapeSql($method) . "'"; + $summary['Paid by'] = $method; + } + $show = incomeShowFrom($src); + if ($with_show && $show !== '') { + $sql .= ' AND ' . incomeShows()[$show]['where']; + $summary['Showing'] = incomeShows()[$show]['label']; + } + $dtf = (string) ($src['dtf'] ?? ''); + $dtt = (string) ($src['dtt'] ?? ''); + $dtf = preg_match('/^\d{4}-\d{2}-\d{2}$/', $dtf) ? $dtf : '1970-01-01'; + $dtt = preg_match('/^\d{4}-\d{2}-\d{2}$/', $dtt) ? $dtt : '2099-12-31'; + if ($dtf !== '1970-01-01' || $dtt !== '2099-12-31') { + $sql .= " AND income.income_date BETWEEN '$dtf' AND '$dtt'"; + $range = formatExportDateRange($dtf, $dtt); + if ($range) { + $summary['Dated'] = $range; + } + } + return $sql; +} + +/** What came in between two dates, in the agent's client scope - optionally one client's. */ +function incomeTotal($from, $to, $client_id = 0) { + global $mysqli; + $client_id = intval($client_id); + $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(income_amount), 0) FROM (" . incomeUnionSql() . ") AS income + WHERE income.income_date BETWEEN '" . escapeSql($from) . "' AND '" . escapeSql($to) . "'" . ($client_id ? " AND income.income_client_id = $client_id" : ''))); + return round(floatval($row[0]), 2); +} + +/** + * The money along the top of the list, in the agent's client scope (one + * client's on their page): this month against the same days of last month, + * the year so far with its monthly average, what the running recurring + * invoices bill a month, and what is still owed on invoices (and how much + * of it is late). + */ +function incomeStats($client_id = 0) { + global $mysqli; + $client_id = intval($client_id); + $month_start = date('Y-m-01'); + $today = date('Y-m-d'); + $last_start = date('Y-m-01', strtotime('first day of last month')); + $last_to_date = date('Y-m-d', min(strtotime(date('Y-m-t', strtotime($last_start))), strtotime($last_start . ' +' . (intval(date('j')) - 1) . ' days'))); + $year_start = date('Y-01-01'); + $stats = [ + 'month_start' => $month_start, 'last_start' => $last_start, 'year_start' => $year_start, + 'month' => incomeTotal($month_start, $today, $client_id), + 'last_to_date' => incomeTotal($last_start, $last_to_date, $client_id), + 'year' => incomeTotal($year_start, $today, $client_id), + ]; + $stats['change'] = $stats['last_to_date'] > 0 ? round(($stats['month'] - $stats['last_to_date']) / $stats['last_to_date'] * 100) : null; + $stats['year_average'] = round($stats['year'] / max(1, intval(date('n')) - 1 + intval(date('j')) / intval(date('t'))), 2); + + $client_sql = $client_id ? " AND recurring_invoice_client_id = $client_id" : ''; + $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(" . recurringInvoiceMonthlySql() . "), 0), COUNT(*) FROM recurring_invoices + WHERE recurring_invoice_status = 1 AND recurring_invoice_archived_at IS NULL $client_sql " . clientScopeSql('recurring_invoice_client_id'))); + $stats['recurring'] = round(floatval($row[0]), 2); + $stats['recurring_count'] = intval($row[1]); + + $open = dashboardOpenInvoicesSql(($client_id ? " AND invoice_client_id = $client_id" : '') . ' ' . clientScopeSql('invoice_client_id')); + $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(balance), 0), COUNT(*), COALESCE(SUM(CASE WHEN invoice_due < CURDATE() THEN balance END), 0), + COALESCE(SUM(invoice_due < CURDATE()), 0) + FROM ($open) AS open_invoices WHERE balance > 0")); + $stats['owed'] = round(floatval($row[0]), 2); + $stats['owed_count'] = intval($row[1]); + $stats['overdue'] = round(floatval($row[2]), 2); + $stats['overdue_count'] = intval($row[3]); + return $stats; +} + +/** The income categories - id => row, archived ones left out but $keep_id. */ +function incomeCategories($keep_id = 0) { + global $mysqli; + $keep_id = intval($keep_id); + $categories = []; + $sql = mysqli_query($mysqli, "SELECT category_id, category_name, category_color, category_icon, category_archived_at FROM categories + WHERE category_type = 'Income' AND category_name <> 'Account Transfer' AND (category_archived_at IS NULL OR category_id = $keep_id) + ORDER BY category_name ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + $categories[intval($row['category_id'])] = $row; + } + return $categories; +} + +/* ---------------------------------------------------------- Other income */ + +/** One piece of other income with its client, category, account and who entered it - raw, or null. Scoped; a transfer's leg isn't income. */ +function incomeRevenueRow($revenue_id, $scoped = true) { + global $mysqli; + $revenue_id = intval($revenue_id); + if (!$revenue_id) { + return null; + } + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT revenues.*, client_name, client_abbreviation, category_name, category_color, account_name, + account_archived_at, account_currency_code, creator.user_name AS created_by_name + FROM revenues + LEFT JOIN clients ON client_id = revenue_client_id + LEFT JOIN categories ON category_id = revenue_category_id + LEFT JOIN accounts ON account_id = revenue_account_id + LEFT JOIN users AS creator ON creator.user_id = revenue_created_by + WHERE revenue_id = $revenue_id AND revenue_archived_at IS NULL AND " . incomeRevenueSql('revenue_id') . ' ' . ($scoped ? clientScopeSql('revenue_client_id') : '') . " + LIMIT 1")); + return $row ?: null; +} + +/** Other income in a phrase - "Referral fee - $250.00 on 2026-10-05". Raw. */ +function incomeRevenueLabel(array $row) { + $what = trim(strtok((string) ($row['revenue_description'] ?? ''), "\n")); + if ($what === '') { + $what = trim((string) ($row['category_name'] ?? '')) ?: 'Other income'; + } + return $what . ' - ' . billingMoney($row['revenue_amount'], $row['revenue_currency_code']) . ' on ' . $row['revenue_date']; +} + +/** + * The one path that writes other income from a form - New, Edit and Copy. + * $in: date, amount, account, category, client_id, payment_method, + * reference and description. The amount can't be nothing; the currency is + * the account's. Returns the revenue id, or 0 with $error. + */ +function incomeRevenueSave(array $in, $revenue_id = 0, &$error = '') { + global $mysqli, $session_user_id, $session_company_currency; + $error = ''; + $revenue_id = intval($revenue_id); + $existing = $revenue_id ? incomeRevenueRow($revenue_id) : null; + if ($revenue_id && !$existing) { + $error = 'That income no longer exists, or is not one you can see'; + return 0; + } + + $date = trim((string) ($in['date'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !checkdate(intval(substr($date, 5, 2)), intval(substr($date, 8, 2)), intval(substr($date, 0, 4)))) { + $error = 'Enter the date it came in'; + return 0; + } + $amount = billingNumberInput($in['amount'] ?? ''); + if ($amount === null || $amount == 0 || abs($amount) >= 1e12) { + $error = 'Enter the amount'; + return 0; + } + $accounts = paymentAccounts($existing ? $existing['revenue_account_id'] : 0); + $account_id = intval($in['account'] ?? 0); + if (!isset($accounts[$account_id])) { + $error = 'Pick the account it went into'; + return 0; + } + $categories = incomeCategories($existing ? $existing['revenue_category_id'] : 0); + $category_id = intval($in['category'] ?? 0); + if (!isset($categories[$category_id])) { + $error = 'Pick a category'; + return 0; + } + $client_id = intval($in['client_id'] ?? 0); + if ($client_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT client_id FROM clients WHERE client_id = $client_id " . clientScopeSql('client_id')))) { + $error = "That client isn't one you can open"; + return 0; + } + $method = trim((string) ($in['payment_method'] ?? '')); + if ($method !== '' && !in_array($method, paymentMethods(), true) && !($existing && $method === (string) $existing['revenue_payment_method'])) { + $error = 'Pick how it was paid'; + return 0; + } + $reference = mb_substr(trim((string) ($in['reference'] ?? '')), 0, 200); + $description = trim(str_replace("\r\n", "\n", (string) ($in['description'] ?? ''))); + $currency = (string) ($accounts[$account_id]['account_currency_code'] ?: $session_company_currency); + + $set = "revenue_date = '$date', revenue_amount = $amount, revenue_currency_code = '" . escapeSql($currency) . "', revenue_account_id = $account_id, + revenue_category_id = $category_id, revenue_client_id = $client_id, revenue_payment_method = " . ($method === '' ? 'NULL' : "'" . escapeSql($method) . "'") . ", + revenue_reference = '" . escapeSql($reference) . "', revenue_description = " . ($description === '' ? 'NULL' : "'" . escapeSql($description) . "'"); + if ($existing) { + mysqli_query($mysqli, "UPDATE revenues SET $set WHERE revenue_id = $revenue_id"); + return $revenue_id; + } + mysqli_query($mysqli, "INSERT INTO revenues SET $set, revenue_created_by = " . intval($session_user_id)); + return mysqli_insert_id($mysqli); +} + +/** Delete other income this agent may see - the row it deleted, or null with $error. */ +function incomeRevenueDelete($revenue_id, &$error = '') { + global $mysqli; + $error = ''; + $row = incomeRevenueRow($revenue_id); + if (!$row) { + $error = 'That income no longer exists, or is not one you can see'; + return null; + } + mysqli_query($mysqli, "DELETE FROM revenues WHERE revenue_id = " . intval($row['revenue_id'])); + return $row; +} + +/* -------------------------------------------------------------- Payments */ + +/** + * A payment with its invoice (number, status, amount, client, category), + * account, what the invoice has been paid in all, the other payments on it + * and, for one made online, the payment provider's fee expense - raw, or + * null. Scoped to the invoice's client. + */ +function incomePaymentRow($payment_id) { + global $mysqli; + $payment_id = intval($payment_id); + if (!$payment_id) { + return null; + } + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT payments.*, invoice_id, invoice_prefix, invoice_number, invoice_status, invoice_amount, invoice_currency_code, + invoice_date, invoice_due, invoice_scope, invoice_client_id, client_name, category_name, category_color, invoice_category_id, account_name, account_archived_at + FROM payments + LEFT JOIN invoices ON invoice_id = payment_invoice_id + LEFT JOIN clients ON client_id = invoice_client_id + LEFT JOIN categories ON category_id = invoice_category_id + LEFT JOIN accounts ON account_id = payment_account_id + WHERE payment_id = $payment_id AND payment_archived_at IS NULL " . clientScopeSql('COALESCE(invoice_client_id, 0)') . " + LIMIT 1")); + if (!$row) { + return null; + } + $row['invoice_paid'] = $row['invoice_id'] ? invoicePaid($row['invoice_id']) : 0.0; + $row['others'] = []; + if ($row['invoice_id']) { + $sql = mysqli_query($mysqli, "SELECT payment_id, payment_date, payment_amount, payment_currency_code, payment_method FROM payments + WHERE payment_invoice_id = " . intval($row['invoice_id']) . " AND payment_id <> $payment_id AND payment_archived_at IS NULL ORDER BY payment_date ASC, payment_id ASC"); + while ($other = mysqli_fetch_assoc($sql)) { + $row['others'][] = $other; + } + } + $row['fee'] = null; + if (preg_match('/^Stripe - pi_/', (string) $row['payment_reference'])) { + $row['fee'] = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT expense_id, expense_amount, expense_currency_code FROM expenses + WHERE LOCATE('" . escapeSql($row['payment_reference']) . "', expense_reference) = 1 ORDER BY expense_id ASC LIMIT 1")) ?: null; + } + return $row; +} + +/** A payment in a phrase - "INV-12 - $500.00 on 2026-10-05". Raw. */ +function incomePaymentLabel(array $row) { + return ($row['invoice_number'] !== null ? $row['invoice_prefix'] . $row['invoice_number'] : 'A payment') . ' - ' . billingMoney($row['payment_amount'], $row['payment_currency_code']) + . ' on ' . $row['payment_date']; +} + +/* ---------------------------------------------------------------- Bulk */ + +/** + * The ticked rows of the Income list - "payment-12" / "revenue-7" - as + * ['payment' => ids, 'revenue' => ids]. Anything else is left out. + */ +function incomeBulkIds($refs) { + $ids = ['payment' => [], 'revenue' => []]; + foreach ((array) $refs as $ref) { + if (is_string($ref) && preg_match('/^(payment|revenue)-([1-9]\d*)$/i', $ref, $m)) { + $ids[strtolower($m[1])][intval($m[2])] = intval($m[2]); + } + } + return $ids; +} diff --git a/functions/reports.php b/functions/reports.php index ef2a06ece..fd9c11624 100644 --- a/functions/reports.php +++ b/functions/reports.php @@ -23,7 +23,8 @@ * require_once "../../includes/footer.php"; * * Money is counted the way the dashboard counts it: income is what came in - - * invoice payments, and revenues with a category; spending is expenses with a + * invoice payments, and other income that isn't a transfer, categorised or + * not (functions/income.php); spending is expenses with a * vendor, as an expense with no vendor is the out side of a transfer between * accounts. Anything tied to a client goes through clientScopeSql(), so an * agent restricted to some clients only ever sees those clients' rows; the @@ -1469,7 +1470,7 @@ function reportCategoryChartSeries(array $categories, array $grid, $months_shown * the year and its share, a totals row, and each amount opening the list it * came from ($list: income.php or expenses.php). */ -function reportCategoryYearTable(array $categories, array $grid, $year, $list, $total_label) { +function reportCategoryYearTable(array $categories, array $grid, $year, $list, $total_label, $uncategorised = '') { $columns = reportYearColumns('month'); $totals = reportGridTotals($grid); $html = '
' @@ -1487,10 +1488,10 @@ function reportCategoryYearTable(array $categories, array $grid, $year, $list, $ . (!empty($category['category_archived_at']) ? ' Archived' : '') . ''; foreach ($columns as $i => [$label, $first, $last]) { [$dtf, $dtt] = reportColumnDates($year, $first, $last); - $url = $cat ? "/agent/$list?category=$cat&canned_date=custom&dtf=$dtf&dtt=$dtt" : ''; + $url = $cat || $uncategorised !== '' ? "/agent/$list?" . ($cat ? "category=$cat" : $uncategorised) . "&canned_date=custom&dtf=$dtf&dtt=$dtt" : ''; $html .= ''; } - $url = $cat ? "/agent/$list?category=$cat&canned_date=custom&dtf=$year-01-01&dtt=$year-12-31" : ''; + $url = $cat || $uncategorised !== '' ? "/agent/$list?" . ($cat ? "category=$cat" : $uncategorised) . "&canned_date=custom&dtf=$year-01-01&dtt=$year-12-31" : ''; $share = reportShare($row['total'], $totals['total'], 1); $html .= '' . ''; diff --git a/functions/search.php b/functions/search.php index efbe4185f..c4178e314 100644 --- a/functions/search.php +++ b/functions/search.php @@ -558,6 +558,31 @@ function globalSearchGroups($terms, $deep) { }, ]; + // Income - other income by what it was or its reference, a payment by its reference (check or + // transaction number), either by the amount. The union is already in the agent's client scope. + $income_amount_match = $expense_amount > 0 ? "OR ABS(income.income_amount) = $expense_amount" : ''; + $groups[] = [ + 'key' => 'income', 'label' => 'Income', 'icon' => 'hand-holding-usd', 'module' => 'module_financial', 'enabled' => $accounting, + 'select' => 'income.*', + 'from' => '(' . incomeUnionSql() . ') AS income', + 'where' => "((income.income_type = 'revenue' AND income.income_description LIKE '%$like%') OR income.income_reference LIKE '%$like%' $income_amount_match)", + 'scope' => null, + 'order' => ($expense_amount > 0 ? "(ABS(income.income_amount) = $expense_amount) DESC, " : '') . 'income.income_date DESC, income.income_id DESC', + 'list_url' => '/agent/income.php?' . http_build_query(['q' => $q]), + 'item' => function ($row) { + $payment = $row['income_type'] === 'payment'; + $what = trim(strtok((string) $row['income_description'], "\n")); + return [ + 'title' => $payment ? 'Payment on ' . ($row['invoice_number'] !== null ? $row['invoice_prefix'] . $row['invoice_number'] : 'an invoice since deleted') + : ($what !== '' ? mb_strimwidth($what, 0, 80, '...') : ($row['category_name'] ?: 'Other income')), + 'subtitle' => globalSearchMoney($row['income_amount'], $row['income_currency_code']) . ' · ' . $row['income_date'] + . ($row['income_reference'] ? ' · #' . $row['income_reference'] : ''), + 'context' => implode(' · ', array_filter([$row['client_name'], $payment ? '' : (string) $row['category_name']])), + 'url' => '/agent/income.php?open=' . $row['income_type'] . '-' . intval($row['income_id']), + ]; + }, + ]; + $vendor_phone_match = $phone ? "OR REGEXP_REPLACE(vendor_phone, '[^0-9]', '') LIKE '%$phone%'" : ''; $groups[] = [ 'key' => 'vendors', 'label' => 'Vendors', 'icon' => 'building', 'module' => 'module_client',
' . reportGridAmount($row['months'][$i], $url) . '' . reportGridAmount($row['total'], $url) . '' . reportPercent($share) . '