From be002b6f2992d92121ef897e866e4bb551ae5eb3 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sat, 17 Oct 2026 21:52:10 -0400 Subject: [PATCH] Roles stage 4: the money on invoices goes by Financial with Sales at Read through billingMoneyCan and enforceBillingMoney, so taking payments one at a time, by saved card or as a batch, changing them and emailing receipts, reminders and statements need write access to Financial, and deleting or refunding a payment needs full access, with the old Sales rules for reminders, statements and receipts still accepted; invoice, invoice list, Income and payment quick looks offer only what the role can do, the reminder window offers only the reminder to a role that cannot send the invoice, a reminder for a draft or with nothing owed still needs Sales, and Income bulk Set Category on payments needs Sales as it changes the invoice; database 3.1.8 gives the built-in Accountant full access to Financial when it still has its shipped levels, and new installs seed it that way; calendar events for a client follow the role's Clients level through calendarEventCan, Read to see, Modify to add, change, move, skip or send, Full to delete or Modify for the agent who added it, events an agent is on always show, events with no client stay open to everyone, and the calendar, dashboard and calendar links leave out other agents' client events for roles without Clients, with New event, the client picker and dragging following the same rule; the API now sets PHP and MySQL to ITFlow's timezone so what it creates, API ticket SLA times and key expiry go by local time, and inc_set_timezone.php only writes the session when one is open and no longer leaves its result and row variables behind; changelog updated --- CHANGELOG.md | 24 +++++- admin/database_updates/3.1.8.php | 28 +++++++ agent/ajax.php | 3 + agent/calendar.php | 7 +- agent/income.php | 8 +- agent/invoice.php | 49 ++++++++----- agent/invoices.php | 6 +- agent/js/calendar.js | 4 +- agent/location.php | 6 +- agent/modals/calendar/calendar_event.php | 29 +++++--- agent/modals/calendar/calendar_event_add.php | 1 + agent/modals/calendar/calendar_event_edit.php | 14 ++-- agent/modals/calendar/calendar_event_form.php | 9 ++- agent/modals/client/client_statement.php | 2 +- agent/modals/income/income_bulk_edit.php | 5 +- agent/modals/invoice/invoice_email.php | 22 +++++- agent/modals/invoice/invoice_payments.php | 5 +- agent/modals/payment/payment.php | 5 +- agent/modals/payment/payment_add.php | 3 +- agent/modals/payment/payment_bulk_add.php | 3 +- agent/modals/payment/payment_edit.php | 3 +- .../payment/payment_saved_method_add.php | 3 +- agent/post/event.php | 15 ++-- agent/post/income.php | 15 ++-- agent/post/invoice.php | 19 ++++- agent/post/payment.php | 25 +++---- api/v1/validate_api_key.php | 17 ++--- functions/billing.php | 47 ++++++++++++ functions/calendar.php | 73 +++++++++++++++++-- functions/dashboard.php | 2 +- functions/income.php | 9 ++- includes/inc_set_timezone.php | 34 ++++++--- setup/seed_data.php | 2 +- 33 files changed, 366 insertions(+), 131 deletions(-) create mode 100644 admin/database_updates/3.1.8.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed897a6b..f5be898cf 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.1.7) 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.1.8) in the same pass. Nothing else is required. -This is a big release, with thirty-seven database steps in one go, so take a backup before you update. +This is a big release, with thirty-eight 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: @@ -39,6 +39,10 @@ Categories keep their names, colors, icons, order and records. A color saved as API keys keep their names, end dates and the agent they act as. Admin > API Keys shows each as never used until its next call. A key acting as an agent who is disabled or archived, or has no role, was already turned away by the API, and is now marked Won't work with the reason (see New Features, API Keys). +The built-in Accountant role gets full access to Financial, so it can delete an expense, other income, a transfer or a payment entered by mistake - but only if it is still as it came: named Accountant, with Clients, Support, Sales and Reports at Read and Financial at Modify. One you have changed keeps what you gave it (see Breaking Changes, Roles). + +What the API makes from now on - tickets, replies, clients and the rest - is stamped in ITFlow's timezone, as the pages stamp what they make. What it made before keeps the server's time, which on a server set to UTC is hours off (see Bug Fixes). + 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 @@ -107,7 +111,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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. + - 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 go by Financial too, with Sales at Read (see Roles). - 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. @@ -172,6 +176,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Add Tickets on an invoice now adds a line for each ticket's time. To only put the tickets on the invoice, as before, switch off Bill their time. - A ticket's time is counted the same way everywhere: Unbilled Time, the Invoice window, Add Tickets and the Unbilled Work report. A ticket counts once it is resolved or closed, and archived tickets and archived replies are left out. - Calendars: only admins can add, edit or delete calendars now. Agents can still add and edit events. +- Roles: + - The money on invoices goes by Financial, with Sales at Read to see the invoices: write access to Financial to take a payment (one, a saved card or a batch) or change one, and full access to delete or refund one. These used to need Sales as well - write access to take a payment, full access to Sales and Financial to change or delete one. Emailing a payment reminder, a statement or a receipt works with write access to Financial and Sales at Read, as well as with write access to Sales, as before. Nothing a role could do before is taken away. Editing, sending, cancelling and deleting an invoice stay with Sales. + - A client's calendar events follow the role's Clients access: Read to see them, write access to add, change, move, skip or send them, and full access to delete them - or write access, for the agent who added it. Agents still see the events they are on, and an event with no client is anyone's, as before. So an agent whose role has no Clients access no longer sees other agents' client events, on the calendar or through their calendar link, and deleting someone else's client event now takes full access to Clients. - Licenses: archiving a license keeps its seats (the devices and users on it), so restoring it brings everything back. In 26.10, archiving took the seats off. - Vendors: deleting a vendor that has expenses, recurring expenses or payment provider fees archives it instead, so your books keep it. Deleting any vendor now also clears it from the assets, licenses, tickets and domains that named it. - Locations: a client's primary location cannot be archived or deleted. Make another location primary first. Bulk archive skips primary locations. @@ -823,6 +830,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run - A user's quick look shows what they can open, from their role, and the user form shows what the role picked opens. - API: read the roles - an admin key every role, with its level of each part of ITFlow and how many agents have it, archived ones too; any other key only its own user's role. Read the agents, with their role, under the Support permission. - API: a call the key's role doesn't allow says what it needs and what the key's user has, with how to get it changed (see Breaking Changes, API). + - Accounting is the Accountant's job: with Financial and Sales at Read, a role can take payments - one, a saved card or a batch - change them and email receipts, reminders and statements, and with full access to Financial delete and refund them (see Breaking Changes, Roles). The invoice, the invoice list, Income and the payment quick looks offer what the role can do, and the reminder window offers only the reminder to a role that can't send the invoice itself. The built-in Accountant role now has full access to Financial. + - Calendar events follow the role, through the client they are for (see Breaking Changes, Roles). A role that can't add to a client's calendar isn't offered New event there, or a client in the event window, which says why, and its events can't be dragged. - **API Keys** (rebuilt) - Admin > API Keys shows, for each key, the agent it acts as and their role, what it can open and for which clients, whether it works and when it was last used. Whether it works reads Works until a day, Expires in so many days in its last two weeks, Expired, or Won't work with why: its agent is disabled, archived or has no role, or it acts as nobody. Search finds keys by their name or their agent's. - The New API Key and Edit windows show what the picked agent can open as you pick them, and offer only active agents. A new key lasts a year unless you change it, and its last day has to be after today. An expired key can be renamed and stays expired; a later day brings it back. @@ -929,6 +938,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - API keys: revoking, deleting and bulk deleting keys showed and logged no name ("API Key revoked"), and the bulk message counted the keys ticked rather than those deleted. A name with an apostrophe showed with a backslash in the created and updated messages. - API keys: a new key could be given today as its last day, so it never worked. An expired key couldn't be renamed without giving it a new day, as Edit wouldn't save the one it had. - API: reading an agent's time (`technicians/time.php`) needed Modify on Support, though it only reads. +- API: the API ran on the server's clock, not ITFlow's timezone, so the tickets, replies and other records it made were stamped in the server's time - hours off on a server set to UTC - and an API ticket's SLA times were worked out from that. A key also stopped working at midnight by the server's clock. +- Calendar: any agent could change, move or delete any client's event, whatever their role, and one whose role has no Clients access saw every client's events. +- Roles: the built-in Accountant role couldn't take a payment, email a reminder or statement, or delete an expense or other income entered by mistake. - Contacts: an agent without Support saw a contact's Assets, Documents, Files and Services tabs, which led to pages they were refused. A document's or file's linked assets, licenses, services, domains and certificates were listed to them too. - All Client Docs listed Credentials to agents who can't open it. - Notifications: shared-link notifications reached agents without Support, with a link they couldn't open. @@ -1348,6 +1360,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Admin: `admin/api_keys.php` is rebuilt (`q`; no paging or sorting). `admin/post/api_keys.php`: `add_api_key` (`name`, `expire`, `run_as_user`, `key` - 32 of `A-Za-z0-9_-` - and `password`), `edit_api_key` (`api_key_id`, `name`, `expire`, `run_as_user`), GET `revoke_api_key` (`api_key_expire` to today) and `delete_api_key`, and `bulk_delete_api_keys` (`api_key_ids[]`). Each checks the key exists, and add and edit take an active agent only. Audit entries are API Key Create, Edit (with what changed, and only when something did), Revoke and Delete, each linked to its key. The Bulk Delete summary entry is gone, as each key logs its own Delete. - API: `api/v1/roles/read.php` (GET; `role_id`, `archived`, `limit`, `offset`) answers `role_id`, `role_name`, `role_description`, `role_is_admin`, `role_levels` (`module_name` => 0 to 3), `role_agents`, `role_created_at`, `role_updated_at` and `role_archived_at`. A key whose user isn't an admin gets only `$session_user_role`. `enforce_api_rbac.php` maps `roles` to `''`. `api/v1/technicians/read.php` (GET, `module_support`; `technician_id`, `archived`, `limit`, `offset`) answers `technician_id`, `technician_name`, `technician_email`, `technician_status`, `role_id`, `role_name`, `role_is_admin`, `technician_created_at` and `technician_archived_at`. - `enforce_api_rbac.php`: `apiPermissionText($module, $level)` words the module refusal, and `$operation_level` maps `time.php` to 1. +- Payments, calendar events and the API's clock (database 3.1.8): + - The migration gives the built-in Accountant (role 1) level 3 on `module_financial` when it still has its shipped levels (name, not admin, Clients, Support, Sales and Reports 1, Financial 2, no Credentials). `setup/seed_data.php` seeds it at 3. + - `functions/billing.php`: `billingMoneyRules()` (`take`, `edit`, `delete`, `receipt`, `remind`, `statement` - a Financial level with Sales at 1, or a Sales and Financial pair as before), `billingMoneyCan($action)` and `enforceBillingMoney($action)`. Used by `add_payment`, `add_payment_stripe`, `add_bulk_payment`, `edit_payment`, `delete_payment`, `refund_payment_stripe` and `payment_send_receipt` in `agent/post/payment.php`, `email_invoice` with `kind=reminder` (Sales 2 for anything else, a draft, or when nothing is owed) and `send_statement` in `agent/post/invoice.php`, and the payment, batch payment, statement and invoice email windows. `invoice_email.php` posts a hidden `kind=reminder` for a role without Sales 2. `apply_credit` and `add_credit` are unchanged, as credits aren't offered yet. + - `incomeCanEditPayments()` is `billingMoneyCan('edit')`, and new `incomeCanDeletePayments()` is `billingMoneyCan('delete')`. `bulk_edit_income` Set Category on payments also needs Sales 2, as it sets their invoices'. + - Calendar: `calendarEventCan($event, $action)` (`view`, `edit`, `delete`), `enforceCalendarEvent()`, `calendarIsOn($event_id)` and `calendarEventScopeSql()`, which `calendarEventItems()`, `calendarUserFeedItems()` and the dashboard's events add. `calendarEventForRequest()` and `calendarEventForModal()` take the action. `calendar_move` in `ajax.php` checks `edit`, and a calendar item's `editable` follows it. `agent/calendar.php` sets `data-can-add`, which `agent/js/calendar.js` reads to turn off picking dates. + - `includes/inc_set_timezone.php` sets `$session_timezone` and `$session_utc_offset`, puts them in `$_SESSION` only when a session is open, and no longer leaves `$result` and `$row` behind. `api/v1/validate_api_key.php` requires it, and sets `api_key_last_used_at` with `NOW()`. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name diff --git a/admin/database_updates/3.1.8.php b/admin/database_updates/3.1.8.php new file mode 100644 index 000000000..fe04336d2 --- /dev/null +++ b/admin/database_updates/3.1.8.php @@ -0,0 +1,28 @@ + 1, 'module_financial' => 2, 'module_reporting' => 1, 'module_sales' => 1, 'module_support' => 1]) { + mysqli_query($mysqli, "UPDATE user_role_permissions SET user_role_permission_level = 3 + WHERE user_role_id = 1 AND module_id = (SELECT module_id FROM modules WHERE module_name = 'module_financial')"); + } + } diff --git a/agent/ajax.php b/agent/ajax.php index 1a00d9ee8..7c3f132e0 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -2217,6 +2217,9 @@ if (isset($_POST['calendar_move'])) { if (intval($event['event_client_id']) && !hasClientAccess($event['event_client_id'])) { $fail('You do not have access to that client.', 403); } + if (!calendarEventCan($event, 'edit')) { + $fail(permissionRefusedText('module_client', 2, intval(lookupUserPermission('module_client'))), 403); + } if (calendarEventRepeats($event)) { $fail('A repeating event is moved from Edit, so the whole series moves together.'); } diff --git a/agent/calendar.php b/agent/calendar.php index bfb1c513f..4729ec044 100644 --- a/agent/calendar.php +++ b/agent/calendar.php @@ -26,6 +26,8 @@ $calendars = calendarList(); $sources = calendarSources(); $can_manage = calendarCanManage(); $can_schedule = calendarCanScheduleTickets(); +// Inside a client, a new event is the client's - Clients at Modify (calendarEventCan()) +$can_add_event = calendarEventCan(['event_client_id' => $page_client_id], 'edit'); $csrf = $_SESSION['csrf_token']; $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_config_calendar_first_day FROM user_settings WHERE user_id = $session_user_id")); @@ -53,6 +55,7 @@ foreach ($sources as $key => $source) { data-user-id="" data-first-day="" data-can-schedule="" + data-can-add="" data-mobile="" data-initial-date="" data-sources=""> @@ -62,7 +65,9 @@ foreach ($sources as $key => $source) {
- + + + @@ -173,7 +178,11 @@ ob_start(); - + + + + +
'You do not have access to that client.']); exit; } + enforceCalendarEvent(['event_client_id' => $client_id], 'edit'); } $form_locked_client = $form_client_id && !empty($_GET['lock_client']); diff --git a/agent/modals/calendar/calendar_event_edit.php b/agent/modals/calendar/calendar_event_edit.php index 633d4eb41..660c0a445 100644 --- a/agent/modals/calendar/calendar_event_edit.php +++ b/agent/modals/calendar/calendar_event_edit.php @@ -8,7 +8,7 @@ require_once '../../../includes/modal_header.php'; -$form = calendarEventForModal($_GET['id'] ?? 0); +$form = calendarEventForModal($_GET['id'] ?? 0, 'edit'); $event_id = intval($form['event_id']); $form_client_id = intval($form['event_client_id']); $form_locked_client = false; @@ -47,11 +47,13 @@ ob_start();
diff --git a/agent/modals/calendar/calendar_event_form.php b/agent/modals/calendar/calendar_event_form.php index a95d4aff6..7ee31b0e6 100644 --- a/agent/modals/calendar/calendar_event_form.php +++ b/agent/modals/calendar/calendar_event_form.php @@ -132,12 +132,17 @@ $form_can_email = calendarCanEmail(); + +
An event for a client takes Clients at Modify in your role.
+
> diff --git a/agent/modals/client/client_statement.php b/agent/modals/client/client_statement.php index 2e94bb957..f498e66ce 100644 --- a/agent/modals/client/client_statement.php +++ b/agent/modals/client/client_statement.php @@ -8,7 +8,7 @@ require_once '../../../includes/modal_header.php'; -enforceUserPermission('module_sales', 2); +enforceBillingMoney('statement'); $client_id = intval($_GET['client_id']); diff --git a/agent/modals/income/income_bulk_edit.php b/agent/modals/income/income_bulk_edit.php index 412e2032a..5fafbc5ab 100644 --- a/agent/modals/income/income_bulk_edit.php +++ b/agent/modals/income/income_bulk_edit.php @@ -18,7 +18,8 @@ $payments = count($ids['payment']); $others = count($ids['revenue']); $count = $payments + $others; $field = (string) ($_GET['field'] ?? ''); -$can_payments = incomeCanEditPayments(); +// A payment's category is its invoice's - setting it changes the invoice +$can_payments = incomeCanEditPayments() && ($field !== 'category' || lookupUserPermission('module_sales') >= 2); $fields = [ 'category' => ['label' => 'Category', 'icon' => 'fa-tag'], @@ -44,7 +45,7 @@ $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') . '.'; + $notes[] = ($field === 'category' ? "A payment's category is its invoice's, and changing the invoice takes write access to Sales" : 'Changing a payment takes write access to 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') . '.'; } diff --git a/agent/modals/invoice/invoice_email.php b/agent/modals/invoice/invoice_email.php index 9cba77160..1240b977f 100644 --- a/agent/modals/invoice/invoice_email.php +++ b/agent/modals/invoice/invoice_email.php @@ -8,7 +8,15 @@ require_once '../../../includes/modal_header.php'; -enforceUserPermission('module_sales', 2); +// Sending the invoice is Sales; a reminder for one still owed is the money +// side, which accounting can send without Sales at Modify (billingMoneyCan()) +$can_send_invoice = lookupUserPermission('module_sales') >= 2; +if (!$can_send_invoice) { + if (($_GET['kind'] ?? '') !== 'reminder') { + enforceUserPermission('module_sales', 2); + } + enforceBillingMoney('remind'); +} $invoice_id = intval($_GET['invoice_id']); @@ -66,7 +74,7 @@ ob_start(); ?>
@@ -89,8 +97,14 @@ ob_start(); 0 && in_array($doc['invoice_status'], invoiceOpenStatuses(), true); $kind = $can_remind && ($_GET['kind'] ?? '') === 'reminder' ? 'reminder' : 'invoice'; - $reminder_when = $can_remind ? billingReminderWhen(-$doc_state['days']) : ''; ?> - + $reminder_when = $can_remind ? billingReminderWhen(-$doc_state['days']) : ''; + if (!$can_send_invoice && !$can_remind) { + enforceUserPermission('module_sales', 2); + } ?> + + +
A payment reminder - it is .
+
>