Files
itflow/CHANGELOG.md
johnnyq b5ceeb4eaf Bill sign-ups through the API and follow up when an invoice is paid - a lead that pays an invoice in full becomes a client however it is paid (online, autopay, a recorded payment or a batch), with the new A lead that pays an invoice becomes a client switch in Admin > Settings > Invoices and billing, on by default; a non-billable open ticket tied to an invoice (one raised through the API with ticket_invoice_id, or a quote's tickets once it becomes the invoice) gets an internal note when the invoice is paid in full, goes from On Hold back to Open, and its assigned agent is notified in the app and by email, while billable tickets whose time is on an invoice are left alone; new API endpoints recurring_invoices/create (lines from products or spelled out, the client's currency, frequency, start date, optional first invoice made at once and emailed to the primary and billing contacts, or marked Sent so its link can be paid, the answer including the invoice reference, amount, due date and pay link), recurring_invoices/read and portal_invites/create (sent from the company rather than the key's agent); tickets/create takes ticket_status (an active status other than Resolved and Closed), ticket_invoice_id (one of the client's own invoices) and ticket_notify agents to tell only the team; database 3.5.9 adds settings.config_invoice_paid_lead_to_client; invoicePaidFollowUp() runs from triggerCustomAction() on invoice_pay, with recurringInvoiceCreate(), billingInvoiceIssue() and the paid kind of ticketAgentEmailContent() in support; the Clients list reads each contract's term, so Renews soon shows again and its PHP warning is gone; changelog updated
2026-11-05 19:00:16 -05:00

734 KiB
Raw Blame History

Changelog

This file documents all notable changes made to ITFlow.

[26.11.3]

Upgrading

Update from Maintenance > Update — Update ITFlow hands the job to cron, which updates the files and applies this release's five database changes (3.5.4 to 3.5.9) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees, Manager Check-in and Managed Instances jobs in Maintenance > Cron are set up by themselves.

Security

  • Setup: the Restore a backup option on the setup page is open to anyone until the first user is created, as it has to be on a new install. A backup built for the purpose could use it to make the web server run code it carried into the uploads folder. This needed Apache with .htaccess overrides switched on - installs on nginx, and Apache servers that ignore .htaccess files, were not affected. A restore no longer brings in the files that decide how the web server treats a folder, it never writes into the folder backups are kept in, and ITFlow's own protections on the uploads folder are put back after every restore. Every release from 26.08 to 26.11.2 is affected. If an install of yours sat at the setup page where others could reach it, look through its uploads folder for .htaccess, .user.ini or .php files that ITFlow didn't put there.
  • Passwords: changing a password now signs that account out everywhere else. Before, a browser that was already signed in stayed signed in after the password changed - including after the incident-response reset in Admin > Users, which is meant to lock out someone who has got in. It covers changing your own password, an admin setting someone's, the incident-response reset, and on the client portal a contact changing theirs, a password reset link, and an agent setting a contact's portal password. On the portal, the browser making the change stays signed in. An agent's View as contact ends too when their password changes.
  • MFA: an agent whose account requires multi-factor authentication but who hadn't set it up yet could use ITFlow without ever setting it up, by going straight to other pages after signing in. Until it is set up, every page now goes back to setting it up, or signing out - including for an agent who is already signed in when an admin requires MFA, or resets it, for them. View as contact isn't offered to them either.
  • Client portal: the two-step sign-in set up that Admin > Client Portal can require could be got past in two ways - by adding a page name to the end of the address, or by sending another action along with setting it up. A contact who still had to set it up could open their tickets and invoices, and raise or resolve tickets. Neither works now.
  • MFA: agents are now emailed when multi-factor authentication is turned on for their account, as client portal contacts already were. Setting it up takes only the password, so this is how someone finds out an authenticator they didn't add was put on their account.

New Features & Updates

  • API: products can be updated through the API (products/update). Only the fields you send are changed, and the key's user needs edit access to Sales, the same as editing a product in ITFlow.
  • API: a ticket can be raised from a ticket template (tickets/create). Send ticket_template_id and the ticket gets what the template sets up - its subject and details unless you send your own, its category, priority, billing, agent, tags, due time and tasks - with the template's fill-ins answered from ticket_fill_ins. Send ticket_notify too and people hear about it as when it is raised in ITFlow: the contact gets the Ticket Created email, and the new-ticket address and the agent it is assigned to are told. The answer gives the ticket's number, as TCK-93, and the link its contact opens it with. A form on your website can open a ticket with the steps to work through already on it.
  • Canned Responses: on a ticket raised from a ticket template, the template's answers fill in too. A response that says [Hostname] or [Employee Name] gets that ticket's answer as it goes in, as [Contact] and [Ticket] do - so one response can serve every ticket from the template. An answer left blank stays in brackets for you to fill in.
  • Invoices: a lead that pays an invoice in full becomes a client by itself, however it is paid - online, by autopay or a payment you record. It is on from this update; turn off A lead that pays an invoice becomes a client in Admin > Settings > Invoices and billing to keep leads as leads until you change them.
  • Tickets: a ticket waiting on an invoice hears when it is paid in full - a note on the ticket says so, it goes from On Hold back to Open, and the agent it is assigned to is emailed. A quote's tickets wait on its invoice once the quote becomes one, and a ticket raised through the API can be tied to an invoice. A billable ticket whose time is on an invoice is left alone.
  • API: recurring invoices can be made (recurring_invoices/create) from your products or from lines you spell out, with the first invoice made and emailed at once so it can be paid from its link, and read (recurring_invoices/read). Someone can be invited to a client's portal (portal_invites/create). A new ticket can start On Hold or in any other open status (ticket_status), wait on one of the client's invoices (ticket_invoice_id), and be announced to your team only (ticket_notify "agents"). Together, a sign-up form on your website can make the lead, invoice them, invite them to the portal and open the ticket that waits for their payment.
  • API keys: agents are notified in ITFlow a week before an API key expires, so an integration doesn't stop without warning.
  • Payment Providers: Stripe's fee on a card payment is now recorded as an expense about 15 minutes after the payment, instead of the next morning. A new Payment Provider Fees job in Maintenance > Cron does it, and you can run it more often there. Record fees now still works any time.
  • Payment Providers: clients can pay the card fee. Turn on Clients pay the card fee on Stripe in Admin > Payment Providers and enter what Stripe charges you, as 2.9% plus 0.30. Each card payment is then made bigger by Stripe's fee on it, so you get the full invoice. The fee goes on the invoice as a Card Fee line, with no tax, when the payment goes through. Clients see it before they pay: on Pay invoices and the checkout page in the client portal, on the page an invoice's link opens, and next to autopay. It applies to payments in the client portal, through an invoice's link, by autopay, and when you charge a saved card, where the Charge Saved Card window shows it too. One payment for several invoices has one fee, shared over them. A client can be let off it with No card fee on their Billing tab. Refunding a card payment from ITFlow takes its Card Fee line back off the invoice. It is off until you turn it on. Card surcharges have rules where you and your clients are - in the US, for one, the card networks cap them and don't allow them on debit cards - so check them before turning it on.
  • Managed mode: one ITFlow can now manage others - a client's, a reseller's or your own hosted ones. Add one in the new Admin > Managed Instances, and paste the code it gives you into the other ITFlow's Admin > Managed By. From then on that ITFlow checks in every minute, and you see how each one is doing: its version and whether an update is waiting (a security one stands out), whether cron is running, its last backup, the errors it logged today, and its agents against its seat limit - and you're notified when one stops checking in. From the manager you can update an instance, or let it update itself a set number of days after a release comes out and straight away for a security release; set how many agents it can have and its white-label key; make it read-only, where its people can still sign in, look and export, or suspend it, with a message of your own; and sign in to it as support in a new tab, for two hours, with no password or seat of its own and every sign-in in its audit log. What the manager sets is locked on the instance, which says so - everything else stays the instance's own, and its admins can disconnect it unless its config.php keeps it managed ($config_manager_required = true, for a provider hosting it). Instances only ever call the manager, so they can sit behind a firewall, and they send how they're doing - never client data.
  • Export Data: the documentation PDF can go out as a secure link, like a login, document or file. Share a link next to Download PDF - or Share the IT documentation on a client's Shared Links - makes the PDF from the parts you tick and gives you a link that works for as long and as many times as you set, with a passphrase or an emailed code first if you like, and tells you when it's downloaded. The PDF is made when the link is, so later changes aren't in it, and it's kept encrypted until the link stops working - then it's deleted. With passwords and 2FA secrets in it, the PDF is locked with a password of its own too, as a download is, and the page the link opens says so.
  • Maintenance > Update is easier to follow. It opens with where your install stands - up to date, an update available, an update running, or the database still to finish - and the one button for it, now Update ITFlow instead of Queue Update. Below it is what's new: the release notes for the versions between yours and the latest, read from the changelog each time ITFlow checks for updates, with the developer notes folded away and the commits behind them a click further. Before you update it lists what to do first, and says whether any of ITFlow's own files have been changed on your install, which updating would put back. Once you click Update ITFlow, the page shows the update running, refreshes itself until it's done, and then says when ITFlow was updated, or what stopped it. Alongside are this install's version, branch, database, when it last checked and updated, whether cron is running, and the command to run the same update from a shell, with the folder and user filled in for your server.

Bug Fixes

  • Transactions: the list, its Transfers pill, Reconcile and the transactions export load faster again. On a test with eight years of books, the Transfers pill went from about a third of a second in 26.11.2 to under a fifth.
  • Expenses: billable expenses waiting to go on an invoice slowed down every page, because the side menu counts them and each one was checked against every expense. With a few hundred waiting, that was about a third of a second on every page. The invoice page, Add Expenses and an expense's quick look had the same delay. All of them are quick again.
  • Transactions: on All accounts, Money in and Money out counted every transfer between your own accounts twice - once out of one account and once into the other - so both were far too high. A Stripe payout to the bank, a check deposit or paying off a card all added to both. They now leave transfers between your accounts out, as the dashboard and Income already did. The Transfers pill still lists them, and one account on its own still counts them as money in and out of that account. The Difference was always right.
  • Clients: the Clients list never showed a contract as Renews soon - it read each client's contracts without their term - and wrote a PHP warning for each one. It does now.
  • API: adding a contact or a location through the API (contacts/create, locations/create) no longer writes a PHP warning to the server's error log for each field left out.
  • App Logs: the email parser logged an error every minute while email-to-ticket parsing was switched off, burying the errors that matter. It now just stops, and Maintenance > Cron says why.
  • Phone numbers: outside North America, a number shown with its country code kept the 0 - or the 8 in Russia - that is only dialled within the country, as "+49 0172 1234567" for a German mobile, wherever it showed: ITFlow's pages, the client portal (a contact's own profile, and the Contact us details), emails, PDFs and exports. Click-to-call dialled it that way too, and so failed. Numbers are now written the way each country writes them: "+49 172 1234567", and "0172 1234567" without the country code. This covers every country, not only the ones ITFlow had its own rules for. A German landline such as Munich's is split after its area code ("+49 89 1234567"), Italy keeps the 0 its landlines are dialled with from abroad ("+39 06 1234 5678"), and South African numbers read "+27 82 123 4567". A number typed with or without that leading 0, or with its country code at the front, shows and dials the same. North American numbers look as they did.

Developer Updates

  • backupRestoreArchive() no longer extracts the inner uploads.zip wholesale. It builds an allow-list of entries and extracts with extractTo($dir, $entries), leaving out any entry whose basename is .htaccess, .user.ini or .htpasswd and any entry that resolves inside backupStorageDir(), which the restore preserves while it empties the rest of uploads/. Unsafe paths (.., absolute) still fail the restore as before. scripts/restore_cli.php gets the same, as it calls the same function.
  • backupHardenStorageDir() writes its .htaccess every time instead of only when there is none, so a guard carried in by an archive can't survive. backupAssertUploadsGuards() also rewrites the deny-all guards in uploads/backups, canned_responses, expenses and tmp, so a restored uploads/ has the same guards as a fresh install. Both use the new BACKUP_UPLOADS_DENY_GUARD constant.
  • accountLedgerSql() reads its lines first and joins each to its account with STRAIGHT_JOIN. Left to choose, the optimizer started from the accounts and found their lines through an index it built on the lines' temporary table (derived_with_keys), which was two to three times slower.
  • Database 3.5.5 indexes expenses.expense_refund_of_id. expenseUnbilledCount() (the side menu, on every agent page), expenseUnbilled(), expenseOnInvoice() and expenseRow() sum each expense's refunds in a correlated subquery, which scanned every expense for each one. Like 3.5.4, it is safe to run again, and db.sql has the key.
  • api/v1/products/update.php takes product_id and any of the product_* fields mapped in api/v1/products/product_model.php, and saves through productSave(). At least one field is required and each must be a scalar. product_track_stock accepts 1, true, yes, on or tracked, and sending product_type without product_track_stock turns stock tracking off, as the product form does. enforce_api_rbac.php gates it on module_sales at write level, and it logs a Product Edit and an API Success.
  • cron/nightly_tasks.php sends an appNotify() "API Key Expiring" notification for each key whose api_key_expire is seven days away, once, on that day.
  • Sessions carry $_SESSION['user_session_stamp'], userSessionStamp() in functions/user.php: a SHA-256 digest of the user's user_password hash, never the hash itself. It is set at the three places a session is made - login.php (agent and client portal) and client/login_microsoft.php - and checked with hash_equals() on every request in includes/load_user_session.php and client/includes/check_login.php, next to the existing status and archived checks. Any write to users.user_password, from any code path, ends every session made before it, with no new column and no migration. A session without the stamp is signed out, which is the one-time sign-out on updating.
  • userSetPassword() calls userSessionRestamp(), which gives the current session a new id (session_regenerate_id(true)) and the new stamp when the password changed is its own user's - so only the other sessions end. The client portal's own password change does the same. portalViewAsAccess() returns agent_session_stamp, checked against the agent's session while viewing as a contact.
  • New requestIsMfaSetup($allowed) in functions/auth.php decides what a session that still has to set up MFA may request: the script really running (realpath($_SERVER['SCRIPT_FILENAME']) under the app root, never PHP_SELF, which a path added after the script name changes), and every GET and POST key inside one of the shapes allowed for that script. includes/load_user_session.php applies it when user_config_force_mfa is 1 and user_token is empty, allowing agent/user/mfa_enforcement.php with no parameters and agent/user/post.php with only csrf_token, enable_mfa and verify_code, or ?logout - everything else redirects to the set up page, modals and AJAX included. The client portal gate in client/includes/check_login.php now uses it too (profile.php?mfa, and post.php with portal_mfa_enable or ?logout), replacing its basename($_SERVER['PHP_SELF']) test; the separate gate in client/post.php, which let any request through that carried portal_mfa_enable or logout alongside other actions, is gone. portalViewAsAccess() refuses an agent still owing MFA set up. The enable_mfa handler sends userNotifyChange().
  • accountLedgerShows($all_accounts): across every account, the in and out cuts leave out txn_kind transfer_in and transfer_out. agent/transactions.php does the same for its Money in and Money out totals, except on the Transfers pill, and notes it in the table footer when the listed lines include transfers the totals leave out. The transactions export, which takes its cuts from the same function, follows.
  • Stripe fees are recorded by a new job, cron/payment_provider_fees.php (payment_provider_fees in includes/cron_jobs.php, every 15 minutes, interval-safe), instead of cron/nightly_tasks.php. The dispatcher seeds its row the first time it sees it. paymentProviderRecordFees() now holds GET_LOCK('itflow_payment_provider_fees', 0) around the work, which moved to paymentProviderRecordFeesLocked(), and returns 'busy' => true when another run has it - "has no fee yet" is read before the fee is written, so two runs at once would each record it. Record fees now says when a run is in progress.
  • Database 3.5.6 adds payment_providers.payment_provider_card_fee, payment_provider_card_fee_percent (decimal 5,2) and payment_provider_card_fee_fixed, and clients.client_card_fee_exempt, all defaulting to 0. Like 3.5.4 and 3.5.5 it is safe to run again, and db.sql has the columns.
  • The card fee lives in functions/billing_pay.php. billingCardFeeRates() reads it off the Stripe provider, billingCardFeeFor($client_id) adds the client's exemption, and billingCardFeeCents($cents, $rates) grosses up in whole numbers: ceil((cents + fixed) * 10000 / (10000 - percent * 100)) - cents, so the fee is the percent of everything charged plus the fixed part. js/portal.js works it the same way from data-pay-fee-percent (hundredths of a percent) and data-pay-fee-fixed (cents) on the Pay invoices form. billingCardFeeShares() splits one payment's fee over its invoices in proportion, by largest remainder.
  • billingPaySelection() now also returns fees ([invoice_id => cents]), fee, charge and fee_rates; the card limit is still checked on what the invoices come to, without the fee. billingStripeIntentParams() charges the fee and puts it in the PaymentIntent's metadata as itflow_fees, in the same id:cents,... form as itflow_invoices, which still carries what each invoice was owed. The guest Payment Element (guest/guest_ajax.php), autopay in cron/nightly_tasks.php and Charge Saved Card (agent/post/payment.php) add itflow_fees for their one invoice.
  • Booking: billingStripeBook() checks that itflow_invoices and itflow_fees add up to amount_received and refuses an itflow_fees that doesn't read. For each invoice it calls billingCardFeeAdd($invoice_id, $fee, $date) before the payment, which inserts a Card Fee line (BILLING_CARD_FEE_NAME, no tax, no product) and raises invoice_amount by the fee directly rather than through billingRecalc(), so the invoice's other lines aren't re-taxed and the payment pays it to the cent. The payment is what was owed plus the fee. A PaymentIntent without itflow_fees, from before, books as it did. The guest return, autopay and Charge Saved Card do the same, and now compare the amount received with the balance and fee in whole cents (autopay and Charge Saved Card compared whole dollars with intval()).
  • Refunding a Stripe payment (refund_payment_stripe) retrieves its PaymentIntent and calls billingCardFeeRemove($invoice_id, $fee), which deletes the latest Card Fee line of that amount and lowers invoice_amount to match, before the invoice's status is worked out again.
  • paymentProviderCheck() takes card_fee, card_fee_percent (0 to 15) and card_fee_fixed (0 to 100), needs at least one rate above 0 when it is on, and keeps the saved rates when it is off. paymentProviderChangeLines() and the Create audit line include it, through the new paymentProviderCardFeeText(). The client's switch is agent/modals/client/client_card_fee_field.php, shown on the Billing tab of Add and Edit Client while Stripe is set up, and saved through card_fee_field and card_fee_exempt in agent/post/client_model.php.
  • formatPhoneNumber() now formats with Google libphonenumber's rules (the data intl-tel-input's bundled utils use), from the new functions/phone_formats.php: for each calling code's main country, its national prefix, how that prefix is parsed off (nationalPrefixForParsing and its transform rule), the general number pattern and the number formats (pattern, format, last leading-digits pattern, national prefix formatting rule, international format). It's loaded the first time a number is formatted, and was made from libphonenumber v9.0.43's PhoneNumberMetadata.xml - its header says how, for a newer release. phoneParts() takes the national prefix off the stored digits as libphonenumber does (only when what's left still matches the general pattern, or the whole didn't), and a leading 00+code or code typed into the number; phoneFormatWith() picks the first format whose leading digits and pattern fit. With the country code it writes the international format, without it the national format with the prefix rule; +1 keeps (412) 555-0100 after the code. A number no format fits shows its digits, without the prefix after a country code and as stored without one. Checked against intl-tel-input v29.2.3's utils on all 1,450 example numbers in the metadata, stored with and without the national prefix: the international format (+1 apart, as above) and the dial number match for every one, and the national format for all but five that no format fits, which keep the 0 they were typed with.
  • New phoneDialNumber($number, $country_code) gives +, the code and the number without its national prefix for tel: links (just the digits without a code). contactPhoneLink() and vendorDialNumber() use it, as do the links that built +code and the stored digits themselves - the contact page and window, the client overview and list, the location page, the ticket and its quick look, the portal's home, contact and ticket pages, and the guest ticket page. The ticket quick look's link no longer runs the extension into the number; it dials it after a pause, as the contact page does.
  • Database 3.5.7 adds settings.config_update_details (MEDIUMTEXT, JSON), written by cron/update_check.php through the new updateCheckDetails() in functions/app.php: APP_VERSION from git show origin/<branch>:includes/app_version.php, the CHANGELOG.md sections newer than the installed APP_VERSION (and the installed version's own section when commits are pending but the version hasn't moved, as on develop) from updateChangelogSections() - at most twelve, about 200 KB - and git status --porcelain --untracked-files=no, the first 50 paths and the count. It's written with mysqli_real_escape_string() rather than escapeSql(), which strips anything tag-shaped. Like the other update columns, admin/update.php and the job check for it first.
  • admin/update.php is rewritten around one state (updating, checking, database, available, manual for a non-git install, unknown, current), with the existing reads kept and each new one guarded by settingsColumnExists(). updateChangelogHtml() renders the small part of Markdown CHANGELOG.md uses - ### headings, - lists with indented continuation lines, paragraphs, `code`, **bold** and http(s) links - escaping everything first, and folds a Developer heading into <details>. A job left at Running past 10 minutes (the check) or 30 (the update) isn't treated as still going. The updating and checking states reload through js/auto_reload.js. cron/app_update.php sets cron_job_run_now on update_check after a successful update, so the page doesn't go on offering what was just installed.
  • Database 3.5.8 adds managed_instances (one row per instance a manager manages: what it sets - _mode, _mode_message, _seat_limit, _white_label_key, _update_policy, _update_delay_days - and what the instance last reported, _report JSON), managed_instance_commands (update_now and check_updates, queued → sent → running → done, failed or cancelled) and settings.config_manager_keys (TEXT, the manager's EC P-256 key pair as JSON) and settings.config_managed (MEDIUMTEXT, the instance's manager, id, secret, the state it was last given and the answers to send). Both sides live in the new functions/managed.php.
  • Protocol: the instance does all the calling, with managedHttpJson() (curl, JSON POST, redirects followed with the POST kept). A connection code is ITFLOW-MANAGE: and base64url JSON {"u": manager address, "t": 64 hex}; api/managed/enroll.php (managerEnroll()) takes the token once, within 7 days, and answers the instance's uuid, a 64-hex secret (the manager keeps only its SHA-256), the manager's name and public key, and the state. api/managed/checkin.php (managerCheckin()) is called every minute by the new cron/manager_checkin.php (first in includes/cron_jobs.php) with managedReport() and the answers to last time's commands; the secret goes as Authorization: Bearer uuid:secret and again as auth in the body, as some servers drop the header before PHP. It answers the state and any queued commands (marked sent), 401 for an unknown instance, and 410 {"released": true} for a removed one, on which the instance disconnects itself. Report times are ISO 8601 with their offset (managedIsoTime()), so a manager in another time zone reads them right.
  • Updates: managedRunCommand('update_now') queues the same update as Maintenance > Update (config_update_queued_at and cron_job_run_now on app_update), and the command stays running until a report's update.last shows app_update finished after it was sent. managerCheckin() queues an automatic update_now when _update_policy is auto and the update has been seen _update_delay_days days (_update_version, _update_seen_at), or straight away when the changelog between the versions has a ### Security section - but not while one is pending or for six hours after one failed. The new cron/managed_instances.php (every 5 minutes) notifies once when an instance hasn't checked in for 10 minutes (_offline_notified_at, cleared by its next check-in) and fails commands sent an hour ago with no answer, or updates running for two hours.
  • Support sign-in: managedSupportLink() signs {i: uuid, n, e, x: expiry, j: nonce} with the manager's private key (managerSign(), OpenSSL, SHA-256); the new login_support.php checks it with the public key the instance was given (managedVerifySupportToken()): this instance, two minutes with a minute's grace for clocks, the nonce not used before. It signs in as managedSupportUser() - one admin user per manager, managed-support@<manager host>.invalid, a random password, user_config_force_mfa 0 - with $_SESSION['managed_support'], and logs and notifies who it was. The support user only gets in that way, for two hours, and is archived on disconnect. Its session can't open the credential vault, so the guard turns away a POST in one that would seal the vault's key for someone or needs a password - a non-empty password or new_password, ir_reset_user_password, add_api_key, getting_started_team - rather than seal it empty. encryptUserSpecificKey(), decryptCredentialEntry() and encryptCredentialEntry() read a missing session vault key as empty instead of warning.
  • managedRequestGuard($surface) enforces the mode: agent in includes/check_login.php, portal in client/includes/check_login.php, api in api/v1/validate_api_key.php, login in login.php and client/login_microsoft.php (suspended only), and public at the end of includes/load_global_settings.php, which nearly every guest and pre-sign-in page loads (guarded by function_exists(), as some portal pages load it before functions.php). Read-only refuses any POST and any request to a post.php or guest_post.php - but not signing out, MFA set-up, a key starting export or download, or managed_checkin_now - with a flash, a 403 JSON for ajax and the API, or includes/managed_blocked.php without a session; suspended shows that page (503), or 503 JSON for the API. managedBannerHtml() (from includes/inc_alert_feedback.php and the portal header) shows read-only and support sessions.
  • Cron: cronJobClaim($mysqli, $job, $paused) doesn't claim a paused job - a due one gets Stopped: Paused - ... and stays due, Run Now included. cron/cron.php pauses every job but MANAGED_KEEP_JOBS while managedMode() isn't active, asking again for each job so a check-in at the top of the cycle applies to the rest of it.
  • Locks: getAgentUserLimit() returns managedSeatLimit() when the manager sets one, and getActiveAgentUserCount() leaves out managedSupportUserId(). managedApplyState() writes the manager's white-label key to config_whitelabel_key and takes it out again when the manager stops setting it, and Settings > Modules doesn't save over it. admin/update.php hides Update ITFlow and queue_update refuses on a managed instance. managedRequired() reads $config_manager_required.
  • cron/ticket_email_parser.php no longer writes an App Logs error when cron or parsing is switched off - cronJobStop() already records why, and with parsing off it was an error a minute, which managedReport() counts. The audit log knows the Managed Instance and Management types and the Connect, Disconnect, Remove, Release, Code, Mode and Support actions.
  • Shared links have a new type, Export (IT documentation). agent/ajax.php share_create makes its PDF with clientExportOptions(), clientExportCheck() and clientExportPdf(), as the download does, and hands it to shareCreate() as export (pdf, name, secret). The link seals the PDF's name, size, SHA-256 and whether it's password-locked into item_payload, and shareExportStore() writes the PDF to uploads/shares/export-<id>.bin with AES-256-GCM under the link's content key - iv, tag, ciphertext - in a folder backupHardenStorageDir() keeps from the web, so the disk and the database alone open nothing. guest/guest_download_file.php serves File or Export, the PDF through shareExportRead(); guest/share_ajax.php describes it from shareExportSize() and the payload; js/guest_share.js downloads it as it does a file. shareEnd() and shareSweep() delete the file with the payload, and sharePrune() deletes any left without a link that can still open it (a client deleted with its links). shareNeedsProtection($type, $secret) takes whether this link holds passwords, shareCanSee() and shareCanMake() need module_client for it, and it's logged as a Client Export too. No database change - item_type is a varchar. clientExportSectionPicker() takes an id prefix and the sections to start ticked; Export Data's Share a link passes the ticked ones as sections[].
  • api/v1/tickets/create.php takes ticket_template_id, ticket_fill_ins (a JSON object of fill-in names and answers, checked with ticketFillInAnswers()) and ticket_notify. A missing or archived template, ticket_fill_ins that isn't an object and a required fill-in left out are a 400 with the reason in message. From a template, ticket_subject, ticket_details, ticket_priority, ticket_billable and ticket_assigned_to not sent come from it, ticket_category is its category, and ticketTemplateFinish($ticket_id, $template_id, true, true, $answers, ...) adds its tags, due time, tasks and fill-in answers and fills its placeholders. ticket_notify = 1 calls ticketMailCreated() and ticketMailColleaguesNew() when config_ticket_client_general_notifications is on, ticketMailNewTicketAddress($ticket_id, 'api'), and for an assigned ticket an app notification and ticketMailAssigned(). The answer adds ticket_reference, ticket_url (ticketEmailUrl()) and, from a template, ticket_tasks. ticketAgentEmailContent()'s new takes how api ("raised a ticket through the API"). A request without the three fields is raised as before, with the two new fields in its answer.
  • cannedResponseValues() adds the ticket's fill-in answers (ticketFillInsStored('ticket', $ticket_id), as ticketFillInText() words them), answered ones only and never over a placeholder of the same name, so cannedResponsePlaceholders() and cannedResponseFill() - the picker, the / search and Update & Reply - fill them in. includes/canned_response_form.php says so under the placeholder buttons.
  • Database 3.5.9 adds settings.config_invoice_paid_lead_to_client (tinyint(1), default 1), after config_invoice_paid_notification_email. It is read where it is used, never in load_global_settings.php, and the switch in Admin > Settings > Invoices and billing (Paid online) shows and saves only once the column is there (settingsColumnExists()).
  • New invoicePaidFollowUp($invoice_id) in functions/billing.php, run by triggerCustomAction() on invoice_pay before any custom handler - every road that books a payment fires that. It does nothing until invoice_status is Paid. Then, with the setting on, client_lead 1 becomes 0 (audit log Client Edit, appNotify()); and each ticket with ticket_invoice_id the invoice, ticket_billable = 0 and not resolved, closed or archived gets an Internal reply (ticket_reply_by = 0), On Hold (3) becomes Open (2), the history line "Invoice paid in full" (which also keeps it from happening twice), and for its assignee a notification and ticketMailAgents() with the new ticketAgentEmailContent() kind paid.
  • api/v1/recurring_invoices/create.php (Sales, Modify) goes through the new recurringInvoiceCreate($in, $by): the client's currency, validateRecurringFrequency(), each line through billingItemSave('recurring', ...) - a product_id gives the name, description and price not sent, tax_id "default" is taxDefaultFor() - and a 400 with the reason for a client, date, product or line that won't do. invoice_now runs recurringInvoiceGenerate($id, $by, true); one the recurring invoice didn't email by itself goes through the new billingInvoiceIssue($invoice_id, $by), which emails a Draft to the primary and billing contacts when billing mail is set up and marks it Sent either way. The answer adds recurring_invoice_reference, amount, next_date and invoice (invoice_id, reference, amount, currency, due, status, url - billingGuestUrl() - and emailed_to). recurring_invoices/read.php lists them, or gets one with its items.
  • api/v1/portal_invites/create.php (Clients, Modify) calls portalInviteSend() with no agent as the sender, so the email reads as from the company; the answer's invite is invited, resent or skipped (with why in message). enforce_api_rbac.php maps recurring_invoices to module_sales and portal_invites to module_client.
  • api/v1/tickets/create.php takes ticket_status (an active status other than Resolved and Closed, else a 400), ticket_invoice_id (one of the client's invoices, else a 400) and ticket_notify "agents" (the new-ticket address and the assignee, not the contact). agent/clients.php reads contract_term_months with each contract, as contractState() needs.

[26.11.2]

Upgrading

Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's one database change (3.5.3 to 3.5.4) in the same pass. Nothing else is required.

Bug Fixes

  • Transactions: the list, its Transfers pill, Reconcile and the transactions export got slower as the books grew, taking several seconds with a few years of history, and saving a new transfer waited on the same list. They load quickly again. On a test with eight years of books, the Transfers pill went from over six seconds to about a third of a second.

Developer Updates

  • Database 3.5.4 indexes transfers.transfer_expense_id. accountLedgerSql() joins every expense to the transfer it may belong to, and with no index the join compared every expense with every transfer (a block nested-loop join on MariaDB). transfer_revenue_id has been indexed since 2.7.5. The migration checks information_schema.STATISTICS first, so it is safe to run again, and db.sql has the key.
  • accountLedgerSql($account_id = 0, $with_balance = true): the running balance, a SUM() OVER the account's whole history, is only worked out with $with_balance. Without it, txn_balance is NULL.
    • agent/transactions.php passes it for one account sorted by date, the only view that shows the balance.
    • The transactions export passes it for one account. Reconcile (reconcileLines() and the uncleared count in agent/reconcile.php) never does.
  • agent/transactions.php works out the Transfers summary cards in the same query as the pill counts and totals, so the Transfers pill reads the ledger twice instead of three times.

[26.11.1]

  • Update README.
  • Fix long decimal data for trips for spome instances.

[26.11]

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.5.3) in the same pass. Nothing else is required.

This is a big release, with nearly seventy 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:

  • rack items still linked to a deleted asset (the item stays in the rack, the dead link is dropped)
  • assets, licenses, tickets, domains, services and certificates still pointing at a deleted vendor or domain, and certificates linked to another client's domain
  • the tasks, approvals and history of deleted tickets, the projects of deleted clients, and tickets still pointing at a deleted project
  • project template entries whose ticket template is gone, and the same service link saved twice

It also gives every existing network a type (LAN, WAN, Guest and so on) guessed from its name or description, which you can change afterwards, and fills in the issue date, names covered and fingerprint of every certificate already on file. Invoices made from a quote before the update are linked to that quote, so each one shows where it came from. Products that ever had stock added keep tracking their stock, and the rest are set to ordered as needed (see Breaking Changes, Products). Their past stock changes are sorted into received, sold and returned from their notes, and linked to the invoice they name. No product becomes a client asset when it is sold until you give it an asset type (see New Features, Products). Each product's price history starts with the update, so lines on recurring invoices are only flagged as behind once their product's price changes. Ticket replies from the 90 days the mail queue keeps are matched to the emails that carried them, so the conversation shows who they were emailed to.

Payment reminders keep the schedule they had: 1 and 30 days late, then every 30 days until the invoice is paid. If reminders were on, they stay on, and every client is included. The schedule can now be changed in Admin > Settings > Invoice (see Breaking Changes, Billing emails). Monthly statements start switched off.

Expenses already entered take who entered them from the audit log, where it has a record of it. Expense receipts are no longer opened straight from the uploads folder - see Breaking Changes, Expenses.

Recurring expenses keep their next date and from then on run on that day of the month - monthly ones stay monthly and yearly ones yearly. Look over their next dates once after the update (see Breaking Changes, Expenses). The expenses they recorded before are linked to them, matched on the vendor, category, client and description. No expense or recurring expense becomes billable to a client by itself - they stay your cost until you mark them (see New Features, Expenses).

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).

Ticket statuses keep their names, colours, order and SLA settings, and every one of them still sends a ticket back to Open when the client replies - until you set one of your own to leave it where it is (see New Features, Ticket Statuses). The five built-in statuses are given a line on what each is for, which you can change.

Payment methods are all offered for money in and out, as before, until you say one is only for one of them (see New Features, Payment Methods). Methods that payments, income or expenses were recorded with but that aren't in Admin > Payment Methods - ACH from a bank import, say - are listed there to add with one click.

Contracts and contract templates keep their names, dates, rates, support hours and terms. Their response and resolution hours become an SLA from Admin > SLAs for each ticket priority - one you already have with the same hours, or a new one named for the template or contract and the priority, such as "Gold - High" - and Urgent takes High's, as there were no urgent hours. A renewal frequency becomes a term: Annually is 1 year, 2 Year is 2 years and so on, each renewing itself, and Manual is 1 year that is only renewed by hand. Net terms become a number of days, so "Net 30" is 30, and ones without a number are left empty, which means the client's own apply. Fully Managed and Partially Managed become Fully managed and Co-managed, Break/Fix stays, and any other type is Other. A contract's status becomes Active, Draft, Ended or Cancelled: Pending is a draft, Expired is ended, and anything that isn't one of those is active. Each has 30 days' notice, and no owner, contact or template until you give it one.

The first nightly run after the update deals with active contracts whose end date has passed (see New Features, Contracts). One that renews itself is renewed term by term until it is current, and one that doesn't is marked ended. Agents are notified in ITFlow about the ones due soon with something to do - ending, or renewing with notice to give during the term - and the owner is emailed once a contract has one. An archived client's contracts are left as they are. Look over Contracts once after the update if end dates weren't kept up to date.

No contract is out for signature after the update - signing starts when you send one (see New Features, Contracts). A signature already stored on a contract, put there by hand or by a script, is kept as a copy recorded before signing came to ITFlow, under the contract's Signing.

No contract has prepaid hours after the update - none included each month and none bought - so nothing is drawn from them, and Unbilled Time is as it was, until you add some (see New Features, Contracts).

A contract in force now sets its client's SLAs, hourly rates and net terms, where it has them (see New Features, Contracts). The update carries contracts' response hours, rates and net terms over, so for a client with an active contract, new tickets can get a different SLA, time can bill at the contract's rate - and its after-hours rate, for tickets opened outside business hours - and new invoices can be due sooner or later than before. Tickets already open keep their SLA. A contract rate of 0.00 - what an empty rate was saved as before - is taken as no rate, so the client's own rate applies, now on its Standard terms contract (below). Look over Contracts once after the update, and clear a rate or net terms you don't want applied. No contract is linked to a recurring invoice until you link one.

A client no longer has an hourly rate, net terms or SLAs of its own. They come from its contract in force, and where it has none, or leaves one empty, from the defaults - the hourly rate, after-hours rate and net terms in Admin > Settings > Defaults and the SLAs in Admin > SLAs (see Breaking Changes, Clients' rates, terms and SLAs). Nothing is billed differently after the update: each client whose own rate, net terms or SLAs differ from the defaults is given a contract called Standard terms that sets just those. It is active, on a 12-month term that renews itself with no notice, and starts the day the client was added - or the day before a contract the client already had, so that one still comes first where it sets a rate, terms or an SLA. An archived client's is archived with it, and a client whose terms were all the defaults gets none. A contract that left its rate, net terms or an SLA to the client's own says the same in its document, now from Standard terms, so nothing signed goes out of date. Two kinds of client SLA can't be carried, as a contract sets an SLA or leaves the default: one set to no SLA, and one pointing at an SLA that has since been archived. That client's new tickets of that priority get the default SLA. The default after-hours rate starts empty, so after-hours time bills at the hourly rate, as before, until you set one.

Nobody can sign up for the client portal after the update - it is off until you turn it on in Admin > Settings > Client portal and let a client's people do it on its Contacts > Invites (see New Features, Client portal invites and signing up). Inviting people works straight away. Contacts given portal access before are as they were.

Agents already set up are as they were after the update, and nobody is invited until you invite them. From now on New User invites by default: they are emailed a link to choose their own password, and the email with a password in it is gone (see New Features, Agent invites, and Breaking Changes, Users).

No client is being onboarded after the update, so no client's overview shows Finish onboarding. Onboard a client, under New Client's arrow on Clients, takes a new client on a step at a time, and a lead or a client you already have can be onboarded from its menu (see New Features, Client onboarding).

No internal note mentions anyone after the update. From then on, typing @ in an internal note mentions someone on your team, and they are told in ITFlow and by email (see New Features, Mentions in internal notes).

Get started, the walk through what is left to set up after installing, isn't shown on an install that is updated - nothing is added to the dashboard and nobody is taken to it on signing in. It is in Admin > Get started for anyone who wants it (see New Features, Get started).

Every account is given a kind - checking, savings, credit card, cash or payment processor - guessed from its name: Visa, Amex or card is a credit card, savings or reserve is savings, cash or petty cash is cash, and Stripe, PayPal, Square or merchant is a payment processor. An account given a liability type back when accounts had types (2023) is a credit card, so what is on it reads as owed. Anything else is checking. Change any that came out wrong with Edit on the Accounts page. An account's balance now leaves out archived payments, income and expenses everywhere, so the Accounts page can show less or more than before (see Bug Fixes). No account is reconciled after the update. The first statement you reconcile on each can start from the bank's balance on the day it starts, bringing everything before it forward as reconciled, or from the account's opening balance (see New Features, Accounts and transactions). Transfers already made out of a cash account count as having taken everything in it up to then to the bank, so the first deposit you make after the update only offers the checks and cash taken in since the last one.

Taxes keep their names and rates, now kept to four decimal places. No client has a rate of its own or is tax exempt, and there is no default rate, so new lines start untaxed as before until you pick one in Admin > Taxes. Billed time, trips and expenses stay untaxed until you switch them on there. Discounts now come off before tax on invoices and quotes that are still open, the next time they are edited (see Breaking Changes, Taxes). No expense has tax recorded on it until you add it, so Tax Collected shows no tax paid on purchases for the months before.

Tags keep their names, colors, icons and records. A color saved as a word, such as dark or primary, becomes the color it stands for, a short one such as #abc is written out in full, and a tag with no color, or one that isn't a color, turns grey, so its pills can be read (see Bug Fixes). An icon saved with fas fa- in front keeps just its name. A tag archived in an older version now counts as archived (see Breaking Changes, Tags). Licenses, services, networks, documents and tickets can now be tagged too, and start with no tags - the Tags pack in Maintenance > Starter Content adds a few for each.

Categories keep their names, colors, icons, order and records. A color saved as a word, such as lightblue or gray, becomes the same color written as #rrggbb, and a short one such as #abc is written out in full, so nothing looks different, but the ticket lists and the reports, which only read colors written that way, now show it (see Bug Fixes). A color that isn't one is taken off, and a category with no color stays without one. Interface types, asset statuses, software types, rack types and note types keep the order they are offered in.

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).

Ticket templates keep their subject, details and tasks, and set nothing else on a ticket until you give them a category, priority, agent, due time or tags. Words in square brackets already in a template, like [Employee Name], are asked for as plain, optional text when a ticket is raised from it, until you set them up on the template's page (see New Features, Ticket templates). Tickets raised before the update don't know which template they came from, so a template's use is counted from the update on. The starter ticket templates you added from Maintenance > Starter Content stay as they are; Update on that page brings them up to the new versions, which ask what they need and come with request forms.

Canned responses keep their names, what they say and their ticket category. One whose category had been deleted is offered on every ticket, as it already was. How often each is used is counted from the update. None sets up anything on the reply until you give it a reply type, status or time worked on its Sets up tab. Every canned response already there is shared - offered to everyone, as before - and none has files.

Document templates keep their names, summaries, what they say and their images. Words in square brackets already in them are now filled in when a document is made: [Client], [Site], [Contact], [Date] and the others listed under New Features (Document templates) from the client, and the rest are asked for as plain, optional text until you set them up on the template's page - one left blank stays as it is, as before. Documents made from a template before the update don't know which one, so a template's use is counted from the update - link one to its template from the document's page to count it. No template is required of anyone, and none asks for reviews, until you set it up on its page. The starter document templates you added from Maintenance > Starter Content stay as they are; Update on that page brings them up to the new versions, which fill in from the client, ask for what they need and are reviewed on a schedule.

License templates keep everything they hold. A license called exactly what one license template is called - as the New License form named it when started from one - counts as made from that template, so a template's use starts out counted; where two templates share a name, neither is given its licenses. Other licenses don't know a template, so they count from the update. No template sets seats, a renewal term, a vendor or a product until you give it one, and none is required of anyone until you set it up on its page. Maintenance > Starter Content has a new License Templates pack to add.

Vendor templates keep everything they hold, and the vendors made from them stay linked to them. Who made each and last changed it is taken from the audit log, where it has a record of it. A vendor made from one that has since been changed by hand shows as out of step with it, and nothing on it changes until you bring it in line. Vendors made by hand stay unlinked - a template's page lists the ones that look like it, to link. No template is required of anyone until you set it up on its page. Vendors and their templates have a status page and a support portal from this version, empty until you fill them in, and templates keep the account manager's line and email as vendors do. The starter vendor templates you added from Maintenance > Starter Content stay as they are; Update on that page gives them their support site, status page, portal and what support asks for.

The app logs are kept for as many days as the audit log, as before, and each can now be set on its own in Admin > Settings > Logs, which is where the log retention went from Settings > Security. A retention of 0 now keeps a log forever (see Breaking Changes, Logs). Nothing is sent to a syslog server or SIEM until you set one up there.

AI providers keep their address, key and models. Each is given a kind from where it points - OpenAI, Anthropic, Azure OpenAI, Google Gemini, Mistral, Groq, OpenRouter, Ollama or LM Studio - or Other (OpenAI-compatible), and passwords and keys are taken out of what is sent to each from now on, which can be turned off for a provider in its Edit window. Every role keeps using AI, through a new Use AI permission in Admin > Roles that can now be taken away from a role. A model's prompt is now its instructions, sent after what each feature asks for (see Breaking Changes, AI). AI Usage starts empty. AI triage of new tickets starts off - turn it on in Admin > Settings > Tickets.

Assets keep their type and everything they hold. NAS, UPS, PDU, Modem/ONT and NVR/DVR are new types to choose from, and no asset is changed to one by the update - a NAS or UPS entered before as a server or Other stays that until you change its type. No asset has details (ports, RAM, battery dates and the like) until you add them, and the update adds no interfaces (see New Features, Asset types and details).

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

  • Client restrictions: if you limit agents to certain clients, that limit is now applied in places it used to slip through. This covers editing, archiving or deleting the client itself, the client list's bulk actions, credits, expenses, trips, recurring expenses, revenues, task approvals, the dashboard lists and the per-client reports. A restricted agent will lose access there. Company-wide report totals are unchanged.

  • Dashboard: the Financial and Technical switches at the top of the dashboard are gone. Each part of the new dashboard shows to anyone whose role can open that part of ITFlow. If you used the switches to keep figures off someone's screen, take that module out of their role instead.

  • Reports: each report now checks access on the page itself, not only in the menu, and is hidden when its part of ITFlow (accounting, ticketing or IT documentation) is turned off. Every report still needs the Reporting permission, plus:

    • Sales for Receivables Aging and Recurring Revenue, which used to need Financial, as they show invoices. Unbilled Work also needs Sales now, in the menu as well as on the page.
    • Financial for Mileage, which used to open with Reporting alone.
  • Reports have new names that say what they show: Outstanding Balances is now Receivables Aging, Recurring Income by Client is Recurring Revenue, Expense by Vendor is Spend by Vendor, Tax Summary is Tax Collected, Unbilled Tickets is Unbilled Work, Time by Technician is Technician Performance, Tickets is Ticket Overview and SLA Summary is SLA Performance. The addresses are the same, so bookmarks still work, though the filters saved in an old bookmark may be ignored and the report opens on its default period.

  • Client export: the client PDF no longer prints passwords and 2FA secrets unless you ask for them, and then it needs a password to open. The old PDF always printed them in plain text, archived credentials included.

  • Shared links:

    • Links you sent before the update keep working until they expire.
    • New links keep their key after a # at the end of the address, so the whole link has to be copied. A system that cuts off everything after the # breaks the link.
    • Opening a link now takes a click on Reveal. This is so mail filters and chat apps that open every link cannot use up a one-time link.
    • Ended links stay listed for 90 days, with who opened them. They used to be deleted overnight.
    • Only the agent who made a link is told when it is opened. It used to notify every agent.
  • Billing:

    • Line amounts are rounded to the cent when a line is saved. Tax used to be stored unrounded, so a total could be a cent off what was printed. Lines already on file keep their amounts until they are edited.
    • Taking a service line off an invoice no longer adds stock to the service. Only products kept in stock move stock, as before when they were added.
    • A quote that has run out can no longer be accepted from its link. Extend its date, or send a new one.
    • Batch Payment only pays invoices that are still owed (sent, viewed or part paid). It used to include non-billable invoices too, and marked them paid.
    • Invoice and quote PDFs have a new layout, and print on Letter paper in the United States and Canada and A4 elsewhere. They were always A4.
    • The recurring invoices list no longer has a date filter. It filtered by the date each one was created.
    • Links to the invoices list with ?status=Draft, Unpaid or Overdue still work, and Paid and Cancelled are new.
  • Clients' rates, terms and SLAs:

    • A client's Edit window no longer has an hourly rate, net terms or SLAs. Its Billing tab says what the client is billed on and where that comes from - its contract, or the defaults - with a link to its contracts. New Client doesn't ask for them either, and a new client is billed on the defaults until it has a contract.
    • The Clients list's Bulk menu no longer has Set Hourly Rate and Set Net Terms. Change the defaults, or the client's contract.
    • The client import still takes the Hourly Rate and Payment Terms columns: a client whose rate or terms differ from the defaults is given a Standard terms contract for them.
    • API: clients/create still takes client_rate and client_net_terms, and makes a Standard terms contract for a client whose rate or terms differ from the defaults, returning its contract_id. clients/update no longer changes them, and says so in not_kept. clients/read no longer returns them.
    • The database columns clients.client_rate and clients.client_net_terms are gone, and sla_assignments keeps only the defaults (sla_assignment_client_id 0). Anything of your own that reads them, such as a custom report, needs changing.
  • Billing emails:

    • Payment reminders moved from Admin > Settings > Notifications to Admin > Settings > Invoice, under Reminders and Statements. Saving the Notifications page no longer changes them.
    • Reminders and payment receipts go to the client's primary and billing contacts, once per address. They used to go to the primary contact only.
    • An invoice on a recurring invoice with autopay is not reminded before or on its due date, as the card is charged that day. It is reminded once it is late.
    • Invoice, quote, reminder, receipt and statement emails have a new look, with the invoice, quote or statement attached as a PDF. Attaching can be turned off in Invoice Settings, or for one email in its Send window. The subjects changed too, such as "Invoice INV-12 from PittPC", "Overdue: invoice INV-12 is 30 days past due" and "Payment received for invoice INV-12". If you filter mail on the old subjects ("Overdue Invoice INV-12", "Quote [scope]", "Account Statement - Client"), update the filters.
    • The late fee is now a line on the invoice like any other, and the total is worked out from the lines. It still comes every 30 days late, while reminders are on.
  • Recurring invoices:

    • The nightly run also bills a run it missed by up to 7 days, for nights the cron didn't run. It used to bill only on the day itself. A recurring invoice further behind than that is still left for you to look at.
    • Invoice Now bills the next run early, and the schedule moves one period on from that run, so the invoice takes its place. It used to restart the schedule from today: pressing it on the 16th for a run due on the 1st moved every later invoice to the 16th.
    • Every invoice made from a recurring invoice takes its taxes at today's rates, and the recurring invoice's lines are updated to match. The nightly run used to copy the amounts saved when each line was added, while Invoice Now used today's rates.
    • A schedule on the 29th, 30th or 31st keeps that day. After a shorter month it goes back to the 31st, where it used to stay on the 28th from then on.
    • The email goes to the client's primary and billing contacts that are not archived, once per address. When outgoing mail is not set up, nothing is queued and the invoice stays a draft. It used to be marked Sent anyway.
  • Quotes:

    • A quote's amount is now what it bills up front. Lines billed each month, quarter or year are counted apart, as so much a month, and the quotes list, search and the client portal show that next to it. Quotes from before the update have only one-time lines, so their amounts don't change.
    • The quote's discount comes off its one-time lines. On a quote with only recurring lines it comes off the first recurring part (each month, then each quarter, then each year), and stays on the recurring invoice made from it.
    • Make Invoice only works on an accepted quote, and only once. The quote is then Invoiced. It used to invoice a quote in any status, as many times as it was pressed. When the client says yes by phone or email, mark the quote Accepted first.
    • The quote email says "Cost: $1,540.00 up front and $2,034.76 a month" when the quote has recurring lines. A quote with only one-time lines still says "Total Cost".
    • Quotes Pipeline values a quote at what it bills up front plus a year of its recurring lines, so a quote with monthly lines counts for more than its up-front amount in quoted, won, lost and open. The open quotes table shows that first-year value with the breakdown under it, and its CSV has Up front, Monthly and First year columns.
  • Products:

    • Only a product with Track stock on moves stock when it is invoiced, and it still can't be invoiced past what is on the shelf. After the update, a product that ever had stock added keeps tracking it. One that never did is ordered as needed and can now be invoiced - it used to be refused as not enough in stock. Turn on Track stock in Edit for anything else you count.
    • The Products page shows everything, with tabs for Services, Products, Recurring and Stocked. It used to open on Services. Old links to products.php?type=service and ?type=product still open those tabs.
    • Clicking a product opens its new page instead of the Edit window.
    • Import finds columns by their heading, so a file can have any of the fields in any order. The old three column file (Product Name, Description, Price) still works. A row whose code is already in the catalog is skipped unless Update products with the same code is on.
    • Add Stock is replaced by one Stock window with Receive, Count and Write off, on the product's page and in the list's menu. Count records the difference from what you counted, and Write off asks why.
    • Cancelling or deleting an invoice puts its tracked products back on the shelf. Deleting one used to leave the stock gone for good.
    • Copying an invoice, or making an invoice from a quote, is refused when a tracked product on it is short, the same as adding the line by hand. Add the stock first.
    • Invoices made from recurring invoices are now linked to their products, so they count as sold on the product's page and in the reports. They don't move stock.
    • Stock can be counted in fractions (up to two decimal places), such as cable by the metre.
  • Expenses:

    • Receipts open through ITFlow, for agents with Financial access to the expense, instead of straight from uploads/expenses/. An old link to a receipt file stops working. On Apache the folder is closed by the .htaccess that comes with the update. On nginx, deny /uploads/expenses/ the way you deny /uploads/tmp/ and /uploads/backups/.
    • A new expense takes the currency of the account it is paid from. It used to take the company's currency whatever the account.
    • The description is optional now.
    • Bulk Set Category, Set Account and Set Client are one window each from the same Bulk menu, with Set Payment Method added. Bulk Delete is one button that asks once.
    • Recurring expenses run on the day of the month of their next date, the way recurring invoices do. The nightly run used to move a monthly one a month on from the day it happened to run, and a new one started in the month picked in the form even when it was monthly. Check the next dates on the Recurring Expenses page after the update.
    • A recurring expense the nightly run missed by up to 7 nights is recorded the next night, dated the day it was due. One further behind waits for you to Record Now or Move On. The notification every night for each recurring expense with a date in the past is replaced by one notification, on the day it falls too far behind.
    • Deleting a recurring expense needs full Financial access, the same as deleting an expense. It used to need write access.
    • Unbilled Time also lists expenses to bill for agents with Financial access, and its count in the menu adds them. For those agents it shows in the menu even with ticketing turned off.
    • Transactions has a Reimbursement type for money paid back to an agent. A Transactions export filtered to Expense leaves those out.
  • Trips:

    • 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 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.
    • 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.
  • Accounts, transactions and transfers:

    • The Transfers page is now the Transfers pill of Transactions. The Transfers link stays in the menu and opens it for every account. An old link to transfers.php lands there with its account and dates. The old page could filter by the account a transfer came from and the one it went to together. Now pick one account, and its Transfers pill shows the transfers into and out of it.
    • The New Transfer window no longer has the list of recent payments to copy into the notes.
    • A transfer takes the currency of the account it leaves. It used to take the company's currency whatever the accounts.
    • An account can only be archived once it holds nothing and no payment provider pays into it, and only an account that was never used can be deleted. The Accounts page used to hide Archive otherwise, but the link itself archived any account, and Delete removed an account with its history still pointing at it. Archive one you no longer use instead.
    • The Transactions export's Type column says Invoice payment, Other income, Transfer in, Expense, Transfer out or Reimbursement (it was Payment, Revenue, Transfer In, Expense, Transfer Out or Reimbursement). Money in and out are now separate In and Out columns, with Account, Who or What and Client added. The signed Amount column is still there, but off unless ticked.
    • Links to transactions.php?type=Payment and the other old type names still work.
    • Once a payment, other income, expense, transfer or reimbursement has cleared on a reconciled statement, its amount, date and account stay as they are and it can't be deleted, until that reconciliation is undone. Its category, client, description, method and reference can still change. This also stops:
      • deleting the invoice a cleared payment is on (cancel the invoice instead), or refunding a cleared Stripe payment from ITFlow, which deleted the payment
      • deleting a client with cleared payments or other income, from ITFlow or through the API (keep the client archived instead)
      • Bulk Set Account and Bulk Delete on the Income and Expenses lists, which leave cleared ones as they are and say how many
      • undoing a reimbursement whose money out has cleared
    • An account brought forward as reconciled (see New Features, Accounts and transactions) has everything before the day it was brought forward to locked the same way, until that is undone.
    • An account's opening balance can't be changed by hand once a statement on it is reconciled, as the first statement starts from it. On an account brought forward from the bank's balance, Correct the opening balance can still move it once.
  • Taxes:

    • Discounts come off before tax. On an invoice, quote or recurring invoice with a discount, tax is worked out on what the client pays after it, the discount shared across the lines by their amounts. It used to be worked out on the full price. Paid, cancelled and non-billable invoices, and quotes that are answered or invoiced, never change. Others follow the next time a line or the discount is edited, and a recurring invoice when it next bills. To keep taxing the full price, switch off Take discounts off before tax in Admin > Taxes.
    • Changing the percent of a rate that is on an invoice or quote that has gone out now makes a new rate with the same name, and archives the old one. What was billed keeps the old rate, on the page, the PDF and the Tax Collected report, where the two show as separate rows. Products, clients, the default and recurring invoices move to the new rate, and draft invoices and quotes do too unless you untick it. It used to change the rate in place, so an invoice already sent showed the new percent next to tax worked out at the old one. A rate's name, description and registration number still change in place.
    • A rate can only be deleted when nothing has ever used it - archive it otherwise. Archiving asks where to move the products, clients, recurring invoices, drafts and default that use it. Left where they are, recurring invoices and drafts keep charging it, and new lines for its products and clients take the next rate along.
    • Tax Rates in the Admin menu is now Taxes.
    • A line on a rate made of parts is taxed part by part, each to the cent, so its tax can be a cent off the whole rate times the line.
    • Tax Collected now shows tax by part, so a rate made of a state's and a county's part shows two rows, and a part named the same on two rates shows one. Its sales are net of discounts, where they used to be the lines before any discount, and archived payments no longer count. It still counts as payments come in unless you switch it to Invoiced.
  • Tags:

    • Archiving a tag now does something: it stays on the records that have it, but isn't offered in record forms, the tag picker or the list and export filters, unless a record or filter already has it. A tag archived in an older version is archived now too - restore it from the Archived tab in Admin > Tags.
    • The + beside the Tags field in record forms is gone. Admins type the new tag's name into the field instead, and it is made when the record is saved. Other agents pick from the tags there are, as before - the + never worked for them.
    • A tag's name is now unique within its kind, in any case: a second client tag called VIP can't be made, though an asset tag can be. Tags already named alike keep their names. Typing a name that exists into a record form picks that tag.
    • A tag that a recurring invoice line counts users or devices by can't be deleted. Archive it, or merge it into another tag, which moves the line along with it.
    • Pills now have dark text on light colors such as yellow and light blue, where every pill had white text.
    • The search box at the top, and the license, service, network, ticket and Files searches, also match tag names, so a search can bring back records whose own details don't have the words in them. In the search box and on its full results page, the tag that matched shows next to each.
  • Categories:

    • Renaming a referral, asset status, interface type, software type, rack type, or contact or asset note type now renames it on every client, asset, interface, license, license template, rack or note that has it. Records keep these by name, so renaming one used to leave its records on the old name, which could no longer be picked.
    • A category can only be deleted while nothing uses it - merge it into another, or archive it. Deleting one in use used to leave expenses, invoices and tickets with no category, and clients and assets with a name that could no longer be picked. Delete is now on current categories too, not only archived ones.
    • A category's name is now unique within its kind, in any case: a second expense category called Travel can't be made, though an income category can be. Categories already named alike keep their names.
    • Interface types and rack types are held to 50 characters, which is all an interface or a rack keeps.
    • The + beside a category picker shows for admins only, on every form. It opened Admin's New Category window, which refused anyone else. Agents typing a referral that isn't one yet into a client's form still make it, as before.
  • 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:

    • ticket assignee, priority and billable
    • Assign Tags on assets, clients, contacts, credentials and locations
    • Link Closed Ticket on projects

    Click the value and pick a new one, or use the + at the end of a record's tags. Each change saves straight away.

  • Tickets: a closed ticket can no longer be changed, either from the ticket's windows or through the API. Merging is only offered between tickets of the same client.

  • Tickets: a ticket that closes by itself some time after it was resolved now sends the client the closed email, as closing it yourself does, when client notifications are on (Admin > Ticket). It used to close without a word.

  • Tickets: an agent is now emailed when a ticket is assigned to them from New Ticket or Edit, as they already were from the assignee pick and Bulk Action. It used to be an app notice only. Assigning a ticket to yourself sends nothing.

  • Client portal: the email inviting a contact to the portal no longer has their password in it. Tick Email them an invite to the portal - on New Contact as well as Edit - and they get a link to set their own password, good for 7 days, or with a Microsoft login a link to sign in. It used to email the password the agent typed, in plain text. A password you type is still set, for reading out over the phone; the link lets them choose another.

  • Email on the contacts and clients lists comes from one of your mailboxes in Admin > Mail, picked by name, and goes out in the email look. Archived contacts and contacts without an email address are left out, and each address gets it once. With no roles ticked on the clients list it goes to every contact that isn't archived. Nothing is sent while outgoing mail isn't set up.

  • Ticket templates: [Client], [Contact], [Asset], [Site], [Agent], [Project], [Ticket], [Date], [Month], [Quarter] and [Year] in a template's subject, details and tasks are filled in when a ticket is raised from it - and in a recurring ticket's each time it raises one, template or not. A recurring ticket with [Month] in its subject used to raise it with [Month] in it. Anything else in square brackets is now asked for when a ticket is raised from the template - optional unless the template says otherwise - and stays as it is when it isn't answered.

  • Ticket templates: a project template's tickets are set up as their ticket templates say. They were always Medium, uncategorised and billed as the default.

  • New Tickets on the assets list: with [Asset] in the subject, the asset's name isn't put in front of it as well. New Tickets on the assets and clients lists now email the agent they go to, once for all of them, as New Ticket does.

  • Emails to agents keep their subjects, so mail rules still match. The ones that changed: a client's reply from the portal is "ITFlow - Ticket updated - [TCK-12] Subject", with the dash the emailed reply always had; one recurring ticket assigned from Bulk Action is "ITFlow - Recurring ticket Subject assigned to you", as from the assignee pick; and a ticket raised on a schedule reaches the new-ticket address as "ITFlow - New Recurring Ticket - Client: Subject".

  • Client portal: primary and technical contacts' Tickets page now opens on everyone's tickets at their company, with Mine to see only their own. The separate All tickets page is gone, and links to it land on Tickets. A ticket shows the client where it stands - Received, In progress, Waiting on you, On hold, Resolved or Closed - rather than your ticket statuses.

  • Client portal: invoices and quotes now open in the portal, where they used to open their guest link in a new tab. The links in your emails work as before. A quote answered in the portal records the contact's name and any note they left. Pay invoices pays the invoices ticked as one card payment, and the Pay drop-down on each invoice is gone (see Client portal).

  • Client portal: only the primary contact can add or change contacts now, and give or turn off their portal access. Technical contacts still see the contacts, but can no longer add or edit them. A primary contact can turn access back on for someone you turned off, and it is logged and notified when they do.

  • Client portal: turning off a contact's portal access, in ITFlow or in the portal, now signs them out at once. They used to stay signed in until their session ran out.

  • Client portal: Certificates is now a tab of Domains & certificates, and links to it land there. A contact who opens a page they can't use is taken home with a message, where they used to be signed out.

  • Client portal: a link to set a password now runs out - an hour for Forgot password, a week for an invite - and works once. Links sent before you update keep working for a day. A link used to work until the nightly run, whenever it was sent.

  • Client portal: a contact's password has to be at least 8 characters when they change it, and changing it - or turning two-step sign-in on or off - asks for their current password and emails them.

  • Sign in: Remember Me on the code step now says how long the browser is remembered (Admin > Settings > Security).

  • Stripe: Refund on a Stripe payment now refunds that payment's amount, where it used to refund the whole card charge. A card payment from the client portal over several invoices is booked as a payment on each, so refund each invoice's part from that invoice. Stripe's fee for such a payment is recorded once, naming every invoice it paid.

  • Invoicing a ticket:

    • The invoice line now reads Ticket TCK-123 - Subject - 10/14/2026. It used to be a block of text with the agent, dates, contact, asset and time spent. You can still edit it before adding the line.
    • Billing a ticket goes in the ticket's history ("Billed on invoice INV-45"). It used to add an internal note to the conversation.
    • An invoice made from a ticket is due on the client's terms, the same as New Invoice. A client set to On Receipt is due on receipt, where it used to get the default terms.
    • 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.

  • Users: passwords must be at least 8 characters, and an email address can only belong to one account, agent or client portal. Existing accounts keep working. The rules apply when an account is added or changed.

  • Users: New User no longer emails a password. It invites by default - they are emailed a link to join and choose their own password, so nobody else ever knows it (see New Features, Agent invites). Set a password now still sets one, for you to give them, and nothing is emailed: the Email them their sign in details switch and the email with the password in it are gone. Get started > Your team invites them too when email is set up, and still shows the password it makes once when it isn't. A script that posts add_user with a password and no sign_in still sets that password; send_email is ignored.

  • Agent user limit: an invite waiting counts toward it until it is accepted, cancelled or runs out, so there is a seat for them when they accept. With every seat taken, cancel an invite to free one.

  • Users: Incident Response now resets agent passwords only, where it used to reset client portal logins too. It also forgets every agent's remembered browsers, so MFA is asked for again. The new passwords are shown once on a plain page, ready to read out or copy.

  • Your account: changing your email or password, or turning MFA off, now asks for your current password. Your old address gets a notice when the email changes, and you get an email when your password changes or MFA is turned off.

  • Agent user limit, for hosting providers: an optional $config_agent_user_limit in config.php caps the number of active agent users. It is not set by default, so nothing changes unless you add it. Disabled and archived agents do not count toward it.

  • API:

    • Tickets: a closed ticket cannot be updated. Creating a ticket for a client that does not exist is refused. A contact, asset or vendor belonging to another client is left off rather than saved, and the assignee has to be an active agent.
    • Tickets: the vendor's ticket number is now text, so case numbers with letters keep them. Send it as ticket_vendor_ticket_number; the old ticket_vendor_ticket_id still works. Resolving a ticket that is already resolved does nothing.
    • Certificates: creating one follows the same rules as the New Certificate window. A name or a host is enough. The domain has to be one of the client's, and the host is checked as it is saved.
    • Domains: an update no longer wipes the expiry date and DNS records when the lookup comes back empty. A domain name the client already has is refused.
    • Deleting an asset, contact, client or location through the API now also cleans up whatever pointed at it, the same as deleting it in ITFlow.
    • New endpoint to read services, under the Support permission. Vendors take the account manager's phone and email.
    • A call the key's role doesn't allow is refused with what it needs and what the key's user has - "This needs Modify on Support - the key's user, Tess Tech (Technician), has Read." - in place of "The user linked to this API key does not have permission for this action." It is still a 403. Anything that matches on the old text needs changing.
  • Audit log for agents: each client now has an Audit Log, and record pages show their history, to anyone who can open them - not only admins. An agent sees the kinds of entry their role covers - a technician without Financial sees no expenses or trips, someone without Credentials no passwords looked at - and never sign-ins, users, settings or another agent turned away from a client, which stay in Admin > Audit Logs. The client overview's Recent activity follows the same rule, so it can show a restricted role less than before.

  • Sign-ins:

    • A failed sign in is now logged against the account it was to - a wrong password, or an account that is disabled, archived or not set up yet - where it used to name no one. So it shows in that person's Your account > Activity and in the sign-ins Admin > Users shows for them, even when someone else was guessing at it.
    • A client portal contact's wrong two-step code now counts toward turning an IP address away (15 failed sign-ins in 10 minutes), as an agent's always did.
    • Admins are now told about a run of failed sign-ins - 10 in 10 minutes from one IP address or against one account - with a notification and an email each. Change or turn it off in Admin > Settings > Security.
    • A call to the API with a key that has expired now says which key, and is logged against it. Anything that reads the logs table for "Incorrect or expired key" needs the new wording.
    • The auth_logs table is removed. Nothing was ever written to it.
  • Logs: log retention moved from Admin > Settings > Security to Admin > Settings > Logs, where the audit log (with API key use) and the app logs each keep their own number of days. A retention of 0 now keeps a log forever - it used to delete everything older than today each night.

  • Audit log: each thing done now has one name, in the entries already there as well as new ones. Unarchive and Restored are Restore, Deleted is Delete, and Closed, Resolved, Reopened, Merged and Sent are Close, Resolve, Reopen, Merge and Send. An agent turned away from a client they can't open is Denied, where it was Access. A saved card or payment provider deleted is Delete and a card saved is Create, where both were Update, and a contract template changed is Edit. A report or script that reads the logs table by the old names needs the new ones.

  • AI:

    • Using AI is a permission of its own, Use AI, in Admin > Roles. Every role has it after the update; a role without it gets no Reword button, no ticket summary and no document template draft.
    • The Reword button is only on the editors when a model is set up for it and the role may use AI. It used to be on every editor, and say an error when pressed.
    • A model's prompt is now its instructions - your house style, sent after what each feature asks of the model. Reword says what rewording is itself now, so a prompt written to make Reword work still works but can be cut down to the style you want.
    • Passwords, PINs, API keys, private keys, 2FA links and card numbers in what is sent are replaced with placeholders, and put back in the answer. A provider can be set to send them as they are, in its Edit window.
    • A provider's address is its chat completions address, as before - one ending /v1 now has /chat/completions put on when saved.
    • Summarize with AI reads the ticket's internal notes, tasks and assets now, as well as the conversation, and is laid out differently.

New Features & Updates

  • Everywhere
    • Search: press Ctrl+K (Cmd+K on a Mac) or click the search box in the top bar. Results show as you type, grouped by type: clients, tickets, contacts, assets, credentials, documents, files, licenses, domains, certificates, networks, IP addresses and more.
      • A ticket, invoice or quote number comes up first, so Enter opens it straight away. Phone numbers, IP addresses and MAC addresses all match.
      • Use the arrow keys and Enter, or Ctrl+Enter to open in a new tab. With nothing typed, it lists the clients you opened last.
      • Search everything opens the full results page, which also searches ticket conversations and the text of documents.
    • Notifications: the bell opens a panel with Unread and All tabs, grouped by day. Clicking one opens what it is about and marks it read. Each one can be marked read or unread, and Mark all as read clears the lot. The count updates every minute. The full Notifications page has search, type and date filters.
    • Quick looks: most records now have a quick look, a window with the essentials that opens from the lists and from anywhere else the record is mentioned, without leaving the page you are on. Each one has a button to open the full page.
    • Record pages: licenses, locations, vendors, racks and services have their own page now. Assets, contacts, documents, projects and networks have rebuilt pages. All of them share one layout:
      • actions along the top
      • the key details in cards
      • related records in tabs with counts, and most pages remember the last tab you used
    • Change it where you see it: ticket status, priority, assignee and billable, a project's manager, a recurring ticket's agent, an asset's status, location and contact, and a contact's location. Click the value, pick a new one, and it saves.
    • Tags look the same on every list and record page. The + at the end adds or removes tags as you tick them, on one record or on everything you have selected. Admins can make a new tag right there.
    • Copy buttons show up when you point at a value, such as an IP, MAC, serial, email, phone, username or password, so lists are less cluttered.
    • Linking: one Link window handles every kind of link: files, documents, assets, contacts, licenses, vendors, credentials, services, domains and certificates. It has a tab for each type, lets you pick several at once, and only offers the client's own records that are not linked yet.
    • Drop-downs show pictures, initials, colors and icons where they help: client, agent and contact avatars, tag and category colors, priority dots and asset types. On a phone the list comes up from the bottom of the screen.
    • Forms have an icon in front of every field, the same across the app. Long forms are split into tabs, and most of them take you to a required field you left empty on another tab when you save.
    • Buttons that only show an icon say what they do when you point at them.
    • Empty lists look the same on every list page. With nothing added yet, a list says so, with a line on what goes in it and its own New buttons - and Import, where the list has it. When a search or filters leave nothing, it says nothing matches, with Clear filters to go back to the whole list, keeping the client and the tab you are on. An empty tab that flags trouble, such as Overdue or Expiring soon, shows a tick, as nothing there is good news. Archived says when nothing is archived.
    • Links that delete or disable something ask first.
    • Leaving a page with a half-written reply or note asks first, including after a window has been opened and closed on that page.
    • The side navigation shows your company logo (Admin > Settings > Company Details) or the company's initials, with the company name wrapping onto two lines.
    • The editor's outline follows your theme color when you click into it, like every other field.
  • Dashboard (rebuilt)
    • Needs attention along the top:

      • tickets past their SLA, unassigned tickets and overdue projects
      • overdue and draft invoices, resolved tickets not invoiced yet, stock to reorder, lines on recurring invoices, drafts and quotes at an old price, and hardware sold in the last 90 days that isn't on the client's assets yet
      • domains, certificates, licenses and warranties expiring in the next 30 days
      • services needing attention and service reviews due

      Each one opens a list of exactly those.

    • Your day: your open tickets, your schedule for the week, your time this week, open tickets per agent (admins), and tickets opened against resolved.

    • Finance: pick a period (this month, last 30 days, this quarter, year to date, or any year). It shows:

      • income, expenses and profit compared with the period before
      • receivables, and recurring revenue and expenses per month
      • a cash flow chart that looks ahead using unpaid invoices, recurring invoices and recurring expenses
      • receivables aging, top clients, account balances, the latest income and expenses, and trips
  • Reports (rebuilt)
    • Every report works the same way:
      • pick a period (this or last month, this or last quarter, this or last year, the last 30 days, 90 days or 12 months, a past year, or your own dates), and narrow it to one client or one agent where that makes sense
      • read the headline figures, compared with the period before, then the chart and the tables
      • click a column heading to sort, and click a figure to open the tickets, invoices, income or expenses behind it
      • Export downloads any table as a CSV with every row, and is recorded in the audit log. Print gives a clean page with your company name and the date.
    • The Reports page lists every report you can open, grouped into Finance, Sales & Billing, Service Desk, and Clients & Documentation, with a search box. The menu on the left uses the same groups.
    • New reports:
      • Client Review: one client's quarter on a page, made to print for a business review (QBR). It covers their tickets and how fast they were handled, SLA and ratings, what the tickets were about, who raised them and which devices keep coming back, urgent tickets, projects, hardware to plan for, renewals, services needing a look, documentation gaps and billing. It opens with recommendations to talk through, and is also in the client's menu.
      • Renewals & Expirations: domains, certificates, licenses and warranties due in the next 30 days to 12 months, or lapsed in the last 90 days, by month and by client. Ones that renew themselves show in blue and can be hidden.
      • Asset Lifecycle: how old the hardware is, its warranty, and whether its operating system is still supported (Windows 10, Windows Server 2012 and older, macOS 13 and older and so on are flagged). It shows what reaches replacement age each quarter for the next two years, for budgeting, and lists the replacement candidates.
      • Documentation Coverage: for each client, whether the basics are documented: primary and billing contacts, a primary location, assets with a location and an owner, networks, credentials, documents, domains, services and licenses. Each client gets a score, and what is missing links to the page to add it.
      • Customer Satisfaction: the share of closed tickets rated good, how many get rated, by agent and by client, how ratings follow a late first reply or a slow fix, and every bad rating to follow up.
      • Open Ticket Aging: the open tickets right now by how long they have been open, per agent and per client, with whose move it is, their SLA and when anything last happened.
      • Client Profitability: income, direct costs and time per client, what you earn per hour against your target rate, and recurring revenue.
      • Quotes Pipeline: quotes won, lost and open, the win rate by count and by value, and open quotes about to expire.
      • Canned Responses: how often each canned response goes into a reply, on how many tickets and by how many agents, against the period before, and when it was last used - every current one, so the ones not used show - and by agent, how often each reaches for one, how many different ones and how many of their own. An admin sees every agent's own; anyone else sees the shared ones and their own.
      • Ticket Templates: how often each template is used and how - by agents, from the client portal's request forms, by recurring tickets and by project templates - with what is resolved and still open, how long its tickets take and the time spent on them, and the templates not used in the period.
      • Mileage: trips by agent and by client, valued at your rate per mile.
      • Budget vs Actual: set a monthly budget for each expense category right on the report, or copy last year's, and see spending against it by month and category.
      • Inventory: what is on the shelf and what it is worth at cost, what came in, and the cost of what was sold and written off, by month. Every tracked product with its stock, value and reorder level, and every stock move in the period.
    • Reworked reports:
      • Profit & Loss: monthly or quarterly, with last year and the change beside each line, and the margin.
      • Income and Expenses: by category across the year, with last year drawn as a dashed line.
      • Spend by Vendor and Income by Client: the period against the one before, with a switch for those paid 600 or more for 1099s. Spend by Vendor adds each vendor's main category and recurring spend. Income by Client adds recurring revenue and the share of your top five clients.
      • Receivables Aging: what each client owes, split into not due yet, 1 to 30, 31 to 60, 61 to 90 and over 90 days late, and the overdue invoices, longest overdue first.
      • Recurring Revenue: monthly and yearly recurring revenue by client and category, and every recurring invoice with its next date. With Financial access, what is left after recurring expenses.
      • Tax Collected: tax by part of each rate, as invoiced or as paid, with taxable, exempt and non-taxable sales, the tax paid on purchases and what is left, by quarter or by month (see Taxes).
      • Unbilled Work: billable tickets that are done but not invoiced, with the hours to bill (rounded the way Billing Time in Admin > Settings > Invoice says, as Unbilled Time does) and what they are worth at the client's rate.
      • Time Detail by Client: per client, each ticket and the time on every reply, with the hours to bill.
      • Ticket Overview: tickets opened against resolved, the typical first reply and time to resolve, sources, priorities, categories, top clients and contacts, and a weekday by hour chart of when tickets come in.
      • Tickets by Client: volume and its change, urgent tickets, time logged, reply and resolution times, SLA and satisfaction for each client.
      • Technician Performance: hours logged and billable, utilization against the working days in the period, replies, tickets worked and resolved, SLA and satisfaction for each agent.
      • SLA Performance and SLA by Client: targets met over time, by priority, by agent and by client, and the tickets past their SLA right now.
      • Credential Rotation: how old every password is, the clients with the most due, which ones have 2FA, and every password due for a change.
  • Clients
    • The clients list has compact rows, filter chips you can remove one at a time, open ticket and balance columns, and tags under the industry with a + to add one. Clicking anywhere in the client's cell opens it.
    • The client side menu shows the client's initials and name under an All clients link. Click it to switch to another client and stay on the same page.
    • The header on every client page shows the initials, industry, tags, primary location, open tickets and balance due, with a panel that opens for more.
    • The client overview is redesigned in two parts:
      • Technical: needs attention (such as licenses over seats, passwords due for a change, certificate problems and services needing attention), open tickets, quick notes that save when you click away, key contacts, favorites, the next 14 days, projects, services, renewals, assets, active share links and recent activity.
      • Financial: unpaid invoices, recurring invoices, and invoiced against paid.
    • Export Data, in the client's menu, is rebuilt with two choices:
      • Documentation PDF: a printable runbook to hand over or keep on file. It has a cover, contents with page numbers, and an overview with the key contacts, service levels and how to reach you. Then comes a chapter for each part you pick: contacts, locations, networks with their IP addresses, assets by type, racks, licenses with their keys and who holds them, domains and DNS, certificates, services with what they run on and depend on, vendors, credentials, files, and every document in full with its images.
      • Full export: one zip with everything held for the client, for a handover, an offboarding or your own records. Every record comes as a CSV spreadsheet and as JSON for moving to another system. Documents come as web pages in their folders, files in their folders under their own names, and ticket conversations with their attachments, alongside projects, invoices, quotes and payments, photos and the PDF. A README.html links it all together.
    • Each part shows how many records it holds. Archived records and internal ticket notes go in only if you tick them.
    • Passwords and 2FA secrets go in only if you ask, and the PDF or zip is then locked with a password you set (AES-256). An encrypted zip opens with 7-Zip, WinRAR, Keka or The Unarchiver, but not with the zip tools built into Windows and macOS.
    • Each export is recorded in the client's audit log, including whether passwords went out.
  • Shared links (rebuilt)
    • One Share window for logins, documents and files, from their lists, quick looks and pages, plus two new kinds of link:
      • Secure note: type a password, a Wi-Fi key or a recovery code and send it as a link. It is not saved anywhere else.
      • Ask for a login: the client gets a form to send you a login safely instead of by email. You get a notification and an email when it arrives. It then waits in Shared Links until you save it to their credentials or discard it.
    • Choose:
      • how long the link works, from 1 hour to 30 days
      • how many times it opens: once, 2, 3, 5 or 10 times, or until it expires
      • what it asks for first: nothing, a passphrase you give them by phone or text (one is made up for you), or a 6-digit code emailed to the recipient, so a forwarded link is no use to anyone else. Five wrong tries lock the link.
    • The link can be emailed to a contact, copied, or shown as a QR code. It is shown once, and nobody can look it up again later.
    • For a login, you can include the 2FA code: they see the current code, never the secret. What they get is fixed when you make the link.
    • The page they open shows your logo and who sent it, then a Reveal button. It has copy buttons, the 2FA code with a countdown, and a button to destroy the link once they have what they need. Documents can be printed, and files download.
    • Shared Links, in each client's menu and under All Client Docs, lists every link:
      • what went out, to whom, who made it, until when, how often it was opened, and where it is now
      • filters for Open, Sent back and Ended
      • turn off one link, the ticked ones, or every open link the client has, when an engineer leaves or a mailbox is compromised
    • Each link's quick look has its history: when it was opened, from which address and browser, wrong passphrases, codes sent, and when it was turned off.
    • You are notified when a link you made is opened (you can switch this off per link), and always when one locks.
    • The key is the part of the link after the #, which browsers never send to the server, so it stays out of web server and proxy logs. Logins, notes and what clients send back are encrypted with that key, which is not stored, so the database alone cannot open them.
    • Admin > Settings > Security sets the longest a link may last, up to 90 days, and whether shared passwords and secure notes must have a passphrase or a code.
  • Billing (rebuilt)
    • Invoices, quotes and recurring invoices have new lists and pages, and every billing window is redone.
    • Lists:
      • Invoices: what you are owed, what of that is overdue, what is sitting in drafts and what came in over the last 30 days, each opening the invoices behind it. Filter by Draft, Unpaid, Overdue, Paid or Cancelled, by category, client and date. Each row shows what is still owed and how long until it is due, or how many days late it is.
      • Quotes: what is out waiting for an answer, what has been accepted and not invoiced yet, what you won over the last 90 days and your win rate. Filter by Draft, Awaiting answer, Expired, Accepted, Invoiced or Declined. Each row shows how long a quote has left.
      • Recurring invoices: your monthly recurring revenue (quarterly ones count as a third, yearly ones as a twelfth), what bills in the next 30 days, how many are running, and any that need a look. Filter by Active, Next 7 days, Late, Paused or All. Each row shows the amount, what it brings in a month, when it next bills, whether it is on autopay, and whether it bills per user or per device.
    • Pages: the invoice, quote or recurring invoice is shown the way the client sees it, with the next thing to do at the top:
      • Invoice: Send (or Mark Sent) a draft, Add Payment on one that is owed, with Charge Saved Card when the client has a card on file. On the side: what has been paid and what is left with a progress bar, the payments (edit, refund through Stripe or delete them there), the tickets billed on it, and its history as a timeline.
      • Quote: Send, then Accepted or Declined when the client answers by phone or email, then Make Invoice (Start Billing when it has recurring lines). A quote that ran out without an answer says so, with an Extend button. On the side: the files the client sent back, and its history.
      • Recurring invoice: Invoice Now, Pause and Resume. On the side: the schedule with the next few dates, the period the next invoice covers and whether invoices go out by themselves, autopay, the invoices it has made and whether they were paid, and its history. A run missed in the last 7 days is billed by the next nightly run. One further behind says so, and offers Invoice Now for each missed run or moving on to the next date without billing them.
    • Lines are edited on the page:
      • Type a product's name or code to fill in its description, price and tax. What the line comes to shows as you type, and Enter adds it. The page comes back ready for the next line.
      • Drag a line to move it. Edit or remove it from its menu. An invoice's lines are locked once it is paid, cancelled or non-billable, and a quote's once it is answered.
      • Taxes show by name on the totals, such as PA Sales Tax 6%, instead of one Tax line.
    • Windows: New and Edit have the same fields for all three, with the discount on both. A new invoice shows its due date from the client's terms, and a quote's valid-until date has 15, 30, 60 and 90 day shortcuts.
      • Add Payment starts at what is owed, says what is left after the payment as you type, and shows each account's balance. Batch Payment lists the invoices it will pay, oldest first.
      • Send, Copy, Make Recurring, Make Invoice, Mark Sent and Add Tickets (any number at once) say what they will do before you do it.
    • PDFs: one new look for invoices, quotes and packing slips, with your logo, taxes by name and page numbers. An invoice still owed carries a QR code to open it online and pay, and a quote waiting for an answer one to accept it. Paid, cancelled, accepted and declined ones are stamped.
    • The client's view of an invoice: what is owed and by when, with Pay Now, the PDF, the payments made, and everything else they owe with a link to each.
    • The client's view of a quote: Accept and Decline, with how long it is good for. Once accepted, they can send back a signed copy or a purchase order.
    • A new recurring invoice can be set to leave a draft to check, and take a discount, from the start.
    • Per user, per device and per license billing on recurring invoices:
      • A line can take its quantity from the client. That can be their contacts (all, or those with a tag), their devices (the types you tick, or those with a tag) or a license's seats (in use or bought). Lost, stolen and retired devices are left out.
      • The count is taken again each time an invoice is made, so a new hire or a new laptop is on the next invoice without anyone editing it. A line can have a minimum for contracts that set one.
      • On the add-a-line row, Counted switches a line to Per user or Per device. Edit Line has every option, and shows the count as you choose.
      • Each such line is marked on the page. A Seats card shows what each one bills against what the client has now, with Update now when they differ.
    • Quarterly recurring invoices, alongside monthly and yearly. They count as a third in monthly recurring revenue on the dashboard, the client pages, the client portal and the reports.
    • Period words: {month}, {quarter}, {year} and {period} in a recurring invoice's scope, notes or lines are filled in with the period each invoice covers, such as "Managed services November 2026", "Q4 2026" or "Nov 1 - Nov 30, 2026".
    • Invoice Now names the run it bills, whether it will be emailed and when the next one is, before you confirm. Pressing it twice, or in two tabs, bills that run only once.
    • Unbilled Time, under Billing and in each client's menu, with a count of what is waiting:
      • Every client with billable tickets that are done and not invoiced, with their tickets, the time logged, the hours to bill, their rate, what it comes to and how long the oldest has waited. Tick clients and make all their invoices at once - a new draft each, or onto each one's newest draft.
      • One client's tickets, each with its hours to bill filled in and ready to change before it goes on. Tick the ones to bill and make an invoice or add them to one of the client's drafts, with a line for each ticket or one for all of them. The total follows as you change the hours, rate or tax.
      • Not billable writes off ticked tickets so they leave the list. Each one's history says so.
      • Two agents billing the same tickets at once can't both bill them.
      • Expenses to bill on to clients sit beside the time and go on the same invoice - see Expenses, Billing expenses on.
    • Billing Time in Admin > Settings > Invoice: round a ticket's time to 6, 12, 15 or 30 minutes or the hour, to the nearest step or always up, with a minimum a ticket bills, and the name of the invoice line. It starts as before: the nearest 15 minutes, no minimum.
    • The ticket's Invoice window is redone. It shows the time logged by each agent and the hours that bills, with the client's rate (or the default rate) filled in, and it can add to one of the client's drafts. An open ticket can still be billed from here, with a warning that later time won't be.
    • Add Tickets on an invoice bills their time: tick the tickets, change their hours, and they go on as lines at the rate.
    • The client overview's Not yet invoiced shows what the unbilled time comes to and opens Unbilled Time. The dashboard and the Unbilled Work report link to it too.
    • Quotes with monthly lines, so one quote can carry the onboarding and the agreement that follows:
      • Each line is billed one time, or each month, quarter or year. Pick it under Billed on the add-a-line row, or in Edit Line.
      • The quote groups its lines under One-time, Each month, Each quarter and Each year, with a total for each, on the page, the PDF and the client's view. The top shows what it comes to up front and each month, quarter or year. A line drags within its own group.
      • Start Billing on an accepted quote: the one-time lines go on a draft invoice, dated as you choose and due on the client's terms. Each month, quarter or year part goes on a new recurring invoice from the date you pick (the first of next month to start), with its invoices emailed as they are made or left as drafts, or is added to one of the client's recurring invoices billed as often. Switch on Bill the first month on the invoice now to put the first period on the invoice too. A quote with only one-time lines keeps Make Invoice.
      • The quote shows what it was invoiced as, with a link to each invoice and recurring invoice, including any it added lines to. The invoice and the recurring invoices link back to the quote, and each history says what happened.
      • The quotes list adds the monthly part to each row and to its totals. Search, the client portal's quotes, the Send and Copy windows, the quote email and the client's Accept all show the amounts by part. The quotes export has a Monthly column, and the client export a monthly amount on quotes and how each quote line is billed.
    • Reminders and statements, in Admin > Settings > Invoice under Reminders and Statements:
      • Set when reminders go: any days before the due date, on the due date, any days late, then every so many days until the invoice is paid. The schedule is shown in words as you type, such as "3 days before it is due, on the due date, 7 and 14 days late, then every 30 days until it is paid".
      • A reminder before the due date is a friendly one. Once the invoice is late it is firmer, and says how many days late it is.
      • A client can be left out of reminders and monthly statements with the switch on the Billing tab of its Edit window.
      • The invoice page's payment card shows how many reminders have gone and when the last one went, and when the next one goes, or why none will. Send Reminder in the More menu sends one now.
      • Monthly statements: switch them on and pick the day of the month (the 1st to the 28th). Each client with a balance gets one, unless left out of reminders.
      • The nightly run sends at most one reminder a day for each invoice and one statement a day for each client, however often it runs. The invoice's history says who each reminder went to and how late the invoice was.
    • Statements: Send Statement attaches the statement as a PDF. It shows what is owed by age (current, 1-30, 31-60, 61-90 and over 90 days) and every invoice in the period with what was paid and what is left. Download PDF in the window follows the dates and options you pick, and Statement PDF is in the invoice list's menu when it is showing one client.
    • Send windows: the invoice, quote and statement windows take a message of your own, which goes in the email above the document, and a switch to attach the PDF. On an invoice that is still owed, the invoice window can send the invoice or a reminder.
    • Billing emails have a new look: your logo, what is owed and by when in a summary, the message if there is one, and a button to view and pay the invoice or accept the quote. This covers invoices, quotes, reminders, payment receipts and statements, and the notices to your team when a quote is accepted or declined or a payment is made online.
      • Receipts show the amount, method, reference and what is still to pay. A batch payment's receipt lists each invoice it was applied to.
      • Each email's entry in the history says who it went to and whether the PDF went with it.
  • Products (rebuilt)
    • Each product or service records what it costs you, so its margin shows, along with the vendor you buy it from, their part number and notes for agents only.
    • How it is billed by default: one time, monthly, quarterly or yearly, and per user or per device. Picking it on a quote fills in Billed. On a recurring invoice, per user or per device takes the quantity from the client's count.
    • Track stock, for products: a tracked product comes off the shelf as it is invoiced and can't be sold past what is there. The rest are ordered as needed, such as drop-shipped hardware.
    • The list:
      • The top shows what catalog lines bring in each month on recurring invoices, what was sold over the last 90 days with the top seller, and what the stock on the shelf is worth at cost.
      • Tabs for Services, Products, Recurring, Stocked and To reorder, a vendor filter, and search by part number and vendor too.
      • Each row shows how it is billed, the stock and where it is kept, the vendor and part number, and the price with its cost and margin.
    • Each product has a page:
      • Its price and margin, what it sold over 12 months, and what it brings in a month on recurring invoices.
      • Its stock, where it is kept and what that is worth, and a Stock tab with every move and what was on the shelf after it.
      • Where it is used: the recurring invoices it is on, open quotes, the invoices it was sold on, and its history.
      • Notes save as you click away. Copy makes a new one to start from.
    • New and Edit are one window, with Service or Product at the top and the margin worked out as you type the price and cost.
    • Stock, for products that track it:
      • Every change is recorded as Received, Sold, Returned, Counted or Written off, with the unit cost, the invoice or expense behind it, a note and who made it.
      • One Stock window to Receive (at a unit cost, which can become the product's cost, linked to an expense from the last year, the vendor's first), Count (type what is on the shelf and the difference is recorded) or Write off (damaged, lost or stolen, used by us, or sent back to the vendor). It shows what will be on the shelf as you type.
      • Reorder at and Order on each product. Products at or under their level show on the To reorder tab, with a badge on the list, on Products in the menu and in Needs attention on the dashboard. A Low Stock notification goes out when stock drops to the level or runs out, saying how many to order and from whom.
      • Count Stock lists every tracked product, or one location's, with what ITFlow has and a box for what you counted. The difference shows as you type, Enter moves to the next box, and blank ones are left alone. Print it as a count sheet to take to the shelf.
    • Hardware becomes client assets:
      • Give a product the asset it becomes - a laptop, a switch, an access point and so on - with its make, model and warranty in months.
      • An invoice with hardware on it has a Hardware card: each line, how many of it are on the client's assets, and which ones with their serial numbers.
      • Add to Assets has a row for each one still to add, named from the model, for its serial number and who has it. Enter after a serial, or a barcode scan, moves to the next one. The make, model and warranty (counted from the invoice date) come from the product and can be changed, and the status and location are set for all of them. A row can be linked to an asset the client already has instead, or taken out and left for later.
      • Each asset made this way is bought on the invoice date with the invoice number as its order reference, and its page links to the invoice and the product.
      • The product's page shows the asset it becomes and how many are in use, with an Assets tab listing each one with its client, serial number, warranty and invoice.
      • Products has a Hardware tab, and each row says the asset it becomes.
      • The packing slip lists the serial numbers under each line.
      • The starter catalog's hardware comes with its asset type and warranty, and so does the demo data's.
    • Price changes reach recurring invoices:
      • Every change to a product's price or cost is kept, with who made it and whether it was edited, imported or came from received stock, on a Prices tab on its page. Pricing says when its price last changed and from what.
      • Lines on recurring invoices, draft invoices and draft quotes still at one of its old prices are flagged on the product's page, the Products page, each of those documents and Needs attention on the dashboard, with what updating them adds a month.
      • Update Prices lists them by product - all of them from the Products menu, or one product's, one client's or one document's. Lines at an old price are picked, ready for today's price. A price the product never had looks agreed with the client, so it is marked Own price and left alone unless picked, with the last change applied to it as the suggestion. Each new price can be changed, Catalog price or the same percent fills in a product's lines at once, and the footer says what recurring invoices bring in a month after. Invoices already sent, and quotes out with clients, keep their prices.
      • Each line changed goes in its document's history, and the document's tax and total follow.
      • Saving a new price, or importing a price list, says how many lines are at an old price.
    • Import takes every field, including Reorder At, Order, Asset Type, Make, Model and Warranty. Categories, taxes and vendors are matched by name. A new price list can update products by their code, and a file with only Code and Price will do. Download the template for the layout.
    • Export adds cost, margin, how it is billed, vendor, part number, stock and its value, the reorder level, and the asset it becomes with its make, model and warranty. It can export just what is to reorder, or just hardware.
    • Invoices, quotes and recurring invoices show agents their margin on lines from products with a cost, at today's cost. A quote shows it up front and each month.
    • Our vendors' pages have a Products tab listing what we buy from them.
    • Search finds products by part number and opens their page.
    • The starter catalog in Starter Content comes with how each item is billed. The demo data's products have costs, vendors and part numbers.
  • Expenses (rebuilt)
    • Along the top of the list: what was spent this month against the same days of last month, the year so far with its monthly average, what the running recurring expenses come to a month, and how many expenses in the last 90 days have no receipt. Each one opens the expenses behind it.
    • Pills cut the list to expenses for a client, ones with no receipt, and refunds, each with a count. Filter by vendor, category, client and date, and under More filters by account and how it was paid. The total of everything the filters leave sits under the list.
    • Each row shows the vendor with what it was for and its reference, the category with its colour, the client, the account and how it was paid, and a paperclip that opens the receipt.
    • Clicking an expense opens its quick look:
      • the receipt right in the window when it is a photo or a PDF
      • who it was paid to, the category, account, payment method, reference and client
      • who entered it and when
      • the refunds against it
      • the stock deliveries it paid for
    • With no receipt yet, attach one right there - on a phone, take the picture.
    • New Expense, Edit, Copy and Refund are one form with an icon on every field. Picking a vendor fills in the category, account and payment method of the last expense with them, unless you changed those yourself.
    • The payment method is saved on each expense, starting from the default in Admin > Settings > Defaults.
    • Copy dates the copy today and leaves off the reference and receipt.
    • Refund starts at what hasn't come back yet and is linked to the expense, so each shows the other.
    • A receipt can be replaced or removed in Edit.
    • Bulk actions set the category, account, payment method or client on the ticked expenses, or delete them. Expenses of clients you can't see are left alone.
    • Export takes the same filters as the list, adding client, payment method and the pills, with Paid By, Receipt and Entered By columns.
    • Search finds expenses by what they were for, their reference or their amount, and opens the quick look.
    • The vendor page's Expenses tab, Transactions and a product's stock history open the expense's quick look. Client Profitability links each client's direct costs to their expenses.
    • Recurring expenses run on a real schedule:
      • monthly, quarterly or yearly, on the day of the month they started on - the 31st runs on the last day of a shorter month
      • with an optional end date for a lease or financing, after which they stop by themselves
    • The New and Edit windows say the schedule in words as you set it, such as "Every quarter on the 31st, starting Dec 31, 2026, until Dec 31, 2028", and fill in from the vendor's last expense like New Expense does.
    • The list shows what they come to a month (quarterly as a third, yearly as a twelfth), what is recorded over the next 30 days, how many are running, and how many need a look. Pills for Active, Next 7 days, Late, Paused, Ended and All, and filters for vendor, category and client.
    • Each row shows the amount and how often, what it comes to a month, the next date with how long until it or how late it is, when it ends, and when it was last recorded.
    • A quick look shows the schedule with the next few dates, the account and payment method, the client, what has been recorded this year, and the expenses it recorded.
    • Record Now records the next run straight away - dated today when early, or the day it was due when late - and moves the schedule on. Pressing it twice records it once.
    • A recurring expense that fell behind can record each missed run with Record Now, or skip them with Move On. Pause and Resume stop and start it.
    • The expenses it records carry its payment method and reference, and are linked to it. They show a ↻ in the expenses list and their quick look names it. Expenses Recorded lists them, and the export takes that filter too.
    • The nightly run tells the team about each expense it records, opening it.
    • The dashboard's recurring expenses, its cash flow forecast and the Expenses, Recurring Revenue and Spend by Vendor reports count quarterly ones and stop at end dates. The vendor page's Recurring tab opens the quick look.
    • The demo data's standing costs are recurring expenses on their own days, with the insurance quarterly, linked to the history they recorded.
    • Billing expenses on to clients - pass-through costs such as hardware, licenses or a courier:
      • Mark an expense for a client billable in New Expense or Edit, with a markup. The window shows what it will bill as you type. The default markup is set in Admin > Settings > Invoice, under Billable Expenses.
      • It waits in Unbilled Time beside the time. One client's page lists their expenses to bill with the cost, markup, the price to change and the margin, and Make Invoice puts the ticked tickets and expenses on one new draft, or one of their drafts. Not billable takes the ticked ones off the list. The all-clients page has an Expenses column, includes clients with only expenses waiting, and bills each client's time and expenses together.
      • Each goes on the invoice as a line of its own, named from the first line of its description, with the vendor, date and reference under it. A refund against it comes off the price first.
      • The invoice has an Expenses card: each expense's cost against what it billed, the margin, and the totals. Add opens Add Expenses with the client's waiting expenses and their prices. Taking one off removes its line, and so does deleting the line or the invoice - the expense is then to bill again. Once the invoice is paid it stays. A cancelled invoice keeps its line, and the expense can still be taken off to bill on a new one.
      • The expense's quick look says it is to bill, at what price and markup, or which invoice it is billed on with the margin, with Take off Invoice. The list has To bill and Billed pills, a mark on each row linking to Unbilled Time or the invoice, and Bulk Set Billable. A billed expense can't be moved to another client until it is taken off.
      • A client's recurring expense can be billed on each time it records one, with a markup, or be covered by one of the client's recurring invoices. A covered one isn't billed by itself: the recurring invoice's new Costs card lists what it covers, what it bills a month before tax against what they cost a month, and the margin left, with Add to set up another one it covers. The recurring expenses list and quick look show which it is.
      • Expenses to bill show on the dashboard and the client overview's attention list, and the client overview's Not yet invoiced includes them. The export has Billing and Billed On columns.
      • The demo data's latest client expenses are to bill, at 15%.
    • Import Statement, from the expenses menu, brings a card or bank statement in as expenses:
      • Upload the CSV your bank or card exports, and pick the account it is for and how it was paid. Lines before the header, semicolons or tabs, and amounts like (12.00), 12.00 CR or 1.234,56 are read as they come.
      • The columns are worked out for you: the date and its format, the description and a memo, the amount (charges positive or negative) or separate money out and money in columns, and a reference such as the check number. You check them once against the statement - the next statement from the same account goes straight to the review.
      • The review lists every line with the vendor, category and client it matched: what you picked for that merchant last time, or one of your vendors whose name is in it (cut short too, such as THREE RIVERS COLO for Three Rivers Colocation), with the vendor's last category. A line with no match offers a new vendor named from its text - "SQ *JOES COFFEE #123 PITTSBURGH PA" is Joes Coffee.
      • Picking a vendor fills in the other lines from the same merchant. Set a category or client for all the ticked lines at once. A line for a client can be billed on to them.
      • Lines brought in before are locked, so importing the same statement twice brings nothing in twice - two coffees on one day are still two. A line with the same amount as an expense entered by hand on that account within 4 days is left out unless ticked, and is linked to that expense so the next statement knows it too.
      • Payments to the card, deposits and refunds are left out unless ticked - then they come in as money back. Bring them in as income takes the same statement on to Import Deposits (see Income).
      • Whether charges are positive or negative is worked out from the words on the lines (DEPOSIT, ACH CREDIT, TRANSFER FROM, INTEREST against PURCHASE, WITHDRAWAL, ACH DEBIT) when they say, so a statement with mostly deposits reads the right way round.
      • Import brings in the ticked lines, making each new vendor once, and opens the expenses list on that account and those dates. The merchants are remembered for next time - see or forget them from the Import Statement page.
    • Paid personally: an expense an agent paid for themselves - a hotel, parts from the store - is picked as Paid personally, with who paid it. It counts as spent on its date like any other, but comes out of none of your accounts until they are paid back.
      • The list has a To reimburse pill. On it, each agent shows with what they are owed and a Reimburse button. Rows show who paid and whether they have been paid back.
      • Reimburse pays an agent back for the ticked expenses from one account, on a date, with how and a reference. The account's balance goes down then, and the payment shows in Transactions as a Reimbursement. Spending totals and reports count the expenses once, on their own dates.
      • The expense's quick look says who paid it, To reimburse with a Reimburse button, or when it was paid back. The reimbursement opens with what it paid back, and Undo (full Financial access) takes the payment back off the account.
      • Bulk Reimburse pays back the ticked expenses of one agent. Bulk Set Account puts a paid personally expense on an account instead, unless it was paid back already.
      • Expenses to reimburse show on the dashboard. The export has a Paid Personally column, and the list can be cut to one agent's.
  • Tickets
    • The ticket list has views for Open, Mine, Unassigned, Needs reply, Waiting, Overdue, Scheduled, Closed and All.
      • Each row shows whose move it is (yours or the client's) along with SLA and due badges, and status, priority and agent change right in the row.
      • The SLA column can be sorted, the filters fold away, and the list has a proper phone layout.
    • The board view is redesigned and works in dark mode. Dragging a ticket to resolved or back follows the same rules as the ticket page.
    • The ticket page has a Details panel where you click a property to change it, with billing moved into the menu. New cards show:
      • Site: the location's address, phone, hours, access notes and directions
      • Vendor: their case number, support line, hours, SLA, account number and PIN, with copy buttons
      • Also open: the client's other open tickets
    • Mentions in internal notes - see Mentions in internal notes.
    • Replying: pick Internal note, Public note or Email reply. Nothing is picked for you, and a line says exactly who will see it. An internal note is for your team only. A public note shows in the portal but sends no email. An email reply goes to the contact and any watchers.
    • The conversation says which of your replies were emailed. An email reply is marked Email reply, with who it went to: click that to see each person and whether their email was sent, is waiting, or failed and why, with a link to the Mail Queue for admins. A public note is marked Public note. Replies from before the update that the mail queue no longer has stay marked Public.
      • Sending a reply says how many people it was emailed to, or that it wasn't and why - no contact or watcher with an email address, or no mail set up.
      • Editing an emailed reply says who has it, and that they keep the email as it was sent.
    • New Ticket can start from an asset or contact, and shows when they already have open tickets.
    • Recurring tickets can be paused and resumed or raised now. Their schedules are written out in plain words with a preview of the next run, and bulk actions are available.
    • The ticket lists on asset, contact, location, vendor and project pages look and work like the main list.
    • Client portal: a ticket waiting on the client says Waiting on you (see Client portal).
    • The ticket link in your ticket emails, which opens without signing in, is rebuilt to match the client portal's ticket page: your logo, where the ticket stands, the conversation oldest first with the files that came with each message, who is working on it and on which device, and your phone, email and website. It also shows:
      • when you will reply, while the SLA's response time is still ahead
      • work booked on the ticket, onsite with the site's address or remote, with Add to calendar
      • tasks waiting for the client's approval, and who was emailed the link to approve them
      • Reply by email, with the ticket number in the subject so the reply lands on the ticket (when ITFlow reads the ticket mailbox), Reply in the client portal, and Call for an Urgent or High ticket
    • Replies still come by email or from the portal, where you know who wrote them, since the link also goes to watchers. Internal notes and their files never show.
    • Close, reopen and the rating are now buttons that send a form, so something that follows the links on a page can't close or reopen a ticket. Reopening tells your agents in Notifications, and a ticket is rated once, Good or Not good.
  • Projects
    • The projects list has views for Active, Mine, Overdue, Due soon, Stalled, Over budget, Ready to close, Completed, Archived and All.
      • Each row shows ticket and task progress bars, time logged against the budget with unbilled hours, and due badges, and the manager can be changed in place.
      • Bulk actions can set the manager or due date, complete, reopen, archive, restore and delete. Export is to CSV or PDF.
    • The project page shows warnings when something needs a look, and stat cards for tickets, tasks, time and due date. Tickets, Tasks and Activity are on tabs, alongside the scope, the details and the team with their hours.
    • Projects now have a start date, budget hours and a client contact.
      • A template can assign its tickets to the manager. You can also add a template's tickets to an existing project.
      • Their tickets are set up as their ticket templates say, with [Client], [Project], [Month] and the like filled in (see Ticket templates).
    • You can add tickets by picking them or by number, and completed projects can be reopened.
    • Project templates in Admin keep their ticket templates in order, and can be duplicated, archived and restored.
    • Projects show on the ticket page, the client overview, the dashboard, the calendar and in search.
  • Ticket templates (rebuilt)
    • A template sets up the whole ticket, not just its subject, details and tasks: its category, priority, billing, the agent it goes to, when it is due (so many hours or days after it is raised) and its tags. Each is left alone unless the template sets it.
    • Picking one on New Ticket fills in every field it sets, so you see the ticket before it is made and can change anything. The picker says what each template sets up and how many tasks it has.
    • The New Tickets windows on the assets and clients lists now offer templates too, as recurring tickets do - and the clients list can raise each ticket for the client's primary contact.
    • [Client], [Contact], [Asset], [Site], [Agent], [Project], [Ticket], [Date], [Month], [Quarter] and [Year] are filled in when the ticket is raised - in its subject, details and tasks.
    • Fill-ins: anything else in square brackets, like [Employee Name] or [Start Date], is asked for when a ticket is raised from the template - on New Ticket, in the New Tickets windows (once, for all of them) and on recurring tickets (once, for every ticket they raise). The answer goes in wherever its [Name] is.
    • On the template's page each fill-in can be made a date, a choice from a list or yes or no, made required, given a line of help, renamed (it is renamed in the text and tasks too) and dragged into the order it is asked in. You can add one that isn't in the text, to keep the answer on the ticket.
    • The answers stay on the ticket, in a Fill-ins card. One left blank stays in the text, and can be answered on the ticket later - it then goes in where it is still waiting, and each change goes in the ticket's history. Tickets raised by a project template are answered this way.
    • A template's task can be for one answer only: "Order a laptop" when Needs a laptop is Yes, "Assign Business Premium" when Licence is Business Premium. On New Ticket the tasks for other answers drop out as you answer. A task whose answer isn't given is kept.
    • Request forms in the client portal: a template can be offered there, under a title, a line about it and an icon of its own - "Onboard a new employee" - to everyone with portal access or the primary and technical contacts only, at every client or the ones you pick. The client answers its fill-ins (a fill-in can be kept for agents to answer instead), adds anything else and any files, and the ticket is raised set up as the template says: category, priority, billing, the agent it goes to (who is told), due time, tags and the tasks for those answers. With [Asset] in the template, the form asks which device. The template's page shows how it appears, who and where it is offered and how many requests it has brought in, and the list marks the ones in the portal. A copy of one starts off not offered.
    • Export and import: Admin > Ticket Templates exports every current template, the ones picked, or one from its page, as a file to import into another ITFlow or keep. Each goes with its tasks, fill-ins, request form, category and tags - not the agent it goes to or the clients its request form is for. Importing matches categories and tags by name and makes the ones missing, and one already here by name is left as it is, added as well under a name of its own, or replaced - keeping its tickets, its agent and its request form's switch and clients. A request form comes in turned off.
    • The MSP starter templates in Starter Content are rebuilt on all of this: each asks what it needs (New Hire Onboarding asks the start date, job title, manager, whose access to copy and whether they need a computer or a phone extension), is set up with its category, priority, tags and due time, adds the tasks for the answers given, and the ones clients raise come with a request form - Onboard a new employee, Someone is leaving, Ask for a new computer and Report a suspicious email. Two new ones, Software Request and Access Change Request, have request forms too. An install that added the starter templates before can bring them up to date with Update - their tickets, agent and request form switch stay.
    • A ticket keeps which template it came from, and its history says so. Admin > Ticket Templates shows what each sets up, its tasks and their time, how many tickets it has raised and when it was last used, and the recurring tickets and project templates that raise it - searched, sorted by name, tasks, use or last use from the column headings, a page at a time, with Active and Archived tabs. A template's page shows the ticket it starts with its placeholders marked, its tasks (added with their time, dragged into order) and its latest tickets, and New ticket from it opens New Ticket with it filled in.
    • Templates can be copied, archived - not offered for new tickets, while the recurring tickets and project templates that use them keep them - restored and deleted.
  • Canned responses
    • Admin > Canned Responses lists what each says, the ticket categories it is offered on, how often it has gone into a reply and when it last did - searched, filtered by where it is offered, sorted by name, use or last use, a page at a time, with Active and Archived tabs.
    • A response can be offered on several ticket categories, or on every ticket.
    • [Contact], [First Name], [Client], [Ticket], [Subject], [Ticket Link], [Asset], [Site], [Project], [Agent], [Date], [Month], [Quarter] and [Year] are filled in from the ticket as a response goes into a reply. [Agent] is you, the agent replying, and [Ticket Link] is a link to the ticket, as its emails have. The response window lists them under the editor to click in where the cursor is. Anything else in square brackets - [Date of visit] - stays as it is, for you to fill in before sending.
    • Responses can be copied, archived - no longer offered on tickets - restored and deleted.
    • Canned response above the reply box opens a list of the ones on offer to search, by name or by what they say, each with a line of it and what it sets up - Enter picks the first one. Or type / in the reply box and keep typing to narrow the list, then Enter or click to put one in.
    • A response can set up the reply too, on its Sets up tab: reply as an internal note, a public note or an email reply, the ticket's status after it, and minutes added to the time worked. They are set as it goes in, a line under the picker says what was set, and you can still change them before sending.
    • Sending a reply a canned response went into with something still in square brackets - [Date of visit] - asks first: Fill them in selects the first one to type over, or Send anyway.
    • Update & Reply on the tickets list offers the responses for the tickets' categories and the ones for every ticket. [First Name], [Ticket], [Ticket Link] and the other placeholders in what you write there are filled in for each ticket as it is sent, and a response picked into it counts as used on each.
    • Admin > Categories counts the canned responses offered on each ticket category, linked to the list of them, and merging categories moves them over.
    • Agents can keep canned responses of their own, in their account under Canned Responses - offered to them alone, first in the picker as Yours. The page lists theirs and the shared ones, with how often they have used each, and their own can be changed, copied, archived and deleted there. A shared one can be copied to theirs, to change as they like.
    • Save your reply as a canned response, at the foot of the picker, opens what you have written with the ticket's contact, first name, client, ticket number, subject, link and your name swapped back for placeholders, offered on the ticket's category, to change before saving. A switch puts it back as you wrote it. It is saved without leaving the ticket: your reply stays as it was, and the new one is in the picker straight away.
    • Admin > Canned Responses has an Agents' own tab, saying whose each is, and Share with everyone moves one to the shared ones. A copy made there is shared. An admin can share one of their own from their account too, or tick Share with everyone when saving a reply.
    • Files can go with a canned response - a how-to, a form to fill in - up to ten, on its Files tab, of the kinds a ticket takes. Picking it shows them under the picker, each with an x to leave it off, and the rest are copied onto the ticket with the reply and sent with its email - on a ticket and in Update & Reply. The lists show how many files each has.
    • Export and import: Admin > Canned Responses exports every current shared one, the ones picked (archived ones too, if you like), or one from its menu, as a file to import into another ITFlow or keep. Each goes with what it says, the ticket categories it is offered on and what it sets up, and its files unless you leave them out. Importing brings them in shared, matches the categories by name and makes the ones missing, matches the status after the reply by name, and for one already here by name leaves yours, adds the file's as well under a name of its own, or replaces yours with it - keeping how often yours has been used. What comes in is made safe, and a file of a kind a ticket doesn't take is left out and said.
    • Starter Content has a Canned Responses pack for an MSP: received, waiting on you, following up, resolved, closing with no reply, a remote session, an onsite visit, escalated to the vendor, an internal handover, billable work, password reset, multi-factor sign-in set up again, account unlocked, licence assigned, phishing reported (phishing or safe), a possible security incident, a new starter ready, a leaver done, warranty repair, printer fixed, file restored, a monitoring alert cleared, internet down with the provider, voicemail PIN reset, a quote sent and an invoice question. Each is filled in from the ticket, sets the reply up (who sees it, the status after it, time worked), and the ones for one kind of ticket are offered on its category. Update brings the ones you added before up to the current version.
    • The New Canned Response button's menu has Import, Export, the report and the starter ones.
  • Document templates (rebuilt)
    • Admin > Document Templates lists what each is for, how many documents have been made from it and for how many clients, when it was last used and when it last changed, and by whom - searched by name, summary or what it says, sorted from the column headings, a page at a time, with Active and Archived tabs.
    • A template's page shows what it says, with its words in square brackets marked - filled in from the client, a table of records, or asked for - who made it and last changed it, its images, and the latest documents made from it with their client. New document for a client, from there or the list, makes one for the client you pick and opens it.
    • Filled in from the client: [Client], [Website], [Site], [Address], [Site Phone], [Contact], [First Name], [Contact Email], [Contact Phone], [Agent], [Date], [Month], [Quarter] and [Year] in a template's name, summary and what it says are filled in when a document is made from it. The site and contact are the client's primary ones, unless you pick another - or none, to leave them to fill in - when you make it. One with nothing to put in (a client with no website) stays as it is.
    • Tables of records: [Contacts], [Locations], [Assets], [Servers], [Workstations], [Network Equipment], [Printers], [Networks], [Domains], [Certificates], [Licenses], [Vendors] and [Services] put in the client's records as they are when the document is made. On a line of its own, it becomes a table - [Networks] gives each network's VLAN, network, gateway, DHCP range and site, [Servers] each server's operating system, IP address and site, [Domains] each domain's registrar and expiry - with an empty row to fill in when the client has none. In a sentence, it becomes their names. The template's page lists the ones it uses and what each holds, and the template window has a button for each placeholder and table to put it in.
    • Fill-ins: any other word in square brackets in a template's name, summary or what it says - [Firewall Model], [Review Date] - is asked for when a document is made, and the answer goes in wherever it is. On the template's page each fill-in can be made a date, a choice from a list or yes or no, made required, given a line of help, renamed (it is renamed in the template too) and dragged into the order it is asked in. One left blank stays for whoever writes the document.
    • New document for a client and Document from template show what the template fills in from the client, the site and contact to use, and its fill-ins, as soon as the template and client are picked. A copied template keeps its fill-ins.
    • Who should have one: a template can be required of every client, or of the clients with a client tag you pick (Managed, say) - leads and archived clients don't count. Its page shows how many of them have a document from it, with a bar, and lists the ones missing one, each with a Make button. Admin > Document Templates shows how many have one on each, and a Required tab lists them.
    • Several clients at once: New documents for several clients, from the template's page or the list, makes one for each client picked - or for every client missing one - each filled in from its own client with its primary site and contact, the fill-ins answered once for all of them. Clients that already have one are skipped unless you say otherwise.
    • A client's Files lists the documents it should have across the top, each a click from making it, and its overview counts them under Documents missing. Document from template lists them first.
    • Reviews: a template can say its documents are reviewed every 3 or 6 months, every year or every 2 years. A document is due once it hasn't been edited or marked as reviewed for that long. It says so on its page - Review due, or when the next review is and who last reviewed it - with Mark reviewed. Files marks the ones due and has a Review due tab listing them from every folder, the client's overview counts them, and the template's page and the list show how many are due. An archived template's documents aren't counted.
    • A document can be linked to the template it counts as made from, from its page - one written before the template, say - so it counts towards who has one and is reviewed as the template says. What it says stays as it is.
    • Export and import: Admin > Document Templates exports every current template, the ones picked, or one from its page, as a file to import into another ITFlow or keep. Each goes with its summary, what it says and its images, its fill-ins and how often its documents are reviewed - not who should have one, which is each install's own call. Importing, one already here by name is left as it is, added as well under a name of its own, or replaced - keeping its documents and who should have one. A new one comes in required of no one.
    • The starter document templates on Maintenance > Starter Content are rebuilt - fourteen of them, with Asset Inventory and Vendors and Escalation new - filled in from the client, with its sites, networks, equipment, servers, domains, licenses, vendors and contacts as tables, asking for the rest (a circuit type from a list, a start date, a required hostname), and the ones that go stale reviewed every 6 or 12 months. Who should have each is left to you. Update brings the ones added from an earlier version up to this one.
    • A Document Templates report (Reports > Clients & Documentation): for each template, the documents made from it in the period against the period before, its current documents and the clients with one, how many of the clients it is required of have one, and its documents due a review; and by client, the templates each should have a document from, the ones missing and the documents due. For one client, or the documents one agent made, and exported by template or by client.
    • Templates can be copied (with their images), archived - not offered for new documents, while the documents made from them stay - restored and deleted. A document made from one remembers it, and its page says which template it came from.
    • The template window has its icons, a summary, and Draft it with AI when an AI model is set up for documentation. A draft uses the placeholders and tables of records, and puts what else is to be filled in in square brackets.
    • Document from template in a client's Files shows each template's summary and how often it has been used. A document made with no name or summary given takes the template's.
  • License templates (rebuilt)
    • Admin > License Templates lists what each is, its type and license type, how many licenses have been made from it and for how many clients with their seats in use against bought, when it was last used and when it last changed, and by whom - searched by name, version, summary, type or notes, sorted from the column headings, a page at a time, with Active and Archived tabs.
    • A template's page shows what a license made from it starts with - name, version, type, license type and notes - the licenses made from it with their client, seats and renewal, how many are renewing in the next 30 days or past their renewal date, and who made it and last changed it.
    • New license for a client, from the page or the list, makes one for the client you pick with its seats, key, purchase and renewal dates and order reference, and takes you to the client's Licenses.
    • Templates can be copied, archived - not offered on New License, while the licenses made from them stay - restored and deleted.
    • The template window has its icons, a summary and room for the notes every license from it should carry.
    • A license started from a template on New License remembers it, and its quick look says which template it came from.
    • A template can carry the seats a license starts with, how often it renews, its vendor - picked from the vendor templates - and the product in Products & Services it is sold as. The page and the list show them, and the list finds a template by its vendor or product.
    • A license made from one starts with its seats, and its renewal date is worked out from when it was bought - a year on for a yearly one, or from today when there is no purchase date. Its vendor is the client's own one made from the vendor template, or one of that name; a client without it has it added from the vendor template, by anyone who can add vendors.
    • On New License, picking a template fills in its seats, vendor and renewal date as well, and the renewal date follows the purchase date until you type one in. A vendor you picked yourself stays.
    • Renew starts on the template's term for licenses made from it.
    • New licenses for several clients at once, from the template's page or the list: pick the clients, skip the ones that already have one, and give the seats, dates and order reference once.
    • A license's quick look shows the product it is sold as, with its price, to anyone who can see Sales.
    • Who should have it, on the template's page: every client, or the clients with certain client tags (leads and archived clients don't count). The page shows how many of them have it and which are missing it, each with Make, and Make for the clients missing one picks them all. The list counts it on each required template and has a Required tab.
    • A client's Licenses shows the licenses it should have and hasn't, each starting New License from its template, and its overview counts them under Licenses missing.
    • The template's page lists its licenses by client with their seats, renewal and how they are billed, with views for over seats, spare seats, renewing soon, expired and not billed. Each view has its own action - True up these, Renew these, Bill these - and licenses can be ticked for a bulk Renew, True up seats or Bill seats.
    • True up seats sets each license's seats bought to what is on it now, or to a number you type.
    • Bill seats puts a line for the product it is sold as on each client's recurring invoice, counting the license's seats bought or in use, so it follows them from then on - on a recurring invoice billed as often as the product, where the client has one. The Billed column shows each one's recurring invoice, a line typed in by hand that isn't its seats, and a paused recurring invoice.
    • Export and import: Admin > License Templates exports every current template, the ones picked (archived ones too, if you like), or one from its page or its row, as a file to import into another ITFlow or keep. Each goes with what a license made from it starts with, its vendor template - never its account number or code - and the name of the product it is sold as; not who should have it, which is each install's own call. Importing, its vendor is the vendor template of that name, added from the file when there isn't one, and it is sold as the product of that name when there is one - prices are yours, so no product is added. One already here by name is left as it is, added as well under a name of its own, or replaced - keeping its licenses, who should have it and, unless the file's product is here, its own product. A new one comes in required of no one.
    • Starter Content has a License Templates pack: Microsoft 365 Business Basic, Standard and Premium, Exchange Online, Google Workspace, Bitdefender GravityZone, ESET PROTECT, Proofpoint Essentials, Bitwarden Teams, a FortiGate security bundle, Veeam Backup & Replication, Veeam Backup for Microsoft 365, Acronis Cyber Protect Cloud, Synology Active Backup, Adobe Acrobat Pro and Creative Cloud, QuickBooks Online, Zoom and Windows Server with its CALs. Each comes with its type, how it is licensed, how often it renews, notes on how it is licensed and assigned, and its vendor, and is sold as the product of the same name once the Products & Services pack is here. Who should have each is yours to set. Update brings the ones you added up to the current version.
    • The New License Template button's menu has Import, Export, the report and the starter ones.
    • Reports > License Templates: for each template, the licenses made from it in the period against the period before, its current licenses and clients, their seats in use against bought - over and spare - the ones renewing in the next 30 days or expired, how many of the clients that should have one do and, for anyone who can see Sales, how many are billed and what that comes to a month. By client too: the licenses each should have, the ones missing, licenses over seats, renewals and the ones not billed. One client can be picked, and each can be exported.
  • Vendor templates (rebuilt)
    • Admin > Vendor Templates lists what each is, its support line, hours and SLA, its support site and email, how many clients have a vendor made from it - and whether it is one of your own - when one was last added and when it last changed, and by whom. Searched by name, what they are, support line, email, site, hours, SLA, account manager or notes, sorted from the column headings, a page at a time, with Active and Archived tabs.
    • A template's page shows what a vendor made from it starts with, its support line a click to call, the clients with a vendor from it - each with its own account number, or marked when it has none yet - a click from their vendor, the license templates that take their vendor from it, who made it and last changed it, and its history. Add to clients is on the page and the list.
    • The template window has the vendor window's tabs - Details, Support, Account and Notes - with their icons. The audit log says what changed on the template.
    • Clients' vendors are kept in step with the template they were made from - its name, what they are, the support line, hours, SLA, support email, site and notes. Their account number, PIN and account manager are always each client's own. Case, spacing, and a site written with https:// or www. don't count as a difference, and a field the template says nothing for never does.
    • Saving a template changes each field on the vendors made from it that still said what the template said - the one a client has its own for is left alone. A field taken off the template is taken off no vendor. Switch off Change the vendors made from it too to change only the template. Each vendor's history says what changed.
    • A template's page shows each client's vendor as in step, or the fields it differs in, with All, Out of step and No account number views. Bring in line shows one vendor's fields beside the template's, or for several - ticked, or Bring these in line on Out of step - how many differ in each, and takes the fields ticked from the template. A vendor can be unlinked from its template there, or from its own page.
    • Not linked yet on a template's page lists vendors made by hand with its name, or a support site on the same host, at clients without one from it, to link one by one or all at once - their details left as they are, or brought in line too.
    • Who should have it, on a template's page and in the list's menu: no one in particular, every client, or the clients with one of the client tags picked. The page shows how many of them have it, with the clients missing it to add it to - one at a time or all at once, picked for you in Add to clients. Leads, archived clients and archived vendors don't count.
    • The list has Required and Out of step tabs, and shows how many of the clients a required one is for have it, and how many of its vendors are out of step.
    • A client's vendor says which fields differ from its template, with Update from template, and has Unlink from template in its menu. One made by hand that looks like a template - the same name or support site - offers Link to template.
    • A client's Vendors list the vendors it should have and doesn't, each a click from New Vendor started from its template, and its overview counts them as Vendors missing.
    • A status page and a support portal on vendors and vendor templates - where the vendor posts outages, and where cases are opened. A vendor's page has Status page beside Support site, and its quick look, the vendors list and the tickets it is on link its status page, to see at a glance whether it is them. Vendors made from a template are kept in step with both.
    • A template keeps the account manager's line and email too, for a rep every client shares. A vendor made from it starts with them, but they are never changed on it afterwards - nor its account number or PIN.
    • Export and Import on Admin > Vendor Templates, and Export on a template's page and menu - a file to copy templates to another ITFlow, keep or share. Never an account number or PIN, and the account manager only when asked. A template already here by name can be left alone, added as well or replaced - replaced, it keeps its account number, PIN and who should have it, and the vendors made from it that still said what it did change with it.
    • Maintenance > Starter Content: the starter vendor templates come with their support site, status page and the portal cases are opened in where they have one, and what support asks for - the tenant, the service tag, the serial number. Update brings the ones added before up to date: only those fields change, and the vendors made from them that still said what they did change with them. Phone numbers, hours and SLAs differ by region and contract, so they stay yours.
    • A license template's file carries its vendor's status page and portal.
    • Reports > Vendor Templates: for each template, the client vendors added from it in the period against the period before, the clients with one, how many of them are out of step with it or have no account number yet, and how many of the clients that should have one do - with, for anyone who can see Support, the licenses, assets and cases on its vendors, and for anyone who can see the books, what was spent with your own vendor from it. By client too: the vendors each should have, the ones missing, its vendors out of step or without an account number, and its open cases with them. One client can be picked, and each can be exported. How they are used, on Admin > Vendor Templates and a template's page, opens it.
    • Templates can be copied, archived - not offered on New Vendor or Add to Clients, while the vendors made from them stay - restored and deleted. Deleted, its vendors and the license templates that used it stay, no longer linked to it.
    • Make a template from it, in the menu of a client's vendor for an admin, starts a template from that vendor - leaving its account number, PIN and account manager, which are the client's own - and links the vendor to it, so other clients can have the same one.
    • A vendor made from a template links to it from its page, for an admin.
  • Assets
    • The asset list has:
      • type icons, colored status pills and OS icons
      • Web, SSH and RDP connect buttons, warranty warnings and rack position
      • type tabs with counts, including a Printers tab, tag search and more sortable columns
    • The asset page has a header with the photo, status, tags and connect buttons, then Network, Hardware, Where & who and Quick notes cards. Related records sit on tabs.
    • Add, Edit and Copy share one form in tabs (Details, Assignment, Network, Purchase, Login or History, Notes), with a DHCP switch and photo removal.
    • Network interfaces have their own form:
      • the IP address is checked against the network, with next free IP and VLANs
      • one port can be connected to another, and any interface can be made primary
      • switches, firewalls and access points get a port map, and a whole run of ports can be added at once
      • interfaces can be imported and exported with their VLANs and connections, and edited in bulk
  • Racks
    • The rack page shows the front and rear of the rack to scale. Click an empty U to add something there.
    • Items can be:
      • mounted front or rear, at full or half depth
      • non-asset items: patch panel, cable management, shelf, PDU, UPS, KVM or blanking panel
      • edited and moved
    • Space and power used are shown, along with the largest free gap. Racks can have front and rear photos, number U1 from the top or the bottom, and have a print view.
    • A Not racked yet list shows what still needs a place. Racks also appear under All Client Docs, and an asset's page shows where it is racked.
  • Contacts
    • The contacts list has role tabs with counts, avatars, role pills, and copy on phone, mobile and email. Location changes in place, and there are chips for linked assets, credentials, licenses and tickets, plus a tag filter.
    • The contact page has the same layout as the asset page, with reach details, notes and tabs for everything linked to them.
    • Add and Edit share one form in tabs (Details, Roles, Access, Notes), with role switches, a password generator, a duplicate email check and photo removal.
    • A contact's avatar now shows everywhere they appear, in the same color throughout. Ticket contact pickers show their title and role under the name.
  • Files and documents
    • Files has:
      • a folder tree with counts, and All, Documents and Uploads tabs
      • color-coded file type tiles, with shared and portal markers
      • search across every folder that shows where each result lives
      • a grid view with real previews (image thumbnails, the first page of a PDF, text excerpts), and a previewer you move through with the arrow keys
    • Drag files onto the list to upload them into that folder. Move, archive, restore and delete files and documents together in bulk.
    • The document page has Edit, Share, PDF, Print, Rename, Move, Link, client portal visibility, Archive and Delete. Its History card lets you view, restore or delete versions, and restoring keeps the current content as a version.
    • Files have a quick look with a preview, the folder, who uploaded it, sharing and links.
    • The upload window has a drop box, a file list and size checks, and it tells you about any file it skipped.
    • Documents and files can now be linked to locations and vendors.
    • Quote and ticket attachments show their file type. The client portal documents list shows the folder, summary and last updated date.
  • Licenses
    • The license list has Renewing soon, Expired, Over seats and No seats assigned tabs. Each row has a seat bar (red when over) and a color-coded renewal date, and you can Renew, Archive, Restore and Delete in bulk.
    • The license page has the key, order reference, vendor account and support details, all copyable. Seats, Keys, Credentials, Documents, Files and History are on tabs.
    • Individual keys can be pasted one per line and handed out to devices and users. A key goes back to the pool when its device or user comes off the license.
    • The Renew window, for one license or many, moves the renewal date by a term or to a set date, previews each new date, and records the new order reference.
    • Renewal Ticket opens New Ticket already filled in. The New form has the template picker built in.
    • Asset and contact pages list their licenses with the key to install. The client overview flags licenses over seats, and search finds licenses by name, vendor, order reference or key.
  • Locations
    • The location page has:
      • Directions and Call buttons
      • New ticket, contact, asset, network, rack, document, upload or trip, already set to that location
      • Move here, to move contacts, assets, networks and racks to it in one go
      • Getting there, Ask for and Access notes that save when you click away, and tabs for everything at the location
    • The locations list puts the primary location first, with a photo or map pin, directions, phone and hours, the on-site contact and counts of what is there.
    • The form has tabs, and a client's first location becomes primary by itself.
    • The export adds the primary flag, on-site contact, counts and access notes.
  • Vendors
    • The vendor page is built around calling them:
      • Call support dials the extension, alongside Email, the support site and a star
      • a New menu for a case with them, a license, an asset, a portal login, a document, an upload, or an expense for your own vendors
      • Support and Account cards, with the PIN hidden until you show or copy it, and the account manager's direct line and email
      • Also used by, listing your other clients with the same vendor
      • tabs for Cases, Licenses, Assets, Domains, Services, Logins, Documents, Files and History
      • spend this year, last year and in total for your own vendors
    • The vendors list puts starred vendors first and shows the support line, hours, SLA, account number and PIN. An Ours / Clients' switch is included, and All Client Docs has a view across every client.
    • Starting a vendor from a template fills in every field. Add to Clients rolls a template out to many clients at once, and Admin > Vendor Templates shows how many clients use each one.
    • Vendors can be tagged.
  • Credentials
    • The list has:
      • username copy and password show and copy, and a live 2FA code fetched when you ask for it
      • where each one opens and what it is for, plus password age and when it was last used
      • filters for Starred, With 2FA, No 2FA, Not changed in 90 days and Shared
    • The form has a password generator (passphrase or random) and a strength meter. The 2FA secret takes spaces or an otpauth:// link, and shows the current code so you can check it.
    • Change password dates and logs the change. Share is in each row's menu and the quick look (see Shared links), and Ask the client for a login is in the list's top menu.
    • The client overview lists passwords due for a change, and the rotation report lets you pick the time span.
  • Domains
    • The domain list has:
      • expiry pills (red when expired, amber when close, blue when close but set to auto-renew)
      • the registrar and hosts, and SPF, DMARC and DKIM pills
      • filters for Starred, Expiring in 45 days, Expired, No auto-renew and Email gaps
    • The domain quick look explains the email checks in plain words. It also has A, NS and MX records to copy, the registrar and host logins, and the raw WHOIS.
    • You can add several domains at once from a pasted list. A pasted web address is tidied down to the domain, and the name is checked as you type.
    • Bulk actions can look up now, set the registrar or host, tag and star.
    • The background check now looks at up to 50 domains a run, soonest to expire first. Expiry alerts mention auto-renew.
    • The client portal shows days to expiry and auto-renew.
  • Certificates
    • Check now connects to the host and records what it serves: the issuer, the dates, the names covered and the fingerprint. The result is OK, name mismatch, untrusted, expired or unreachable.
    • The form has a Check button to see what the host serves before you save. For a host ITFlow cannot reach, paste the certificate or enter the issuer and expiry by hand.
    • The list has All, Starred, Expiring soon, Expired, Problems and Not checked views, and can check in bulk.
    • Certificates valid for 100 days or less, such as Let's Encrypt, renew themselves. They only count as expiring soon inside 20 days, and skip the 45-day alert.
    • Certificates are checked daily, with a notification when a good one goes bad. The client overview has a Certificate problems box.
  • Services
    • The services list has Needs attention, Review due, High importance, No review set, Starred and Archived views. It shows what each service depends on, and has bulk actions.
    • The service page covers what it is, backup and recovery, who to call, and notes. Tabs list the assets it runs on, logins, domains and certificates, documents, tickets and history.
    • Services can have a review schedule. The Review window has a checklist, and reviews due show on the dashboard and calendar, and in the nightly notifications.
    • A service needs attention when a domain or certificate it uses is expiring, a certificate check fails, a device it runs on is out of warranty or out of action, or something linked to it has been archived.
    • Report a problem opens a ticket linked to the service, and tickets can be linked to services from either side.
    • Services can be archived instead of deleted.
  • Calendar
    • The calendar only loads the dates on screen. Each calendar can be shown or hidden, along with scheduled tickets, project dates, service reviews, recurring tickets, renewals and invoices due.
    • You can filter to one agent, open a quick look by clicking an item, and drag tickets and events to reschedule them. A To schedule panel lets you drag open tickets onto the calendar.
    • Events can:
      • repeat every few days, weeks, months or years, with an end date and single dates skipped
      • have a client site, a location or a meeting link
      • invite agents, contacts and guest emails, with invitations, updates and cancellations sent as real calendar invites
      • be downloaded as a calendar file, or have the invitation sent again
    • Every agent has a personal subscription link for their phone or mail app, with setup help for Google, Outlook, Apple, Nextcloud and Thunderbird.
      • It can show their own schedule or the whole team's, with a busy-only option.
      • It follows their role and client access, and stops working when the account is disabled.
    • Repeating events keep their time across daylight saving changes in subscriptions and invites.
    • The ticket schedule window shows the assigned agent's day.
  • Networks
    • The networks list has type tiles and filters, usage bars, and the subnet, gateway, DNS and DHCP at a glance. Search finds the network an IP address belongs to.
    • The network page has an address plan:
      • documented addresses and device interfaces merged into one list
      • free ranges with a Use button, plus the DHCP range, gateway and next free IP
      • flags for conflicts, addresses in the DHCP pool, MAC mismatches, held-back addresses and addresses outside the subnet
      • a notice when the network overlaps another one, and WAN provider details
    • The form guesses the type from the name and shows live subnet details, with gateway and DHCP suggestions and an overlap check.
    • Documented addresses can be fixed, a DHCP reservation with its MAC, held back or floating. Up to 256 can be added as a range, and import and export include the type and MAC.
  • Users and your account
    • Admin > Users has:
      • Users, Admins, No MFA, Disabled and Archived views, with a role filter
      • status and MFA pills, remembered browsers and a client access summary
      • the last sign-in with its device and IP, and a count of agent seats
    • Bulk actions can change role, require MFA, forget remembered browsers, activate and disable.
    • New and Edit User share one form in tabs: Details, Sign in and Client access. It has generated passwords, a picture preview, and Only or Never rules per client with search and Set all.
    • Archiving a user hands their open tickets to another agent. Restoring asks for a new password. The export has a role filter and MFA, last sign-in and client access columns.
    • Your account is split into Profile, Security, Preferences and Activity.
      • The MFA QR code is drawn on the page itself.
      • You can see your remembered browsers and forget one or all of them.
      • You can set how many rows a list shows per page.
  • Trips (rebuilt)
    • Along the top of the list: the miles this month against the same days of last month, the year so far, your own this month, and what is owed for driving their own vehicles (with no mileage rate set, how much of the year's driving was for clients).
    • Pills cut the list to your own trips, trips for a client, trips for a ticket, our own errands, to pay back and to bill, each with a count. Filter by driver, client and date. Search finds what it was for, where from and to, the client, site, driver and ticket number. The total of everything the filters leave sits under the list.
    • Each row shows the route with the ticket and what it was for, the client and their site, the driver as an agent pill, and the miles with marks for a round trip, their own vehicle (paid back or not) and billing (billed or not).
    • Clicking a trip opens its quick look:
      • the route, with Directions in Google Maps from where it started, and the miles each way for a round trip
      • the driver, the client, their site and the ticket, each linked
      • the odometer readings, and who logged it and when
      • how many trips have gone to that site, opening them
      • what it pays back or was paid, and what it bills or was billed on, with Pay Mileage and Take off Invoice
    • From the quick look or a row's menu: Edit, Copy (dated today), Return Trip and Delete. Return Trip is the way back, from there to where it started, one way and on the same day.
    • New Trip, Edit, Copy and Return Trip are one form with an icon on every field:
      • Pick the client to choose from their sites and their tickets (open ones, and those done in the last 60 days). A site fills in its address as where to. A ticket fills in what the trip was for, and its site.
      • From starts where your last trip started, and offers the places you start from most.
      • Enter the miles one way and switch Round trip on or off. The form shows what the whole trip comes to.
      • A route you have driven before fills in its miles.
      • Odometer readings are optional. With both, the trip is what the odometer says.
    • Tickets: Log a trip in the ticket's menu starts with its client, site and subject. An onsite ticket, or one with trips, has a Trips card listing them with the miles driven for it.
    • Locations: the location page has a Trips tab with the trips out to it and Log Trip.
    • The Mileage report shows each trip's ticket and site under its purpose, a trip's date opens it, and the CSV has Site and Ticket columns. Its rate starts at the mileage rate below, and it is in miles or kilometers as set.
    • Bulk actions set the driver, the client, the vehicle or billable on the ticked trips, pay one driver's mileage, or delete them. Changing the client takes off the old client's site and ticket.
    • Export takes the same filters as the list, with Driver, Round Trip, One Way, Site, Ticket, the odometer readings, Vehicle, Paid Back and Billing as columns.
    • Trips follow their ticket. Merging a ticket with its replies moves its trips to the ticket it goes into, and moving it to another client takes its trips along. Deleting a ticket or a location keeps the trips, without the link.
    • Admin > Settings > Invoice has Trips and Mileage:
      • miles or kilometers, for every trip, rate and report
      • what an agent is paid back for each mile in their own vehicle, such as the IRS standard rate, and the expense category it goes under
      • what a trip bills a client: a trip charge, a rate a mile, and how far the charge covers before the rate starts
      • the name of the invoice line
    • Paying agents back for their own vehicle:
      • The form has In their own vehicle, starting as the driver's last trip was, and shows what the trip pays back.
      • The list's To pay back pill and card show what is owed (Owed to you, for an agent without Financial access), with each agent and what they are owed above the list.
      • Pay Mileage pays one agent for the trips they tick, at the usual rate or another for trips from before it changed. Each client's trips become a mileage expense the agent paid personally, under the mileage category and to the vendor Mileage (made the first time), so each client's costs include the driving. Pay it back now pays them from an account there and then; otherwise the expenses wait in To reimburse with anything else they paid for.
      • Once paid for, a trip keeps its distance, driver and vehicle, and can't be deleted. Deleting the mileage expense makes its trips to pay back again. The expense's quick look lists the trips it pays for.
    • Billing trips to clients:
      • The form has Bill the client for it, starting as the ticket is billable, and shows what the trip bills.
      • The list's To bill pill shows the trips waiting. Unbilled Time has Travel to bill beside the expenses, each at its price to change, going on the client's invoice with their tickets and expenses, a line each. Billing every client at once takes their trips too, and Not billable takes a trip off the list.
      • The invoice has a Travel card with the trips on it, Add for the client's trips waiting, and Take off Invoice. Deleting the line or the invoice makes a trip to bill again.
      • 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.
    • Import Deposits, from the Income list's menu, brings the money in on a bank statement in as payments and income:
      • Upload the CSV your bank exports, and pick the account the deposits went into and how clients usually pay. It is the same file Import Statement takes for expenses, read the same way, and the columns are kept on the account for both: a statement already brought in for its expenses goes straight to the review, and either side can go on to the other without uploading it again. Only the money in is brought in here.
      • Each deposit is matched to what it is:
        • an invoice number in its text or reference ("MOBILE DEPOSIT INV-1042")
        • what the same payer was last time - a client's payment, other income in its category, or a transfer
        • a client's name in it ("ACH CREDIT RAVENWOOD DENTAL GRP")
        • the only invoice owing exactly that much, the oldest of one client's invoices owing it, or everything one client owes together
        • a payout from the payment provider, which comes in as a transfer from the account its online payments go into, and a transfer from your savings or another of your accounts
        • anything else is other income, for you to give a category and the client it came from
      • A payment pays the client's invoices the oldest due first. One check for several invoices pays them all, and a deposit short of what they owe leaves the last one part paid. Pick the client and the invoices it pays are ticked for you - the one owing the amount, the ones that add up to it, or the oldest first - and each deposit says what it pays off, in full or part paid, or how much more than they owe it is. Two deposits on one invoice don't pay it twice.
      • Deposits brought in before are locked, so importing the same statement again brings nothing in twice. A deposit with the amount of a payment, other income or transfer entered by hand on that account within 4 days - or of a day's payments together, one check entered as a payment on each invoice - is left out unless ticked, and is linked to what was entered so the next statement knows it. A payout into the account the payment provider's payments go into is recorded already, so it is left out.
      • Import brings in the ticked deposits as payments (each invoice paid or part paid, with its history saying so), other income and transfers, and opens the Income list on that account and those dates. Email each client their receipt sends the usual payment receipt for each invoice paid. Every ticked deposit is checked first, so nothing comes in until they are all right.
      • How it was paid is read from the text - ACH, a check, a wire - when one of your payment methods fits, or the one picked on upload.
      • The payers are remembered for next time - see or forget them from the Import Deposits page.
      • A payment or other income brought in this way says so on its quick look.
      • Payments need write access to Sales, as adding one on an invoice does. Without it, deposits can still come in as other income and transfers.
  • Accounts and transactions (rebuilt)
    • Each account has a kind - checking, savings, credit card, cash, payment processor or other - with its own icon wherever the account is picked. A credit card's balance runs below zero as it is spent on, and shows as what is owed on it ("$1,240.00 owed") on the Accounts page, its ledger, the dashboard and every account picker.
    • Along the top of the Accounts page: what the accounts hold (cards aside), what is owed on cards, and the money in and out this month against the same days of last month. Money moved between your own accounts doesn't count as in or out.
    • Each account shows its description and the defaults it carries (payments go in, expenses paid from, transfers from or to, or a payment provider's payments), its kind and currency, what it holds with its opening balance, what came in and went out this month, and its latest line with how many there are. Its menu has Transactions, Income into it, Expenses from it, Edit, Pay the Card for a card or Transfer from it otherwise, and Archive once it holds nothing. The Archived pill lists archived accounts, each with Restore, and Delete for one never used.
    • New Account and Edit are one form with an icon on every field: the name (unique among open accounts), the kind, the currency, the opening balance, a description and notes. The opening balance can be changed later, and the change is logged. The currency is locked once money is recorded on the account. For a card, an opening balance below zero is what was owed on it.
    • Transactions is each account's ledger. Pick an account at the top of the filters, or Every account:
      • The cards show what it holds now (or owes, for a card), the money in and out over the dates picked, and the difference, with what it held at the end of those dates.
      • Every line has its date (opening its quick look), what it was - the invoice paid, the other income, the vendor, the transfer's other account or the agent paid back - with its kind, how it was paid, the reference and a mark when it came from a bank statement, then the category, the client, In, Out and the balance after it. The balance shows for one account sorted by date. With Every account, an Account column takes its place.
      • Pills for Money in, Money out and Transfers, each with a count. Filter by the date, category and client, and under More filters by the kind of line, how it was paid and an amount range. Search finds what it was, the description, the reference, the category, the client, a transfer's other account, how it was paid, or an amount. The totals in and out sit under the list.
      • Export takes the same filters and pills, for one account or every account.
    • Transfers keep their place in the menu, which opens the Transfers pill for every account. Each transfer shows once there, from the account it left, and the cards show what was moved, what paid off cards and what payment processors paid out.
    • New Transfer, Pay the Card and Edit are one form: where it comes from and where it goes, each with what it holds now, a Swap button to turn it round, the amount in the currency of the account it leaves, the date, how it was moved and notes. Every field is checked when it is saved: two different open accounts, an amount, a real date and one of your payment methods. Editing moves both sides together.
    • A transfer into a credit card is a card payment, and says so on the ledger and in its quick look. Clicking a transfer opens its quick look: what came out of one account and went into the other, with what each holds now and a link to its ledger, how it was moved, who entered it, notes, and Edit and Delete. A transfer's expense opens it too.
    • Deposits out of a cash account: a new transfer out of a cash account lists the payments and other income taken into it that haven't gone to the bank yet, oldest first, each with its date, what it is for, the client, how it was paid and the amount. Tick the checks and cash going to the bank, and the amount and notes fill in, until either is typed over. What is ticked goes with the transfer and isn't offered again. A transfer out of it with nothing ticked - every one made before the update, and any made since without ticking - counts as taking everything up to it, so only what came in after it is listed.
      • A deposit's quick look lists what was in it, and says so when they don't add up to the amount. The quick look of a payment or other income in one says which deposit it went in, opening it.
      • Deleting the transfer, or changing it to come out of another account, puts what it carried back to waiting. So does moving one of them to another account, with Edit or Bulk Set Account.
    • The dashboard's accounts open their ledgers, and a card shows what is owed on it.
    • Adding and changing accounts and transfers needs write access to Financial, and deleting full access, as before. Restoring an archived account needs write access.
    • The demo data adds a Business Visa for software, hosting, supplies, meals and the other small costs, paid off from Operating Checking each month.
    • Reconcile, from an account's menu on the Accounts page or Transactions, checks the account against its bank or card statement:
      • Enter the statement's ending date and balance (for a card, what the statement says is owed), with notes such as the statement's number. It starts from where the last statement ended.
      • The first statement on an account starts from the bank's balance or the opening balance:
        • From the bank's balance, enter the day the statement starts and the balance it starts from (for a card, what was owed then). Everything on the account before that day is brought forward as reconciled - cleared and locked as if a statement had been reconciled to the day before - so an account with years of history starts matching the bank from that statement on, without ticking its past. This is picked for you when the account has lines from before the statement's month.
        • From the opening balance, every line since the account was opened is listed to tick, as before.
      • Where ITFlow had the account at a different balance from the bank's on the day it was brought forward to, the statement says how far apart they were. Correct the opening balance moves the account's opening balance by that much, once, so ITFlow's balance matches the bank's from then on. Leave it, and the statements still reconcile from the bank's balance.
      • Brought forward shows in the history with everything before the day it covers, and what the opening balance was corrected by or how far ITFlow was off. While it is the latest and nothing is in progress, Undo takes it away, frees its lines and puts the opening balance back.
      • Every line on the account up to the statement's date that hasn't cleared yet is listed, oldest first. Tick each one the statement shows. Lines brought in with Import Statement or Import Deposits start ticked.
      • The cards show where it started, the statement, what is ticked (cleared) and the difference, and the bar at the bottom keeps the difference and Finish in view down a long statement. Finish opens once the difference is nothing.
      • Pills for Money in, Money out, Not ticked and Ticked, a search by who, reference or amount, and Tick all shown and Untick all shown for what they leave.
      • Ticks are saved as they are made, so a statement can be left and finished later. Change the statement's date or balance from its menu, or Discard it.
      • Add what the statement has and ITFlow doesn't - a bank fee, interest - with New Expense, New Income or New Transfer from its menu, starting on the account.
      • With nothing in progress, Reconcile shows where the account is reconciled to, how much hasn't cleared yet, and the statements reconciled before - each with its ending balance, the lines it cleared, what came in and went out, and who reconciled it when. The latest can be undone, which puts it back in progress with what was ticked.
    • On Transactions, a line cleared on a statement has a tick by its date, opening the lines that statement cleared - or saying it was brought forward. The Uncleared pill lists what no statement has cleared yet, and the export has a Cleared column with the statement's date. The Accounts page shows where each account is reconciled to, or that one is being reconciled.
    • A cleared line's quick look says which statement it cleared on, and its Edit window keeps its amount, date and account as they are (see Breaking Changes, Accounts, transactions and transfers).
    • Reconciling needs write access to Financial, and undoing a reconciled statement full access. Agents with read access can look at the statements.
    • The demo data reconciles Operating Checking and the Business Visa to each month's statement through last month.
  • Client portal (rebuilt: the look, home, tickets, billing, Your IT and accounts)
    • A new look: a light top bar with your logo, or your company's initials on your theme colour, and the sections the contact can open - Home, Tickets, Billing for billing contacts, Your IT for technical contacts, and your custom links - with New ticket and their account menu. On a phone the sections sit in a bar along the bottom and a menu that slides in. Contacts can switch to dark mode from their account menu, and it stays that way. The welcome banner along the top of every page is gone.
    • Home shows what needs the contact first: tickets waiting on their reply or resolved for them to confirm, tasks waiting for their approval and invoices past due. Then their open tickets, what is owed with Pay invoices, the domains, certificates, licenses and warranties renewing in the next 45 days or lapsed in the last 30, the devices they use with any warranty that has run out or is about to, and your phone and email when they are set.
    • Tickets has tabs for Open, Waiting on you, Resolved, Closed and All, each with its count, a search by subject or number, and 20 to a page. Primary and technical contacts see their whole company's, with who raised each, and Mine for their own.
    • A ticket shows how far it has got - received, in progress, resolved, closed - and the conversation oldest first, with the contact's own messages on the right and the files that came with each. Tasks waiting for their approval have Approve. Alongside are the priority, category, who raised it, who is working on it, the device and how many tasks are done.
    • Replies take files dropped onto the form or chosen, listed before they are sent. It's fixed marks a ticket resolved once its tasks are done, a resolved ticket asks them to close it or reopen it, and a closed one asks how you did.
    • With request forms offered to the contact (see Ticket templates), New ticket starts with a choice: Report a problem, or one of the forms. Home lists them under Ask for something, and a ticket raised from one shows its answers under Request details.
    • New Ticket asks how much it is getting in the way, what it is about and which device - their own, or any of their company's for primary and technical contacts - and takes screenshots and files. Alongside it is what happens next, and your phone number for anything urgent when it is set.
    • Invoices open in the portal, with tabs for To pay, Paid and All, each with its count, a search by number or scope, and what is owed - and how much of it is late - under the title. An invoice shows as it prints, with the amount due and when, Pay, Download PDF and Print, the payments made with a receipt for each, and the recurring invoice it came from. When autopay will pay it, it says which card and when. Opening a sent invoice marks it viewed, with the contact's name in its history.
    • Pay invoices: the contact ticks the invoices to pay - all of them, or just the one when they come from its Pay button - and pays them together as one card payment, with a saved card there and then or a new card on a checkout page. The total and the Pay button follow the ticks, and your Stripe threshold applies to the total. Each invoice gets a payment for its part with the same reference, the client gets one receipt listing them all, and your paid notification address one email.
    • Quotes has tabs for Waiting for you, Accepted and All. A quote shows as it prints, with what it comes to up front and each month, quarter or year. Accept and Decline open a window for the contact's name and a note for you - a PO number, when to start, what would change their mind - and both go in the quote's history, the notification and the email to your quote notification address. Accepting asks them to confirm they can accept it for their company. An accepted quote takes a signed copy or purchase order, and one that has run out offers to ask for an updated quote.
    • Payments lists every payment on the account, with one card payment over several invoices as one line. Its receipt shows what was paid, when and how, and each invoice it paid with what is still owed, ready to print.
    • Statement shows what is owed by how late it is - current, then 1-30, 31-60, 61-90 and over 90 days - with the open invoices, or everything from the last 12 months with the paid ones. Download PDF is the same statement you email.
    • A recurring invoice shows what it bills line by line, when the next invoice comes, its autopay card and the invoices it has made.
    • Cards & autopay: the saved cards, with expired ones marked and the recurring invoices each one pays, adding a card in Stripe's form straight after agreeing to it, removing one, and autopay for each recurring invoice, all on one page.
    • Home lists quotes waiting for an answer, and a past-due invoice opens that invoice. Ask us on an invoice, quote or recurring invoice starts a ticket with the subject filled in.
    • Pay invoices and Cards & autopay only show when Stripe is set up. Without it, an invoice says to use the payment details on it.
    • Devices has tabs by kind - Computers, Servers, Network, Phones & tablets and Other - each with its count, Everyone's or Mine, and a search by name, make, model, serial or who uses it. Each shows who uses it and where it is, with its warranty when it has run out or is about to.
    • A device shows its make and model, serial, OS, who uses it, where it is, when it was bought and how old it is, its warranty, and its open tickets and licenses. Report a problem starts a ticket with the device picked, and Open goes to its link when it has one. Any contact can open the devices they use from Home.
    • Licenses has tabs for All, Renewing soon (the next 30 days) and Expired, with how many seats are in use and when each renews. License keys are not shown.
    • Domains & certificates: the domains with their registrar, DNS, web and mail hosts, when each expires and whether it renews by itself, and on its own tab the certificates with their domain, issuer and expiry.
    • Documents lists the documents you share with the client, in tabs by folder, with a search that looks inside them. A document shows as written, with its tables and colours, and the files that came with it - a PDF or picture shown on the page, any file to open or download - ready to print, and Ask us about it starts a ticket. Primary and technical contacts can write a new document in the editor or upload a file (PDF, Word, OpenDocument, RTF or text), and you are notified of each.
    • Contacts lists everyone at the company, the primary contact first, with their roles, and a search by name, email, title, department or phone.
    • The primary contact manages their company's contacts: Add a person with their details, phone numbers and roles, and give them portal access with an emailed invite to set a password - or to sign in with Microsoft 365 when that is set up. A person's page shows whether they can sign in and when they last did, with Email the invite again, Turn off access and Left the company?, which turns off their access and starts an offboarding ticket. Each change is in the audit log, and you are notified when someone is added, given access, turned off or has left.
    • Home: the renewals open the domain, certificate, license or device they are about, and each device opens its page.
    • Your account: who the contact is to you, with Ask us to change them; their phone numbers; the emails they get; signing in - changing their password, two-step sign-in and the browsers it remembers; their phone PIN; and their recent sign-ins with the device and address. Activity has tabs for signing in and changes, 25 to a page.
    • Two-step sign-in: a contact who signs in with a password can turn on a code from an authenticator app - Microsoft or Google Authenticator, 1Password, Authy, Bitwarden - by scanning a QR code. Signing in then asks for the code, and can remember the browser for as long as your Remember Me setting. Five wrong codes in 10 minutes stop that account for a while. Microsoft 365 sign-ins are left to Microsoft.
    • Admin > Client Portal can require two-step sign-in: a contact signing in with a password who hasn't set it up is taken to set it up before anything else. On the agent's contact page, Turn off two-step sign-in is there for a lost phone.
    • Emails from us: a contact can have every ticket email, or replies and fixes only - not the emails saying a ticket was raised or closed. Primary and technical contacts can also hear about their company's other tickets: new ones (a short email with who raised it and a link), or new ones and every update, as a watcher would. Raised in the portal, by email or by an agent, while client notifications are on (Admin > Ticket).
    • Forgot password and the invite page are in the new look. A new password is typed twice, and the contact gets an email when it is set. Forgot password sends one email per account every 5 minutes, and answers the same whether or not the address has an account.
    • Announcements: Admin > Client Portal posts a notice at the top of the portal's home page - to every client or one - as a Notice, Heads up or Urgent, from when to when you choose. Web addresses in it become links.
    • View as contact: on a contact's page, View portal as them opens the portal in a new tab as they see it, read only - nothing can be sent, paid or changed, opening an invoice doesn't mark it viewed, and a strip along the top says whose view it is, with Exit. It needs read access to clients and support and to the client; billing shows only when your role reads sales and financial too. Each one is in the audit log.
    • The agent's contact page shows whether a contact signs in with a code too, when they last signed in, and the ticket emails they chose.
  • Taxes (rebuilt)
    • Admin > Taxes lists each rate with what it comes to, its parts, its registration number and what uses it: the invoices, quotes and recurring invoices with a line at it, and the products and clients that pick it. Active and Archived tabs, with what replaced a rate and when, Restore, Make default, and Delete for a rate nothing has used. Sort by name, rate or use from the column headings.
    • A rate can be made of parts, such as a state's 6% and a county's 1%, or GST and QST. A part can be charged on top of the parts above it. The whole rate shows as you type. Invoices, quotes, their PDFs and the client's views show each part on its own line in the totals, such as Pennsylvania 6% and Allegheny County 1%.
    • Rates go to four decimal places, such as Quebec's 9.975%, and lines and the Tax Collected report show the exact rate.
    • Each rate takes a description and a registration number, and each part can have its own number. The numbers a document charges under print with your company's details on the invoice or quote and its PDF, such as GST No. 123456789 RT0001.
    • Edit on a rate that has gone out on invoices or quotes says how many, and that a new rate or parts makes a new rate from now on, with a switch to move the draft invoices and quotes too.
    • How lines are taxed, on the same page: the default rate for new lines, whether billed time, trips and expenses billed to clients are taxed, and whether discounts come off before tax.
    • Each client can have its own rate, on the Billing tab of its Edit window, for where it is. New lines start on the client's rate, then the product's, then the default, whether typed in, picked from the catalog, or made from tickets, Unbilled Time, trips and expenses. A product set to no tax stays untaxed. Unbilled Time bills each line at its client's usual rate unless you pick one, and the ticket's Invoice and Quote windows, Add Tickets, Add Expenses and Add Trips start on it.
    • Tax exempt clients, with the certificate number and when it expires, on the Billing tab. Their new lines start untaxed, and their pages say Tax exempt, or that the certificate has run out. An open invoice, quote or recurring invoice that still has taxed lines for them says so, with Take the tax off. An invoice or quote with no tax on it says Tax exempt with the certificate number under the client's name.
    • The client overview's Terms shows the client's rate, or that it is tax exempt.
    • Tax Collected (Reports) is rebuilt for filing:
      • Invoiced counts each invoice once it is sent, in the month of its date. Paid counts each payment with its share of its invoice's tax and sales, so a part-paid invoice counts only the part paid.
      • Tax shows by part of each rate, with its registration number, so a state's and a county's tax show apart. Name a part the same on two rates and they add up together.
      • Sales, before tax and net of discounts, split into taxable, exempt (no tax, for a client marked tax exempt) and non-taxable (no tax, for anyone else).
      • The tax paid on purchases, by part, and the net tax - what was collected less what was paid - for GST, HST and VAT returns.
      • Exempt sales by client, with each certificate and when it expires, expired ones flagged.
      • By quarter or month, with a chart of each month's tax. Export the table, or the exempt sales by client, as CSV.
    • Expenses record the tax included in them: pick the rate and the tax is worked out from the amount as you type, or type over it with what the receipt says. A tax with no rate of yours can be typed in on its own. Money back gives its tax back. The list and the quick look show the tax, and picking a vendor fills in the tax of the last expense with them along with the rest.
    • Recurring expenses take a tax too, and put it on each expense they record.
    • Exports: invoices can add Subtotal, Discount, Tax and Tax by Part; quotes and recurring invoices Tax; expenses Before Tax, Tax and Tax Rate; and clients Tax Rate, Tax Exempt, Exemption Certificate and Exemption Expires. They are off until ticked.
    • API: clients take client_default_tax_id, client_tax_exempt, client_tax_exempt_number and client_tax_exempt_expires, and a new invoice line takes tax_id default for the client's usual rate. A new endpoint reads the taxes with their parts. Invoices and quotes add their subtotal, tax, tax by part and registration numbers, invoice lines name their rate, and expenses include their tax.
    • Demo data: both taxes have a registration number, Allegheny County Sales Tax is made of the state's 6% - named PA Sales Tax, so Tax Collected adds the two up together - and the county's 1%, each client has its rate, the church and the charter school are tax exempt, one with a certificate that has run out, and what the company buys for its own use pays sales tax.
  • Tags (rebuilt)
    • A quieter look, everywhere a tag shows: its color is the accent rather than the fill - a faint wash of it, a fine edge in it and its icon in it, with the name in the page's own text - so a row of tags reads as labels beside the record instead of drawing the eye from what is on the screen. Pale colors are taken a little darker to show on white, and dark ones lighter in dark mode, so every tag reads in both. The same look is on lists and record pages, Admin > Tags, the tag window as you change it, global search, the tag picker and every Tags filter. Pointing at a tag deepens it.
    • Admin > Tags lists each kind's tags - clients, locations, contacts, credentials, assets, vendors, domains, licenses, services, networks, documents and tickets, down the side with how many each has. Each tag shows as it looks on a record, with its description, how many records have it (and how many of those are archived), opening that list filtered by it, and the recurring invoice lines that count users or devices by it.
    • Search by name or description, sort by name or by how many records have each from the column headings, a page at a time, and Active and Archived tabs.
    • Each tag has Edit, Merge into..., a link to its records, Archive or Restore, and Delete, which asks first and says how many records lose the tag. Tick several for Merge into one, Archive or Restore, and Delete.
    • The tag window shows the tag as it will look while you change it:
      • its name, and what it is for, which can be changed while nothing has the tag yet
      • a description, shown when you point at the tag and in the tag picker, which searches it too
      • a color from the swatches, or any other from the color picker or typed as a hex value
      • an icon from the grid, which you can search, or any Font Awesome icon by its name
    • Merge moves every record with the merged tags onto the one you keep, once each, and the recurring invoice lines that count by them count by it. The others are deleted.
    • Record forms: an admin types a new tag's name into the Tags field and picks Add or presses Enter, and the tag is made, with the next color along, when the record is saved - nothing reloads and nothing typed is lost. Typing the name of an archived tag brings it back.
    • A recurring invoice line counting by a tag keeps it in its window once the tag is archived, or when the client no longer has anything with it, so saving the line doesn't drop it.
    • Licenses, services, networks, documents and tickets take tags, like the other seven kinds:
      • their lists show each one's tags with the + to change them (tickets show the + once a ticket has a tag), filter by tag and find tags by name in the search, and Bulk Action has Tags
      • their pages and quick looks show the tags, with the + to change them, and their New and Edit windows have a Tags field
      • their exports filter by tag and can add a Tags column
    • Files: documents take tags. Filtering by a tag shows the documents with it - from every folder at the top level, like a search - and no uploads. Bulk Action > Tags tags the documents ticked and leaves uploads alone.
    • Tickets: Tags is in the More filters, shown as a chip like the others, and the board's cards show their tags. Changing a ticket's tags is written in its history. Merging a ticket into another moves its tags over too, apart from archived ones.
    • Search finds records by their tags: searching VIP finds the clients, assets or tickets tagged VIP, with the tag next to each. A Tags group lists the tags that match, each in its color, opening its list filtered by it - or for a document tag, the search for it, as documents are listed one client at a time.
    • API: read the tags, one record's tags or the records with a tag, set a record's tags or add and take some off, and make a tag (admin keys only). Every record read now includes its tags.
    • Starter Content: the Tags pack has tags for licenses, services, networks, documents and tickets - We Resell, Managed, PCI, Runbook, Escalated and so on. Demo data tags its licenses, services, documents and tickets with them.
  • Categories (rebuilt)
    • Admin > Categories lists each kind - expense, income, referral, ticket, network interface, asset status, software type, rack type, and contact and asset note types - down the side with how many each has, and what each kind is for when you point at it.
    • Each category shows as it looks on records - its icon, or a dot, in its color, and an asset status as its badge - with its description and what uses it, such as "42 expenses · 3 recurring expenses · 24 budget months" (and how many of those are archived). Each opens its list filtered by the category where there is one: expenses, recurring expenses, invoices, other income, products, tickets, recurring tickets, and clients by referral.
    • Search by name or description, sort by name or by what uses each most from the column headings, a page at a time, and Active and Archived tabs. The kinds offered in an order of their own - interface types, asset statuses, software types, rack types and note types - list in that order first (in order, next to the column heading, puts them back in it), and are dragged into it, or moved with Move up and Move down - on any page, the first of a page moving up past the last of the one before.
    • Each category has Edit, Merge into..., Archive or Restore, and Delete while nothing uses it - otherwise Delete is greyed out and says what uses it. Tick several for Merge into one, Archive or Restore, and Delete, which keeps the ones in use and says which.
    • The category window shows it as it will look while you change it:
      • its name and a description, shown when you point at it
      • a color from the swatches, any other from the color picker or typed as a hex value, or none - an asset status with none keeps its built-in color
      • an icon from the grid, which you can search, any Font Awesome icon by its name, or none
      • what uses it, and for a kind records keep by name, that renaming it renames it on them too
    • Merge moves everything with the merged categories onto the one you keep, and the others are deleted: expenses, recurring expenses, budget months (a month budgeted under both becomes one line of their total), import rules, a payment provider's fees and the mileage setting, invoices, quotes, recurring invoices, other income, products, tickets, recurring tickets and canned responses - and for the kinds kept by name, the clients, assets, interfaces, licenses, license templates, racks and notes that have them.
    • Names that records have but aren't categories - notes typed General, referrals entered before there was a list - are listed under the kind's categories with how many have each, linked to them where there is a list, and Add makes one a category. To fold one into another, add it, then merge it.
    • Renaming or merging doesn't mark the records it changes as edited.
    • Every form that picks a category picks it the same way, from the categories in the order they are offered in, each with its color, its icon and its description underneath. A record keeps a category archived since it was picked, marked (archived), and a status, type or referral it has that isn't a category, marked (not set up), so saving it changes neither.
    • The + beside a category picker opens the category window over the form. What you make there is picked in the picker and the window closes, leaving the form as it was - nothing reloads and nothing typed is lost. A name that is taken is said in the window. It is on every form that picks one: expenses, recurring expenses, other income, invoices, quotes and recurring invoices (and a ticket's quote and invoice, and Unbilled Time), products, tickets and recurring tickets, a client's referral, an asset's status, an interface's type, a license's and a license template's type, a rack's type, a contact or asset note's type, payment providers, canned responses and the mileage setting - and their Bulk Action windows.
    • A referral typed into a client's form that matches one in another case takes its spelling.
    • A window opened over another shades the one underneath, rather than drawing its shade behind it.
    • Lists show a category the way the category window does - its icon in its color, or a dot of its color: expenses, recurring expenses, other income, transactions, tickets, recurring tickets, a ticket and its quick look, and the expense, recurring expense, payment and other income quick looks.
    • A list's category filter offers the categories its records have, rather than every one, in the kind's order with their colors and icons. One its records have that has since been archived is still there, marked (archived), and a referral clients have that isn't one, marked (not set up). This is on expenses, recurring expenses, income, invoices, products, tickets, recurring tickets, clients by referral, and their export windows.
    • New filters, each in the export window too: quotes and recurring invoices by category, assets by status, licenses by software type and racks by type. The assets tabs count within the status picked, and the licenses tabs within the software and license type.
    • The invoice, quote and recurring invoice exports have a Category column, and the asset export a Status column, to tick.
    • Admin > Categories links what uses a category to these lists filtered by it too: quotes, recurring invoices, assets, licenses and racks.
    • API: read the categories - every kind the key can see, one kind, one by id or by name, archived ones too - with how many records have each.
  • Roles (rebuilt)
    • Admin > Roles shows what each role opens at a glance - its level of each part of ITFlow in that level's color, or Admin - everything - and its agents, opening Users filtered to them. Search, sort by name, agents or when it was last changed, and Active and Archived tabs.
    • New, Edit and Copy are one window. Admin is a switch; otherwise each part of ITFlow - Clients, Support, Credentials, Sales, Financial and Reports - gets None, Read, Modify or Full, with a line for each level saying what it lets them do, ticked as you pick. Reports only has None and Read, all it uses. A change reaches the role's agents from the next page they open.
    • Copy starts a new role from another's levels.
    • Archive a role nobody has to stop it being given out, and Restore it. Delete one no agent has, archived ones included. Archive and Delete are greyed out with the reason when they can't be used.
    • The Administrator role can now be opened and renamed. No admin role can lose admin while it is your own, so nobody can lock themselves out of Admin.
    • Role names are unique, in any case, and the description is optional.
    • The audit log says what changed on a role: "Sales Read to Modify, Reports None to Read".
    • A page reads the agent's role once, where it used to ask the database again at every check - dozens of times on a busy page.
    • When a role doesn't allow something, ITFlow says so plainly - "Your role, Technician, can only view Support, not change it" - with how to get it changed: on a page, in a card where the page would be, with Back; in a window, in the window; after a button, back on its page. It used to show a bare line of text on a blank page, and a window it refused only said it couldn't load.
    • Agents aren't offered what their role can't do: the Clients and Assets bulk menus at Read, Delete among archived assets below Full, editing an asset's recurring tickets at Read, and links into a report for an agent who can't open it.
    • The role window warns when a role is given Support, Credentials, Sales or Financial with Clients at None, as each client's pages need Clients.
    • 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, and they sort by name, end date, agent or last use. Active and Expired tabs - a revoked key moves to Expired, where it can be deleted.
    • 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.
    • Create on the Details tab before ticking that the key and password were copied opens the Key tab, rather than doing nothing.
    • Revoke ends a key today. Delete is for expired keys, as before, one at a time or several from the bulk menu.
    • The audit log says who a new key acts as and when it ends, and what an edit changed: "renamed from RMM, expiring 2027-06-01 (was 2026-12-01), acting as Tess Tech (was Johnny Test)".
  • Audit logs (rebuilt)
    • Admin > Audit Logs lists who did what, when and from where, with views for Changes, Deletions, Sign-ins, Security (failed and blocked sign-ins, an agent turned away from a client, viewing the portal as a contact, app logs cleared, and changes to users, roles, API keys, the master key, settings and backups) and Data out (passwords looked at, exports, downloads and shared links), each counted for the filters you have picked.
    • Search finds an entry by what it says, who did it, the client, the IP address or the browser, and it can be narrowed by the date, who did it - an agent, a client contact in the portal, or ITFlow itself for cron, the API and the email parser - the client, the type and, under More filters, the action. It sorts from the column headings, a page at a time.
    • Each entry shows when, who with their picture, the client, the kind of record with the action as a coloured label, what it says, and the IP address with the system and browser in words. A script calling the API shows as itself, for example python-requests.
    • An entry about a record links to it while it is still there - a client, contact, location, vendor, asset, network, rack, credential, certificate, domain, license, service, document, file, shared link, ticket, project, quote, invoice, recurring invoice, product, expense, recurring expense, trip or template - and says No longer here once it has been deleted. A credential, certificate, domain, file, expense or trip opens on its list with its quick look open.
    • The quick look on each entry shows all of it: the whole description, who with their email, the IP address with everything else from it, the browser as it reported itself, the record's other entries with a link to all of them, and what the same person did in the quarter hour either side, each of those opening in turn.
    • Export downloads what you are looking at, or whatever you pick in the window, as a CSV or PDF, with the columns you choose - who, the record by name and the device among them. Each export is itself in the audit log.
    • Entries that pointed at the wrong record now point at the right one, old ones included: editing or redacting a ticket reply is on the ticket, a document changed through the API on the document, paying an agent back on the expense that recorded it, and a client restored through the API on the client.
    • Records show their own history: a History tab on contacts, assets, networks and projects, a History card on racks, documents and the ticket, project, document and license templates, and History folded at the foot of the file, expense, recurring expense and trip quick looks. Vendors, locations, licenses, services and products keep their History tab, in the same look. Each entry says what was done, who did it with their picture and when - and, for an admin, from which IP address - newest first, the latest 50 with how many more there are and a link to all of them in the audit log.
    • Each client has an Audit Log in its menu, after Calendar: everything done for the client, searched and filtered by date, who, type and action, with views for Changes, Deletions, Sign-ins (their contacts signing in to the portal) and Data out, each entry linking to its record. Agents see the kinds of entry their role covers (see Breaking Changes). An admin also gets each entry's quick look and a button to Admin > Audit Logs. A record's history links here for all of it, showing that one record by name.
    • The client overview's Recent activity links to the client's Audit Log, for everyone rather than admins only.
    • Your account > Activity is rebuilt on the same list: everything you did, searched and filtered by date, a page at a time, with views for Changes, Deletions, Sign-ins and Data out, each entry with its client, its record and where you did it from. Sign-ins says what to do about one that wasn't you.
    • A document's earlier versions are now in a card called Versions, with its history under it.
    • A failed sign in says why - a wrong password, no account with that email, an account that is disabled, archived or hasn't set a password yet, a contact whose client is archived, the client portal turned off, or a wrong MFA or two-step code after the right password - and is on the account it was to, marked Their account, as it may not have been them. A sign in through Microsoft 365 that fails is logged too.
    • The first time an account signs in from an IP address, the sign in is marked New IP - in Admin > Audit Logs, the client's Audit Log, Your account > Activity and the quick look.
    • Admin > Sign-ins, under Access: People - each agent and portal contact with their last sign in, its IP address and device, how many sign-ins and failed ones (the wrong MFA codes among them), how many IP addresses and, for a date range, how many of them were new - and IP addresses - each address with its sign-ins and failures, the accounts it tried and any emails with no account, when it was first and last seen, and whether it is being turned away now. Searched by name, email or address, for a date range, sorted from the column headings, a page at a time; each opens its sign-ins in the audit log.
    • Failed sign-in alerts: after a number of failed sign-ins in 10 minutes from one IP address or against one account - 10 to start with, or 5, 20, 50 or never, in Admin > Settings > Security - each admin gets a notification and an email saying who was tried and from where, with a link to look. A run is told about once. The same goes for API calls turned away from one address. Each alert is in the audit log's Security view.
    • Admin > API Keys counts each key's calls over the last 30 days and shows where the last one came from. Usage, on a key's menu or its count, shows its calls today, this week and this month, each day's as a bar, the endpoints it called and how often, the IP addresses the calls came from, and the calls turned away after it expired. Use is kept as long as the audit log.
    • Admin > App Logs is rebuilt: what ITFlow itself does - cron runs, mail, payment providers, the API - by level, Errors, Warnings and Info (and Debug once there is any), each counted for the filters you have picked, with each entry's level as a coloured label and its category with an icon. Search finds an entry by its category or what it says, and it can be narrowed by category - each with how many entries it has - and by date. It sorts from the column headings, a page at a time.
    • Each app log entry opens a quick look with all of it, to copy into a bug report, the other app log entries in the five minutes either side, and what was done in the audit log then - so a payment provider's error sits beside the payment.
    • The app logs can be exported as a CSV or PDF, with the columns you choose, and cleared - the entries shown, with the search, level, category and dates you picked, or every one. Clearing is in the audit log, in its Security view, saying what went. The audit log itself can't be cleared.
    • Admin > Settings > Logs, after Security: how many days the audit log, with API key use, and the app logs are each kept, or Keep forever, with how many entries each holds and when the oldest is from.
    • Sending logs on: each audit log entry, and the app log entries from a level up (errors, warnings and errors - to start with - info and up, or everything), can go to a syslog server or SIEM as it is written - Graylog, Splunk, Wazuh, Microsoft Sentinel, QRadar and the like. Over UDP, TCP or TLS (with the server's certificate checked, or not for one on your own network), as JSON, CEF or plain text, as standard syslog messages. Save and send a test sends a test message and says whether it got there - over UDP, whether nothing was listening. A server that can't be reached doesn't slow ITFlow down: the failure goes in the app logs under Log Forwarding, nothing is sent for a minute, and the settings page shows it with a link to it. What an entry looks like in each format is shown from the latest one.
    • Reports > Audit Activity, under Activity: what was done in ITFlow in a period - changes, deletions, passwords looked at, exports and, for an admin, sign-ins and the ones that failed - against the period before, over time, and by the hour of the week, with how much was done out of the business hours set for SLAs. Then by person (people since deleted together, and ITFlow itself last), by client and by kind of record, each linking to those entries in the audit log, and for an admin the security events. One client or one agent can be picked, and each table exported. Anyone else sees only the kinds of entry their role covers, for the clients they can open.
    • Admin > Audit Logs' Export has a menu with the Activity report, the log settings and the App Logs.
  • Emails (rebuilt)
    • Ticket emails to clients now look like the billing emails: your logo or name, a coloured heading saying what it is, the ticket's number, subject, status and priority in a box, the reply or the details quoted as they were written with who wrote them, a button to open the ticket, and your details at the foot. This covers a ticket opened by an agent, on a schedule or from an email, a reply, resolved, closed, being made a watcher, replying to a closed ticket, a reply through the API and a task needing their approval.
    • Each says under it why they get it - the contact on the ticket, watching it, or asking for every update on their company's tickets - in place of the YOU ARE A COLLABORATOR block.
    • A new ticket's email says when you will respond, from its SLA, and an Urgent or High one asks them to phone if it is holding them up.
    • Resolved says when it closes by itself, with a button to view or reopen it. Closed asks how you did.
    • The subjects are the same and the reply line stays at the top, so replies still go onto their ticket and are cut off where they were.
    • Ticket emails carry the conversation: the new message first, then the earlier public replies, newest first, and the request at the end. Each one shows who wrote it and when, as a short excerpt: pictures are left out and long messages are cut short. After four replies the rest are counted. Internal notes never show. Resolved, Closed, Work booked, Watching, Assigned to you, Client replied and the SLA alerts all carry it, so an email says what the ticket is about even when nothing new was written.
    • The line an inbox shows beside the subject is now the start of the message, or what happened ("We have marked ticket TCK-12 resolved."), where it used to repeat the number and subject.
    • New ticket at their company, to the primary and technical contacts who asked, shows what was asked when it came from the portal or one of you. An emailed ticket's words are never sent on by themselves, as the sender of an email can be forged - "We have your email" doesn't repeat them either.
    • Images in a reply point at your ITFlow, so they show, and anything in a reply that could run in the email is taken out.
    • Scheduled work: the contact, the ticket's watchers and the assigned agent get the date, time and whether it is on site, with the calendar file as before. Cancelling it tells them the same way, and the calendar file takes it out of their calendar.
    • Emails to your team are in the same look, each with an Open in ITFlow button:
      • Assigned to you: who assigned it, with the details and the client. Several from Bulk Action come as one email listing them, with a button to the agent's tickets. Recurring tickets the same way.
      • Mentioned you: who mentioned them on which ticket, the internal note, and a button to it (see Mentions in internal notes).
      • New ticket, to the new-ticket address (Admin > Ticket): who raised it and how - in the portal, by email or on a schedule - with what they wrote. A ticket raised on a schedule now reaches it too.
      • Client replied, to the agent the ticket is assigned to: who replied, in the portal or by email, with what they wrote. A portal reply used to say only to check ITFlow.
      • SLA at risk and missed, to the SLA alert address (Admin > SLAs) and the assigned agent: which target, when it is or was due and who has it, in amber for at risk and red once missed. An agent who is also the alert address gets one.
      • Shared links: a login sent back through a link you made, with a button to review it.
    • Account emails: an invite to join, with an Accept the invite button (see Agent invites), a change to their account, a sign in from a new device and a wrong MFA code. The sign in alerts say when, the browser and system in words, and the IP address, with a button to their sign in security.
    • The test email in Admin > Mail comes in the new look, saying which mailbox it was sent from.
    • Client portal: the email confirming a saved card is in the billing emails' look, from the invoice mailbox, naming the card and when it expires.
    • Client portal emails - an invite, from you or from a primary contact, Forgot password, a changed password, and two-step sign-in turned on or off - all come from the system mailbox in the same look.
    • Shared links: the email with a link and the one with a code to open it are in the same look, with your note quoted and the whole link printed under the button, as a link only works when it is copied whole. Subjects are the same.
    • Calendar invitations, updates and cancellations say when, how often it repeats, where, the description and a Join button for a meeting link, with the calendar file as before.
    • Email on the contacts and clients lists: what you write goes out with your logo above it, your details below and a line saying why they got it. Pick the mailbox it comes from and the name it shows, start it "Hello" and their name if you like, and send it now or later. What you write is made safe and its images point at your ITFlow, as in ticket emails.
  • Admin > Mail > Previews: every email ITFlow sends, 65 of them, as it goes out - your logo, your details and the address it comes from. Each is made when you open it, by the same code that sends it, on sample records (a client, its contacts and an agent) that are thrown away again, so looking sends and saves nothing. It says who gets it and what sends it, shows it at computer or phone width, and Send me a copy emails it to you, marked [Preview].
  • Setup (rebuilt)
    • The setup wizard is a centred installer like the sign-in page, with the steps across the top - each ticked off once done, and a progress bar instead on a phone. It is light or dark as your system is set, and what a step has to say shows on the next page instead of being lost.
    • Welcome says what to have to hand - an empty database and a user for it, your name and email, your company's name and country, and your language, currency and timezone. An install left part-way is picked up where it stopped.
    • Checks splits what ITFlow needs from what it works better with. A required check that fails - the PHP version, an extension, a folder it can't write to - says how to fix it and holds setup until it passes, and Check again looks again. Recommended: HTTPS with a valid certificate, a domain name rather than an address, uploads of 500 MB for restoring a backup, zip, and git for one-click updates.
    • Database: a mistake shows on the form, saying why it couldn't connect, with what you typed kept apart from the password. A database that already has tables in it is turned away rather than written over. ITFlow's tables are made before config.php is written, and if config.php can't be written, setup shows what to put in it, with a copy button. A config.php made by hand to an empty database gets a Create the tables button.
    • Each step after the database waits for the one before it, and says so.
    • Your account: the password is typed twice, each with its eye, and the avatar shows once picked. A missing name, an email that isn't one, a short password, passwords that differ or a file that isn't a picture is said beside the field before anything is sent - and by the server too, with the name and email kept.
    • Company: the phone takes its country code from the country picked, as in Admin > Settings > Company, and the logo shows once picked. A mistake comes back with everything typed kept, and the website is saved without its http:// and trailing slash.
    • Region starts on the currency of the company's country, a language of that country, your browser's where there is one (French (Canada) for a French browser in Canada, English (Canada) for others) and your browser's timezone - or the country's, when it has only one - and shows money and a date as they will look.
    • Finish says what to know before signing in - that sign-in needs HTTPS when the page came over http://, with the config.php line for a test install; your backup key, made there and then so it can be kept, with a copy button; and the address links in emails go to - then what to do once signed in: cron (its crontab line, to copy), mail, backups, starter content and your team. Telling the ITFlow project about the install is off unless switched on, and shows exactly what it sends.
    • Restore a backup: drop it on the page or choose it. One larger than the server takes through a browser is pointed at the command line before it is sent; otherwise the upload shows how far it has got, then that it is restoring, and a page says when it is done.
    • scripts/setup_cli.php runs the wizard's checks and checks what it is given the same way, asks again for an answer that is wrong (the password twice, not shown), suggests the language, currency and timezone from the country, and ends with the backup key and the crontab line. --non-interactive asks nothing at all. New options: --phone-country-code, --tax-id, --share-telemetry, --comments and --skip-get-started, which leaves Get started in Admin instead of opening it on first signing in (see Get started).
  • Settings (rebuilt)
    • Admin > Settings > Overview: every settings page as a tile, grouped - your company, work, email and notifications, sign-in and access, and system - each saying how it is set now ("Next INV-946 · reminders on · late fees off"). What needs looking at is at the top: mail sending off, email to ticket with no mailbox, a login key required but never set, no timezone. A page for a module that is off is shown faded, and left out of the menu.
    • Find a setting: type what you are after - late fee, logo, two-step, timezone - and it lists every setting that matches, with the page and section it is on. Picking one opens the page with that setting lit up and ready to change, on the right tab for Mail.
    • The pages are laid out alike: what the page is for at the top, then sections, each with what it is for beside its fields. Once something is changed the bar at the foot says so, with Undo changes beside Save, and leaving the page asks first. A save with something wrong in it comes back with what is wrong beside each field and everything typed still there, and saves nothing. The audit log says what changed, from what to what - "Next ticket number 4021 → 4100" - and a save that changed nothing says so.
    • Company details, Localization, Theme, Modules and Telemetry are the first pages in it. Company details shows the logo, and a new one before it is saved, with a button to take it off; the phone takes its country code from the country picked; the website is kept without its https://. Localization shows money and the time now as the language, currency and timezone picked will write them. A theme colour shows across the page as soon as it is picked, and Save keeps it; the browser tab icon is shown, with a way back to ITFlow's. Modules says what each one holds, and that switching one off deletes nothing. Telemetry is one switch, with exactly what the nightly report holds.
    • Tickets, Projects, Quotes, Invoices and billing, and Defaults are in it too. Each prefix and next number shows the number the next one will get as it is typed, and the last one made; a number already used is said at once, and can't be saved. Tickets is in sections - numbering, email to ticket, new tickets, time and billing, closing, and the tickets page - with what happens to an email from an unknown sender when they are left out. Invoices and billing holds numbering for invoices and recurring invoices, the footer and tax ID, late fees (with a warning when reminders are off, as no fee is added then), who hears of a payment made online, reminders and statements, billing time, billable expenses, and trips and mileage. Defaults is grouped into opening ITFlow, new clients and accounts; the start page can be any page of ITFlow typed in, and an account archived since it was picked stays picked.
    • Notifications, Security, Client portal, Single sign-on, Mail and Logs complete it. Notifications is no longer a table of mostly empty columns: alerts for your team, and the emails clients get by themselves, each saying when it goes - with a warning while mail sending is off, and reminders shown as they are, set in Invoices and billing. Security shows the address agents sign in at as the login key is typed, and requiring a key with none set is refused rather than quietly switched off. Single sign-on gives the redirect address to register in Microsoft Entra, with a copy button, and no longer puts the client secret into the page - left empty, it stays as saved.
    • Mail keeps its tabs, laid out like the rest. Sending and Receiving show only what the provider picked takes: for a server, its name, port, encryption (with the port each usually has) and sign-in; for Google Workspace or Microsoft 365, the account that signs in, whether the OAuth app is connected, and the way to it. The OAuth tab says how to set the app up for the provider picked and whether it is connected, with the redirect address to copy. Passwords, the OAuth client secret and its refresh token are no longer put into the page - left empty, they stay as saved. From addresses gives each mailbox - system emails, invoices, quotes and tickets - its own section, saying what it sends. Receiving says whether email to ticket is on to read it.
    • Settings are in the search window (Ctrl K) for admins: type a setting - late fee, login key, smtp - and pick it to go straight to it.
  • Mail Queue (rebuilt)
    • Tabs for where each email is - Waiting, Sending, Retrying, Failed, Sent and Cancelled - with how many in each, following the search. Each email says where it stands: scheduled for when, tried 2 of 4 and when it is tried next, gave up, not tried again (a bad address), sent when, or cancelled.
    • What is stopping mail going is at the top: sending not set up, cron switched off or not running, the Mail Queue job switched off, or email waiting longer than it should - with the job's last error. When nothing is, it says when the job last ran.
    • Retry now starts an email over - a full set of tries, at the next run - for a failed or cancelled one. Cancel is its own status now, rather than Failed. Retry now, Cancel and Delete are on each email's menu and in Bulk Action, done only to the ones they can be, saying how many and why not the rest.
    • Why an email failed is kept with it - the mail server's own answer, an address that isn't one, no from address, a domain with no mail server - and shown under its status and searched. Cancelling keeps who cancelled it. The alert for a failed email says why and when it is tried again, and when ITFlow gives up after the last try there is an alert for that too, each opening the Mail Queue on its tab.
    • Opening an email shows where it stands and why, who it is to and from, when it was queued, how many tries it has had, when it was sent or last failed, its attachments and calendar file - and the email itself as it will arrive, rather than stripped of its layout. A sent one says what it said was cleared on sending, and why. Retry now, Cancel and Delete are in the window too.
    • Without opening it: the Settings overview says when email failed to send in the last day, and when the queue isn't moving, and the Mail tile how many failed this week; the Mail Queue menu item carries the same count; Mail's Tests tab shows when mail was last sent and what is waiting, retrying or failed. The Mail Queue is in the search window (Ctrl K) for admins, and the test email says where to watch it go.
  • Payment Providers (rebuilt)
    • Admin > Payment Providers lists each provider with whether it is on, live or test (as its keys say), the account its payments go into, its card limit, whether its fees are recorded as expenses, its saved cards and the autopay on them, and when a card payment last came through it. Search, sort by any of them, and Saved Cards from the top.
    • A provider can be turned off and on again: off, clients can't pay by card and autopay with its cards is skipped, while its cards are kept. Delete says first what goes with it - its saved cards, the autopay on them and its links to clients - and suggests turning it off instead.
    • Add and Edit are one window. The secret key is no longer put into it - left empty, it stays as saved. Save checks first: keys that aren't Stripe's, one live and one test, a card limit that isn't money, or fees with no vendor or category are said beside the field, and new keys are checked with Stripe - before anything is saved, and without closing the window or losing what was typed. Fees have one switch, Record its fees as expenses, with who they are paid to and their category. The card limit is shown in your currency.
    • The audit log says what changed, from what to what - "Card limit No limit → Under $2,500.00", "Mode test → live" - with the keys only as changed.
    • Saved Cards: each card a client has saved, as Visa •••• 4242 with when it runs out, whose it is (to their client page), and the recurring invoices that autopay with it and when each next does. Tabs for the cards on autopay and the ones that have expired - an expired card on autopay says its next autopay will be declined, and the Expired tab turns red while there is one. Filter by client, or by provider from the Payment Providers list; search by the provider's customer or payment method ID; sort by when they run out.
    • Removing a card says first which recurring invoices stop autopaying with it. It is taken off Stripe as well, each of those recurring invoices says in its history that its autopay stopped and why, and when Stripe can't be reached the card is still removed and you are told it is saved there.
    • What goes through it: each provider shows its card payments in the last 30 days and when the last came, what its fees came to, and how many are still to record - flagged once they are more than two days old, as the nightly run should have had them. Record fees now does what the nightly run does, and says what it recorded, what Stripe hasn't settled yet and what it wouldn't say. Check connection asks Stripe whose account the key is, and says so - or why it isn't connected.
    • Settings > Invoices and billing says how card payments stand, under Paid online: none set up, turned off, or on - with live or test keys, where payments go, the card limit and how many came in the last 30 days. The Settings overview flags test keys, card payments going into an archived account, and Stripe's fees not being recorded, and the Invoices and billing tile says whether cards are on.
  • Custom Links (rebuilt)
    • Admin > Custom Links has a tab for each menu a link can show in - the agent menu, the top bar, the client portal, the admin menu and the reports menu - with how many each holds, plus All and Archived. Search looks at the name, the address and the description.
    • Each link shows its icon, what it is for, where it goes and whether it opens in a new tab. A link whose address can't work is flagged.
    • On a menu's tab, drag the links into the order the menu shows them in, or use Move up and Move down. A new link goes at the end of its menu - the Order number is gone.
    • New Link and Edit are one window with a preview of the link in its menu, as it will look. There's an icon picker with search, and a description that shows when the link is hovered. Open in a new tab switches itself on for another site and off for a page of ITFlow, until you change it.
    • A link can be copied, to put it in a second menu, archived to hide it without losing it, restored, or opened to try it. Delete asks first.
    • The address has to be a web address, a page of ITFlow, mailto:, tel:, or one of the remote tools and shares that asset links open (rdp:, ssh:, smb: and the like). A javascript: or data: address is refused, and one saved before now goes nowhere.
    • In the menus: the agent menu's links sit under Tools, with the heading even for a role without Reports. The admin menu's links get a heading of their own, Links. A link that opens in a new tab has a small arrow, a link without an icon gets the link arrow, and every link shows its description on hover - in the top bar as well as its name.
  • AI Providers (rebuilt)
    • Admin > AI Providers lists each provider with its kind and address, whether its key is saved (it is never shown), whether it runs on your own network, whether passwords and keys go to it as they are, its models and what each is for, and its requests and tokens in the last 30 days, with how many failed. Above the list, which model General, Tickets, Documentation and Billing each use - their own, or General's. Search finds a provider by a model on it too, and the list sorts by name, kind, models, requests or when it was last used.
    • Add and Edit are one window. Pick its kind - OpenAI, Anthropic (Claude), Azure OpenAI, Google Gemini, Mistral, Groq, OpenRouter, Ollama, LM Studio, or any other OpenAI-compatible service - and its address and name are filled in, with a link to where its keys are made. Test asks it for its models: it says it answered and how many there are, or why not - a key it turned away, an address that isn't there or doesn't answer - beside the field. Save checks first the same way, and a provider that doesn't answer right now (one on your network that is off, say) can still be saved, once it has said so. The key is no longer put into the window; left empty it stays, and Remove the saved key takes it off.
    • Adding the first provider asks for the model to use, with the ones it has suggested as you type, so one window sets AI up for everything.
    • Test on a provider's menu says whether it answers with its key, and names any model set up on it that it doesn't have. Delete says which models go with it and which features stop.
    • Admin > AI Models: each model with its provider, what it is for, its instructions and temperature, and its use in the last 30 days. A second model for a use case is marked Not used, as only the first is, and adding one says so. Filter by provider; sort by name, provider, use case or requests.
    • A model's window suggests the models its provider has as you type, says what the use case picked has now - that this one will be used for it, or that another is and this one won't be - and Try it sends the model a one-line request as it is in the window, unsaved, and shows what came back and how long it took. Its instructions go with every request it handles: your house style, things to always or never say.
    • Admin > AI Usage: every request - when, the feature (Reword, Reword a ticket reply, Ticket summary, Document template draft, Test), who asked, the client it was for, the model and provider, the tokens it took, how long, how many secrets were kept out, and why it failed when it did. Totals for what is shown above the list - requests, tokens in and out, failed, secrets kept out and answer time. Tabs for the failed ones, the ones secrets were kept out of, and tests; filter by date, provider, model, feature and who; search by any of them or the client. Kept as long as the audit log.
    • Keeping secrets out: passwords, PINs, API keys (OpenAI, Anthropic, Stripe, AWS, GitHub, GitLab, Slack, Google), JWTs, private keys, 2FA setup links and card numbers in a reply or ticket are taken out before it goes to the provider, as [secret-1] and so on, and put back in the answer - the provider never sees them, and AI Usage counts them. On by default for every provider; it can be turned off for one you run yourself.
    • Use AI permission in Admin > Roles, so a role can be kept from AI altogether.
    • The Settings overview says when AI requests have failed in the last day and are still failing, with the provider's reason.
    • Reword is the first button on the editors where it is there, says when there is nothing to reword yet, keeps your formatting, placeholders, links and names, and says Undo puts your words back - which it does, in one step. It works on the admin pages' editors too.
  • AI on tickets
    • Draft: a Draft button beside Reword in the ticket reply editor writes the next reply from the ticket - or, when you have typed a few notes ("onsite tue 10am, bring new modem"), the reply those notes say, in full and adding nothing to them. It is written for the reply type you picked: an email or public note greets the contact by first name and leaves the sign-off to your signature, an internal note is short and for colleagues. A reply the client will read is drafted without the internal notes, so nothing in them can end up in front of the client. Where only you know something - the time of a visit - it leaves a note in square brackets to fill in. Undo puts back what was there.
    • Summary: Summarize with AI now gives the problem, what has been done, where it stands and the next steps, with a line in short at the top - from the whole ticket, its internal notes, tasks, assets and time worked. Save as a note puts it on the ticket as an internal note, so whoever picks it up next has it; Copy and Again are beside it.
    • Triage: Triage with AI, from the ticket's menu, suggests a category from your ticket categories (their descriptions help it pick) and a priority, with a line on what the client needs and why that priority. The suggestion shows on the ticket with what it has now - tick what to take and Apply, or Dismiss. What was applied is in the ticket's history.
    • Triage as tickets come in: Admin > Settings > Tickets has Triage new tickets with AI. Suggest puts the suggestion on each new ticket from email, the portal or the API, within a minute of it coming in. Fill in sets the category when the ticket has none, and the priority of an emailed ticket, since nobody picked one - a priority the client picked in the portal is only suggested. The AI Triage job in Maintenance > Cron does it, up to five tickets a minute; one the provider turns away isn't tried again, and Triage with AI on the ticket asks once more.
    • Write up as a document: from a ticket's menu, AI writes it up as a document for the client's documentation - what it looks like, the cause, the steps that fixed it, and how to stop it happening - for next time, without the story of the ticket. It opens in the New Document form to check and change, in the client's documents (your own company's, for a ticket with no client), linked to the ticket's asset, and the ticket's history says it was written up. Passwords and keys in the ticket never reach the document - they are left out as [removed], whatever the provider is set to.
    • AI Usage shows each of these by name, and each needs the Use AI permission.
  • AI for clients and documents
    • Reword's tones: the arrow beside Reword in any editor has Make it shorter, More formal, Friendlier, Plain English (for someone non-technical), Organize it (headings and lists), and Just fix spelling and grammar - each keeping the facts, names, numbers, links and placeholders, and the formatting. Undo puts your words back.
    • Translate: from the same menu, into any of 30 languages - your last pick comes first next time. A reply can go to the client in their own language, with product names, commands and placeholders left as they are. AI Usage counts translations on their own.
    • Client briefing: Briefing on the client overview sums the client up for whoever is about to talk to them - before a call, a visit or a quarterly review. How things stand, open work and what needs pushing, what keeps coming up (the same device, the same kind of problem, the same person) and what would stop it, renewals and scheduled work coming up, what they owe, and what to bring up with them. For the last 30 days, the last quarter or the last year. It only uses what your role can see - no money without Sales - and never a credential or license key. Save as a document keeps it in the client's documents, for the next review.
    • Draft a document: New Document has Draft it with AI - say what it is for ("a disaster recovery plan for the main office") and it is drafted with its name and summary, using what ITFlow knows of the client - their sites, key people, servers and network gear, networks, software and domains - unless you untick it. Anything only you know is left in [square brackets] to fill in. Never a password, key or login.
    • Review with AI: on a document's page, AI reads it and says whether it looks good, needs work or is out of date, what to fix or check - the most important first - and a one-line summary for it, which Use as its summary saves. It flags passwords and keys written in the document, which belong in Credentials, and never sends them. Mark reviewed is beside it.
  • AI for quotes and invoices
    • A new use case, Billing, for a model of its own in Admin > AI Models - General's is used until there is one.
    • Write it with AI: under the add-a-line row on a quote or invoice that can still change. Say what it is for ("three laptops for the new starters, set up and given Microsoft 365") and AI picks the lines: products and services from your catalog wherever one fits, at the catalog's price and billed as the product is; labor in hours, to the quarter hour, at the client's rate; and anything the catalog doesn't have, with no price for you to set. AI never prices anything. It suggests a scope, a note for the client (what is assumed and what isn't included), and what to check before sending. Tick the lines to keep, change any of them - the total follows, once and each month, quarter or year - and Add puts them on, each taxed as a line of its kind is, with the scope and the note if you keep them. A quote raised from a ticket can draw on the ticket, and both can use what ITFlow knows of the client - its people, sites and devices - to size per-user and per-device lines. Without Support, the ticket and the client's documentation are left out, and only how many people the client has is used.
    • Invoicing a ticket: Write it beside the line's description says what was done, from the replies and notes - the ones that logged time first - as short points under the usual ticket line, for the client to read. No times, prices or names, nothing from internal notes the client shouldn't see, and passwords never sent nor written back. Put back what was there undoes it.
    • AI Usage shows each by name, and each needs the Use AI permission and Sales to change quotes and invoices.
  • Debug (rebuilt)
    • Admin > Debug says first what needs putting right - problems, then warnings - each with what was found, what to do about it and a link to where to do it. When nothing does, it says everything looks good.
    • Along the top: the version and where the code came from, the database's version (and an update waiting), PHP, when cron last ran, failed email this week, the last backup, and errors logged in the last day - each a link to its page.
    • Every check, by section: ITFlow (its version, the database's version against the one the code expects, updates and whether they can run here), background jobs (cron running and switched on, with the crontab line when it isn't; jobs that failed in the last day; jobs switched off), mail (sending, email given up on, email waiting to be retried, the ticket mailbox last read), backups (the last one, one that failed, the backup folder), PHP (version, the extensions ITFlow needs, zip, upload size, memory and time limits), web and security (HTTPS, the certificate and when it runs out, the address in ITFlow's emails against the one in use, a domain name, HTTPS only), files and disk (the uploads folder, one-click updates able to write, free space), the server (system, web server, database server, time zone, and the database's clock against PHP's), and errors logged in the last day with the latest few.
    • Database: its tables, rows and size, the biggest tables with a bar for their size, and how its tables and columns compare with a fresh install's - each difference named, with what to do when something is missing. Row counts are the database's own estimate; Count exactly counts every table.
    • Uploads: files and size by folder, biggest first.
    • Copy for support puts the whole report on the clipboard as plain text, for a forum post or a support email - versions, what needs looking at and the size of things, without host names, paths, addresses or keys. The report is on the page too, to read before posting.
    • The page opens quickly on a big install, and still opens when the database is behind the code - which is when it is wanted most.
  • Ticket Statuses (rebuilt)
    • Admin > Ticket Statuses shows each status as its badge, with what it is for, how many tickets are in it (a link to them), whether the SLA's resolution clock runs or pauses in it, and what a client's reply does to a ticket in it. Tabs for the ones in use and the ones switched off; search by name or description.
    • Drag them into the order the status pickers and the ticket board show them in, or use Move up and Move down. The Order number is gone.
    • New Status and Edit are one window with a preview of the badge, colour swatches (or any colour), and a description that shows when the status is hovered - in the pickers, on the board, and on tickets everywhere.
    • When the client replies: each of your own statuses can send the ticket back to Open, as every reply always has, or leave it where it is - for a status like Scheduled, where a "thanks, see you then" shouldn't put the ticket back in the queue. By email and in the portal; whoever it is assigned to is told either way.
    • Switch off a status to take it out of the pickers and off the board, keeping it - its tickets can be moved to another status first, or left where they are. Switch it on again from the Switched off tab.
    • Delete one of your own and its tickets are moved to the status you pick first - each says so in its history, and its SLA clock follows. Canned responses that set it leave the status as it is instead.
    • ITFlow's five statuses (New, Open, On Hold, Resolved, Closed) are marked Built in. They keep their names, stay switched on, and their SLA clock and reply behaviour are fixed - the window shows what each does and when ITFlow sets it. Their colour and description can change.
    • Status badges have dark or white text, whichever reads on their colour - a yellow status is readable now.
  • Payment Methods (rebuilt)
    • Admin > Payment Methods shows each method with its description, what it is for, what it has been used on - "137 payments · 36 income" - linking to them in Transactions, and when it was last used. Tabs for the ones in use and the archived ones; search; sort by name, what it is for, use or last used.
    • Offered for: money in (payments from clients and other income), money out (expenses, recurring expenses, reimbursements and mileage claims) or both. Each picker offers only the methods that fit - the company card isn't offered on a client's payment, and a check from a client isn't offered on an expense. Transfers between accounts offer every method.
    • Renaming a method renames it on every payment, income, expense, recurring expense, reimbursement, transfer and autopay that used it, and in the defaults - it used to leave them all under the old name.
    • Defaults: the default for payments and the default for expenses are marked on the list, and any method can be made one from its menu - the same settings as Admin > Settings > Defaults, which now offers the methods for money in for payments and for money out for expenses.
    • Archive a method you no longer take - it stops being offered and stops being a default, and the records that used it keep it. Restore brings it back. The Transactions filter still finds them.
    • Delete a method nothing used; one that was used is merged into another instead - its records change to the method you pick, which takes its place as a default too. For two names for one thing, "Card" and "Credit Card".
    • Methods records were paid by but that aren't in the list - from a bank import, an older version or demo data - are named at the top with how much they are used, to add with one click.
    • New and Edit are one window, with an icon in front of every field.
  • Contracts (new)
    • Contracts, under Billing in the menu and in each client's menu, keeps every agreement with a client: its type (Fully managed, Co-managed, Block hours, Time and materials, Break/fix, Project or Other), its term, start and end, whether it renews itself and the notice needed to end it, support hours, rates, net terms and an SLA for each ticket priority from Admin > SLAs. A client has no rates, terms or SLAs of its own - its contract's are what it is billed on. Leave a rate, the net terms or an SLA empty and the default applies, from Admin > Settings > Defaults and Admin > SLAs.
    • Along the top of the list: how many are in force, ending soon without renewing, renewing soon (a good time to review them with the client) and drafts. Tabs for Active, Due soon, Signing (out for signature, or waiting for you to countersign), Drafts, Ended and All, with search, filters for type and client, and sorting by name, client, type, start, end or owner. Each row says when it renews or ends, when notice is due and where its signing stands. Due soon is within a month of the notice date, when there is something to do: a contract ending, or one renewing itself with notice to give during its term - not one with no notice, like Standard terms, or with notice as long as its term. The menu shows how many are due soon. Across every client, an archived client's contracts are left out - they are on its own page.
    • New Contract can start from a template, which fills in the type, term, renewal, notice, cover and terms, and any of them can be changed after. Pick the client, a contact there and the agent who looks after it. The end date follows the start and the term until you change it, and the window says when notice is due. A term from the 31st ends the day before the 31st, or before the last day of a shorter month - a monthly contract from Jan 31 runs to Feb 27, then Mar 30. Save it as active, or as a draft - a draft sent to sign becomes active once both sides have signed, or make it active yourself. Copy starts a new contract from an existing one.
    • A contract's page shows where it stands (draft, starts soon, active, renews soon, ends soon, ended or cancelled) and how far through its term it is. It also shows what the contract covers, with the SLA for each priority, the terms and internal notes, the client, contact and owner, and its history.
    • Renew sets a new last day, a term on by default. Cancel takes the last day it is in force and why, and it doesn't renew after that; a request to sign it still out, or waiting on your countersignature, is withdrawn. Make active takes the day a draft starts and sets its end a term later. A date that isn't a real day, like Feb 30, is refused.
    • Delete is for a contract made by mistake. One that tickets' time was drawn from, or that was signed by both, stays - cancel it instead, or put its tickets back first - and its menu says why in place of Delete.
    • Every night, a contract that renews itself is renewed for another term once it ends, each term starting on the day of the month it started (or the last day of a shorter month) unless its end date was set to another day, and one that doesn't is marked ended. A month before its notice date, when there is something to do - it ends, or it renews itself and notice is given during its term - its owner is emailed what it is, when notice is due and a link to it, and agents are notified in ITFlow. This happens once a term. Nothing is sent for a contract with no notice to give, like Standard terms, or notice as long as its term, like a monthly one with 30 days' notice. An archived client's contracts are left as they are - not renewed, ended or reminded of - until the client is restored.
    • The client overview lists contracts ending or renewing soon under Needs attention, and shows the contract in force in the Financial row.
    • Deleting a client deletes its contracts. It used to fail while the client had one (see Bug Fixes).
    • Each contract is also a document - who it is between, the agreement in a few lines (type, term, renewal, notice, support hours, rates and when invoices are due), the response times for each priority, its terms and a place for both sides to sign. Preview it, print it or download it as a PDF.
    • Its terms can use placeholders that fill in from the contract - [Client], [Client Address], [Contact], [Start Date], [End Date], [Term], [Renewal], [Rate], [Net Terms], [Owner] and more - and [SLA] on a line of its own becomes the table of response times. They are listed under the terms editor in the contract and template windows; click one to put it in. Anything that nothing fills in is pointed out before the contract goes out.
    • Send to sign emails one of the client's contacts, or anyone by name and address, a link to read and sign it, with a note from you. They sign with their name and job title, and draw their signature with a mouse, finger or pen or type it. They can sign from the link, or in the client portal, where Billing > Contracts lists their contracts and the home page shows the ones waiting for a signature.
    • When they have signed, whoever looks after the contract is emailed and agents are told in ITFlow. Countersign signs it for your side, and then the client is emailed the PDF signed by both. A draft becomes active from its start date, or from that day when it has none.
    • The contract's Signing card says where it stands - sent, opened, signed or countersigned - with when and from where. It has the link to copy, Send the link again, Withdraw, the copy sent or signed to view or download, and the requests before it. The signed PDF ends with how it was signed: when it was sent and opened, who signed it, when and from which IP address, and the document's fingerprint.
    • Changing a contract while it is out for signature withdraws the request when what it says has changed, so no one signs an old version. Send it again for the new one. A signed contract that no longer matches the copy signed says so.
    • Nothing is signed on a contract that was cancelled or has ended, or whose client was archived. Its request still out, or waiting on your countersignature, is withdrawn - when it is cancelled, when the client is archived (one or several at once), and by the nightly job when it ends - and the link, the portal and Countersign refuse it. It isn't sent to sign while the client is archived.
    • Prepaid hours. A contract can include hours each month - set under Cover in the contract and template windows - and the hours a client buys are added on its page with Add hours: how many, the price, the day they were bought and the day they run out, and a note, with a draft invoice for them if you like. Hours included each month are for that month only. Time is drawn from them while the contract is active, and once it is cancelled, until its last day - hours can be added until then too.
    • Every night the time on the client's billable tickets done since is drawn from those hours, ticket by ticket, rounded the way Billing Time in Admin > Settings > Invoice says: the oldest ticket first, from the hours that run out first, and only from hours that cover the day the ticket was done. A ticket they cover leaves Unbilled Time. One they run out on part way stays there with the rest, billed at the usual rate. Draw now does it straight away, from the contract or the client's Unbilled Time. Each ticket's history says what it drew, and a ticket worked on again after it was covered goes back to Unbilled Time for the extra time.
    • The contract's Prepaid hours card shows what is left and of how much, what runs out when, each block of hours with its days, what was used of it and its invoice, and the tickets drawn from them. Put back returns a ticket's hours and bills it as usual, and the nightly draw leaves it alone until Draw it on the ticket. Hours nothing was drawn from can be deleted. Once a ticket is invoiced, its hours stay drawn.
    • Set Tell the owner under, and when the hours left fall below it, whoever looks after the contract is emailed and agents are told in ITFlow - once, until more hours are added. Agents are also told when the hours run out part way through a ticket.
    • The contracts list shows the hours left on each, the client overview the hours left across them, a ticket what it drew, and Unbilled Time what is left, with Draw now and how much a ticket's hours were cut by. In the client portal, a contract shows the hours left, what runs out when and the tickets they went on (a ticket's subject only to a contact who can open it), and the list shows the hours left.
    • The hours included each month are in the contract's document, and [Monthly Hours] fills them in. A contract without them reads as it did, so nothing already signed goes out of date.
    • The contract emails - the reminder before the notice date, Please sign, Signed by the client, the signed copy and Prepaid hours running low - are in Admin > Mail > Previews with the rest.
    • While a contract is in force - from its first day to its last, and while one that renews itself waits for the night's renewal - it sets the client's terms. New tickets get its SLA for their priority, ahead of the default from Admin > SLAs. Time bills at its rate, and at its after-hours rate for a ticket opened outside the business days and hours in Admin > Settings (the ones SLAs use) - else the default after-hours rate, else its rate - going by the contract in force on the day the work was done. New invoices are due on its net terms. When two are in force, the one that started last comes first, and what it leaves empty comes from the other; whatever no contract in force sets, the default applies. The contract's page says whether it is in force now, and where each thing it leaves empty comes from - another contract, by name, or the default - as does its document.
    • Unbilled Time bills each ticket at its own rate: an after-hours ticket says so, the rate is left empty when a client's tickets don't all bill at one rate (type one to bill them all at it), and lines put together into one are one for each rate. The ticket's Invoice window, Add Tickets, a quote from a ticket, the Unbilled Work and Time Detail reports, the New Invoice window's due date, copying an invoice, invoicing a quote and the invoice page follow the contract too, and say when its rate or terms are the ones used. The client overview's Terms shows the rate, after-hours rate and net terms the client is billed on, and whether they are its contract's or the defaults - as does the Billing tab of its Edit window. The clients list shows the hourly rate each client is billed at under Recurring - its contract's, or the default, marked (default).
    • A contract from a template, on the client: New Client's Billing tab has a Contract to start from a template, from a day picked (today to start with), and Edit Client has New contract, which adds another - active, with the template's type, term, cover and terms, the primary contact, and whoever added it looking after it. Agents with Sales at Modify see them.
    • The clients list has a Contract column: the contract in force, with when it renews or ends and how many more are in force - or None, with the next to start or a draft, or Start a contract, which opens New Contract for the client. It sorts by contract, the filters have Contract (one in force, none in force, or one started from a template), and Action has Start Contract, a contract from a template for every client ticked. Admin > Contract Templates shows what a template leaves empty as the default, marked as such, for its rate, net terms and each priority's SLA.
    • The defaults - what a client is billed on when no contract in force sets it - are under Rates and terms in Admin > Settings > Defaults: the hourly rate, the net terms and a new default after-hours rate (left empty, after-hours time bills at the hourly rate), with the SLAs in Admin > SLAs. The contract and template windows show them in the fields left empty. Demo data puts each demo client's rate, terms and SLAs on a contract: a managed services agreement, or the terms a break fix client is billed on.
    • The recurring invoice that bills a contract: make one from the contract's page - the amount each time, how often, the first invoice's day, the line, category and tax, and whether each is emailed by itself - or link one of the client's. It is paused while the contract is a draft, starts when it is made active or renewed (runs missed in the meantime are skipped, not billed), and is paused once the contract ends or its cancellation takes effect. That happens once, so one started again by hand is left alone. The contract shows it with its amount and next date, and the recurring invoice and its list show the contract. Unlink lets it carry on as it is.
  • Contract Templates (rebuilt)
    • Admin > Contract Templates shows each template with its type, its term and renewal, the notice, its cover (support hours, rate and net terms), the SLA for each priority and how many contracts were started from it. Tabs for the ones in use and the archived ones; search; sort by name, term or contracts.
    • New and Edit are one window with Template, Cover and Terms tabs and an icon in front of every field. A template has the same terms as a contract, the hours included each month among them, without the client and dates, and its terms can use the same placeholders.
    • Copy makes "Name (copy)". Archive takes a template off New Contract and keeps it, and Restore brings it back. Delete leaves the contracts started from it as they are.
    • Contract Templates is under Templates in the admin menu while the accounting module is on. When there are no templates yet, New Contract tells an admin where to make them.
  • Client portal invites and signing up (new)
    • Invite people to a client's portal by email, without making them a contact first: Invite to the portal on a client's Contacts, or Invite people on its Invites tab. Put in their addresses - several at once, one to a line or separated by commas, a name in front if you like - and whether they are technical or billing contacts. Each gets an email from you with a link to join. Someone already invited is sent it again with a new link; someone who signs in already, or whose address an agent or another client's contact signs in with, is left out, and you are told why.
    • Joining: the link opens a page in the portal's look where they put in their name, job title, phone and a password - or, when single sign-on is set up, choose their Microsoft 365 account instead. They are a contact from then on, with the roles you gave them, and sign in straight away. When the client already has a contact with that address, that contact is given access, keeping what you had filled in. The link works once, for 7 days.
    • A client's Contacts has an Invites tab with the invites waiting - who, what they will see, when and by whom it was sent, and whether the link is still good or has run out - with Send again (a new link, the old one stops working) and Cancel invite, a search and sorting. Across all clients the tab shows while any are waiting. You are notified in ITFlow when someone you invited joins, and the contact's page says whose invite they joined from.
    • The primary contact can invite people from Contacts in the portal too. Waiting to join lists their company's invites, with Send again and Cancel, and they are emailed when someone they invited joins. Every agent is notified.
    • Signing up: let the people at a client make their own account, from Create an account on the sign-in page. They put in their work email address, and when it is at one of the client's Domains they are emailed a link to finish - name, job title, phone and password, as with an invite. A verified address is all it takes: they join as a plain contact who sees the tickets they raise, every agent is notified, and the client's primary contact is emailed with a link to them. Someone already a contact at the client signs up as that contact.
    • Turn it on for a client with Let them sign up on its Invites tab - it needs the client's email domain under Domains - and for the portal as a whole in Admin > Settings > Client portal, which also lists the clients that let people and their domains. Create an account shows on the sign-in page only while signing up is on, a client lets people, and outgoing mail is set up.
    • The page says the same whatever address is put in - it never says whether someone has an account, or which client a domain belongs to. Someone who signs in already is emailed that they have an account, with a link to set a new password. Nothing is sent for an address at no client that lets people, one whose portal access was turned off, or a domain two such clients have. One email per address every 5 minutes, at most 20 sign-ups from one internet address in 10 minutes, and robots that fill in a hidden field are turned away. Each ask is in the audit log under sign-ins, with why when nothing was sent.
    • Links to finish signing up last 24 hours. Turning signing up off for a client stops the links already sent for it. The nightly run clears sign-up links nobody used a week after they run out, and invites cancelled more than 90 days ago.
    • The emails are in Admin > Mail > Previews: Invite to join, from you or from a colleague, Invite accepted, Finish signing up, Signing up with an account already, and Someone signed up.
  • Agent invites (new)
    • New User invites them by default. You set their name, email, role, whether MFA is required, the clients they see and a picture as before, and under Sign in, Invite them is picked. They are emailed a link to join: the page it opens says who invited them and as what, they check their name and choose their own password, and from then they are an agent and sign in on the usual page. Nobody else ever knows their password - it also unlocks their copy of the credential vault - so no password is passed on, written down or emailed. Set a password now is still there, for you to give them one yourself.
    • With no email set up, Invite them gives you the link to send them yourself - by chat, text or your own email. It is shown once, at the top of Users.
    • Users has an Invited view: who is invited, their role, whether the link is still good or has run out, MFA, the clients they will see, and who invited them and when it was emailed - with Send again, Get the link (a new one, so the one sent before stops working) and Cancel invite, a search, the role filter and sorting. Inviting someone already invited changes their invite to what you set now and sends a new link.
    • The link works once, for 7 days. When they join, whoever invited them is notified in ITFlow, the audit log has it in their name, and their quick look says whose invite they joined from. A link that has run out, been used or cancelled says so, and who to ask. If the login key is required, the page sends them to sign in with it.
    • An invite waiting holds a seat under the agent user limit, so there is one for them when they accept, and a role with invites waiting can't be archived.
    • Get started > Your team invites them too, when email is set up, and lists who is invited.
    • The email is in Admin > Mail > Previews, as Invite to join under Your team's accounts. The nightly run clears invites cancelled, or run out, more than 90 days ago.
  • Mentions in internal notes (new)
    • Type @ in a ticket's internal note to mention someone on your team: a list of the agents who can open the ticket comes up, narrowing as you type a name or role, and picking one puts their name in the note. Nobody else is offered - not an agent whose role has no Support, nor one kept away from the client by their client access.
    • When the note is added, each agent it mentions is told in ITFlow, with a link that opens the ticket at the note, and by email from the ticket mailbox: who mentioned them, the note, the ticket's details and the conversation so far. Adding the note says who was told, and the ticket's history has it. An agent the note mentions isn't also told the ticket was updated.
    • On the ticket, a note that mentions you says Mentions you, and your name in it stands out.
    • Mentioned me, beside Mine on the tickets list and the board, lists the open tickets with a note that mentions you.
    • Editing a note tells only the agents it mentions that it didn't before, and someone taken out of it no longer counts as mentioned. An archived note mentions nobody.
    • Mentions are for internal notes only. In a public note or an email reply they are plain text and tell nobody, and the reply box says so if you switch with mentions in it. Making a note public in Edit does the same.
    • The email is in Admin > Mail > Previews, as Mentioned in a note under Tickets - to your team.
  • Client onboarding (new)
    • Onboard a client, under New Client's arrow on Clients, takes a new client on a step at a time on a page of its own: the company, its locations, its people, the agreement, billing, IT basics and a kickoff project. Each step saves as it goes - the first makes the client - so you can leave part way and carry on later. Every step after the first has Skip for now, and any can be gone back to from the steps along the top. New Client stays as it was, for adding one in a window.
    • The company: name, short name, industry, website, how they found you, tags and notes. The client is made as New Client makes it, with its website's domain looked up and its certificate added. For a lead, saving it makes them a client.
    • Locations and People add one at a time and stay on the step for another. The first location is the primary, and the first person the primary contact. Each person can be a billing or technical contact, and be given client portal access, which emails them a link to choose their password - with mail set up, and not for an address that signs in already.
    • Agreement: a contract from one of your templates - a fully managed one picked first - from the day you pick, with you as its owner. Send it to sign sends it as a draft, with a note, to one of their people, as the contract's own Send to sign does. With no templates yet, it says where to make one.
    • Billing: who gets the invoices, their tax ID and tax rate or exemption, payment reminders, and the agreement's recurring invoice - how much, how often, the first invoice, its category, line and tax, and whether each is emailed. While the agreement is out to sign, its recurring invoice waits, paused, until it is countersigned. A mistake anywhere in the step saves none of it.
    • IT basics: their domains, one to a line, each looked up as it is added, with its website certificate - and vendors and licenses from your templates. Those that Admin > Vendor Templates and License Templates say every client, or clients with their tags, should have are listed ticked, and the rest are in a picker. A license comes with its template's seats, renewal and vendor, the vendor made for them too when they haven't got it. What they have already is shown as had, and nothing is added twice.
    • Kickoff: a project from a project template - one with onboarding in its name picked first - named for the client, with its manager, contact, start and due dates, and its tickets raised from the template, assigned to the manager if you like.
    • A step ticks itself off however it was done, here or anywhere else in ITFlow: once the client has a location, a contact, a contract, a recurring invoice, a vendor or license, or a project. The summary at the end lists every step with where it stands, and Finish.
    • The client's overview shows Finish onboarding, with how far it has got, a chip for each step and Carry on, until every step is done or skipped, Finish is pressed or its × is clicked. The client's menu has Onboard, Carry on onboarding or Onboarding, and the clients list has it on the row of a lead and of a client part way through. Edit on a lead offers it too, and so does Get started's Your first client.
    • Onboarding needs Clients at Modify, as adding a client does. Agreement is there with Sales at Modify and the accounting module on, Billing with accounting, IT basics with documentation, and Kickoff with Support at Modify and ticketing on; domains and licenses need Support at Modify. Starting onboarding and finishing it are in the client's audit log.
  • Get started (new)
    • On a new install, the first admin to sign in after setup is taken through what is left to set up, a step at a time: Cron, Email, Backups, Billing, Support, Your team, Starter content and Your first client. Each step is a short form of its own and saves on its own, with Skip for now - It's just me, on Your team. Billing goes with the accounting module and Support with ticketing, so a step whose module is off isn't there.
    • A step ticks itself off from the settings themselves, however they were set - here or on the settings page: cron heard from and on, a way to send email and an address to send it as, nightly backups on, an hourly rate, default response times, a second agent, ticket or document templates or canned responses, a client. The steps along the top show which are done, skipped or still to do, and any can be gone back to.
    • Cron: the crontab line to copy, whether cron has been heard from - checked every 10 seconds while the step waits, so it ticks itself off once the line is in - and Turn cron on, as it ships off.
    • Email: the SMTP server ITFlow sends through and the address and name it sends as, checked as Admin > Mail checks them, with Save and send me a test. Optionally a support mailbox, read into tickets, whose address ticket emails then come from when they had none. Microsoft 365 and Google Workspace are set up in Admin > Mail, and the step keeps them.
    • Backups: the backup key, to show and copy, with a tick to say a copy is kept somewhere else before nightly backups are turned on - when they are taken, the database or everything, and how many to keep and for how long.
    • Billing: the default hourly rate and net terms, which a client is billed on when its contract doesn't set them, a tax rate for new lines when there is no default (made and made the default), invoice and quote numbering, and - if you like - Stripe, with its keys put to Stripe before they are saved.
    • Support: business days and hours, the standard response times for each priority as the default SLAs (Urgent answered in 1 business hour, High 2, Medium 4, Low 8, with no time to resolve by - one can be added in Admin > SLAs; an SLA with the same response time and none to resolve by is used when there is one, and only priorities with none get them), ticket numbering and the client portal on or off. Open tickets are stamped afresh with the hours and response times saved.
    • Your team: everyone who signs in and everyone invited, and Add someone - a name, an email and a role. With email set up they are invited, as New User in Admin > Users does, and choose their own password from the link (see Agent invites). With no email set up yet, ITFlow makes their password and shows it to you once to give them, and they change it once signed in.
    • Starter content: the packs from Maintenance > Starter Content, each with how much it adds, ticked to add - and Pick which under each, as on that page (see Starter Content, Pick which). Your first client: a name, website and contact, made as the Clients page makes one - or the demo data, to look around first.
    • The dashboard shows Finish setting up, with how far it has got, a chip for each step and Carry on, to admins until every step is done or skipped. Its × stops it showing there. Admin > Get started opens it any time, with how many steps are done or skipped beside it, and a summary at the end lists every step with where it stands.
    • Until every step is done or skipped, the top of Get started has Do it later and Skip all. Do it later goes to the dashboard and leaves each step as it is, for Finish setting up to carry on from. Skip all, once you say yes, skips every step still to do, and the dashboard stops showing Finish setting up - each step is still in Admin > Get started, and doing one ticks it off as ever.
    • The setup wizard's last step says Get started comes next. An install set up with setup_cli.php --skip-get-started doesn't open it on signing in or show it on the dashboard - it is in Admin > Get started.
  • Starter Content, Pick which (new)
    • Under each pack on Maintenance > Starter Content and Get started's Starter content step, Pick which lists what the pack would add, to choose one by one. Every item starts ticked, so leaving it alone adds the whole pack as before; All and None tick or clear them, and the pack's count says how many are picked. What is here already isn't listed.
    • Each item shows as it will look: a tag as its pill, a category as its chip in its colour and icon, a product with its price and how it is billed - with what each is for. Categories are grouped by type, tags by what they tag and products by income category.
    • Preview, on ticket, project, document, vendor and license templates, canned responses and products, opens the whole of it in a window: a ticket template's subject, category, priority, tags, request form, what it says, what it asks for and its tasks; a canned response's reply type, ticket categories, status and what it says; a document template's review period, fill-ins and content; a vendor's support site, status page, portal and what support asks for; a license's type, renewal, vendor and product; a product's price, billing, code and category; a project template's tickets in order. The words filled in as each is used are marked.
    • On Maintenance > Starter Content, a pack's Add brings only what is ticked - and waits while nothing is - and Will be added says how many. Add all still adds every pack whole.
  • Asset types and details (new)
    • Five more types: NAS, UPS, PDU, Modem/ONT and NVR/DVR, each with its own icon and colour. The asset list's tabs follow them: NAS units sit with the servers in Servers & NAS, UPSs and PDUs in a new Power tab, modems with the network kit and NVRs under Other.
    • Each type asks for what it has and nothing else. A virtual machine has no serial number, purchase or warranty, a mobile phone or tablet no network, and a display no OS. Kit that runs firmware - network gear, UPSs, printers, desk phones, cameras - calls the OS field Firmware, and only computers, phones, tablets and displays are assigned to someone. A field a type doesn't ask for still shows while it holds something, so nothing is lost when a type changes. The examples in each field follow the type picked.
    • Details: the form has a Details section with what only that kind of thing has - CPU, RAM and storage for a laptop, desktop or server; host, vCPUs, RAM and storage for a virtual machine; ports, SFP ports, PoE budget and management for a switch; ports and SFP ports for a firewall; the Wi-Fi standard for an access point; drive bays, drives, raw and usable capacity, RAID and ports for a NAS; capacity (VA), output, runtime, outlets and when the battery went in and is to be replaced by for a UPS; kind, outlets and input for a PDU; circuit ID, ISP account and ports for a modem; channels, storage and ports for an NVR; colour and supplies for a printer; the extension for a desk phone; number, carrier and IMEI for a mobile phone or tablet; size and resolution for a display, and resolution for a camera. A value a detail can't take - a word for a number, a date that doesn't exist - is left out, and the message after saving says which and why.
    • Ports: saving a switch with 24 ports and 4 SFP ports adds the interfaces it doesn't have yet - 02 to 24 after its primary 01, then SFP 1 to 4 - as Ethernet and SFP, carrying on from the ports already there. A new count adds the ones missing and a lower one takes none away; an edit that leaves the counts as they were adds nothing back. Wi-Fi, VLAN and other virtual interfaces don't count as ports, and neither does a management port such as mgmt0 or iDRAC. The form says how many the asset has now.
    • The asset page's Hardware card lists the details, and the quick look the main ones. A server or NAS lists the virtual machines that run on it, and a VM links its host - one of the same client's servers or NAS units.
    • UPS batteries: the replace-by date shows how long is left on the asset page, a badge in the asset list as it nears or passes, and agents are notified in ITFlow 45, 7 and 1 days before, as for warranties.
    • The asset list has a Details column to show, with each asset's main details. Assigned To is offered only on assets that are assigned to someone, and New on each tab says what it adds - New Network Device, New UPS.
    • Copy brings the details along, but not those of the one unit - IMEI, phone number, extension, circuit ID and ISP account. Transfer to Client takes the details and the interfaces' types to the new client, all but a VM's host.
    • Export can add any detail as a column of its own, and its type filter has the list's tabs. Import reads details from columns after the usual twelve, headed as the export heads them (Ports, RAM (GB), Host and so on), keeps those the row's type has - a host by its name, even one further down the file - adds the ports they count, and says which values it couldn't keep. The API reads and writes them as asset_specs.
    • Racks offer NAS units, UPSs, PDUs, NVRs and modems with the servers and network kit when racking an asset, and list them under Not racked yet - a NAS or UPS at 2U, a PDU or modem at half depth to start with. In the client portal's Your IT, UPSs and PDUs are under Power and NAS units with the servers, and the client export and overview group them the same way.

Bug Fixes

  • Client Portal: setting up automatic payment on a recurring invoice now only works on that client's own invoice and saved card, and needs a contact with billing access. On the agent side, the saved card has to belong to the invoice's client.
  • Search: the full results page no longer puts passwords into the page. Showing or copying a password from there is logged, the same as everywhere else.
  • Credentials: saving a credential after your vault session had timed out wiped its password. That no longer happens.
  • Credentials: saved 2FA secrets with spaces or lowercase letters produced no code. Imports carried values from one row into the next and accepted any file type. Share links stayed open after their credential was deleted.
  • Credentials: a credential entered on the asset form with only a username was not saved.
  • Shared links: a mail filter or chat preview opening a one-time link used it up before the person it was for could open it.
  • Shared links: anyone holding a login link could keep getting its 2FA codes until the link expired, even after its one view was used. Codes now go only to the browser that opened it, for 30 minutes.
  • Shared links: the link's key, and for a login the key that decrypts it, were in the address, so they ended up in web server logs.
  • Billing: editing an invoice or a quote set its discount to zero, and a discount given on a new quote was ignored.
  • Billing: changing the quantity of a stocked product on an invoice did not move its stock.
  • Billing: editing a payment's amount did not update the invoice, so it could stay Partial when paid in full or Paid when not.
  • Billing: deleting a recurring invoice left its autopay behind.
  • Billing: an invoice made from a quote was due counting from today instead of the invoice date, and with no terms when the client had none set.
  • Billing: the client's view of a quote offered Accept on a viewed quote that had run out, and the invoice and quote views put the browser's HTML into the history.
  • Billing: copying an invoice logged its old number with the prefix twice.
  • Billing: the nightly late fee line was saved without its subtotal, and the invoice total was raised by hand instead of from its lines.
  • Billing: the email telling your team a quote was accepted or declined linked to a page that doesn't exist. It now opens the quote.
  • Billing: a batch payment's receipt went to the primary contact only, and sending it wrote a PHP warning.
  • Expenses: receipts were served straight from uploads/expenses/, so anyone with a receipt's address could open it without signing in.
  • Expenses: Bulk Delete's Yes, Delete! button did nothing. Bulk Set Client could change expenses of clients the agent is not allowed to see.
  • Expenses: the New Expense window opened for agents who could not save one. Deleting an expense with no receipt, or replacing a receipt when there was none, wrote a PHP warning.
  • Expenses: there was no way to set how an expense was paid, so the default payment method in Admin > Settings > Defaults went unused. A photo receipt downloaded with .pdf on its name.
  • Recurring expenses: editing one, even just its amount, moved its next date to the day and month in the form. The expenses they made left out the payment method, and a recurring expense could not be paused.
  • Recurring expenses: the list's date filter went by when each was set up, not when it runs.
  • Products: Restore in the archived list's bulk actions did nothing. Deleting a product left its stock records behind.
  • Products: Add Stock could record zero units, or link an expense that no longer existed.
  • Products: deleting an invoice did not put its stock back. Copying an invoice or making one from a quote lost the link to the product, so the copy took no stock and the product did not count it as sold.
  • Products: a stock change for a fraction of a unit, such as 1.5, was rounded to a whole number.
  • Billing: Make Invoice on a quote could make a second invoice when pressed twice or from two tabs, and could invoice a quote that was never accepted or had been declined. Its audit log entry named the quote with the current quote prefix instead of the quote's own.
  • Recurring invoices: if cron missed a night, that recurring invoice stopped billing until someone moved its date. The missed run is now billed the next night, up to 7 days late.
  • Invoicing a ticket: on an invoice that already had lines, its line went in near the top instead of at the end. A ticket already on an invoice could be billed again from a second window, and a ticket with 100 hours or more logged had no hours filled in.
  • Invoicing a ticket for a client with no hourly rate filled in nothing. It now takes the default hourly rate, as the Unbilled Work report already did.
  • Recurring invoices: the email went to contacts that had been archived, and could reach the same address twice.
  • Client Portal: the recurring card on the home page didn't show for a client with only yearly recurring invoices, and counted archived ones.
  • Copy buttons in many places, such as pages that already had a window open, showed Copied! but copied nothing. Copy guest URL on invoices and quotes jumped the page to the top, and copying an empty field wiped your clipboard.
  • Tickets: clicking Send on a reply that didn't go - with the reply box shut - cleared its time worked.
  • Picking from a list: the canned response list, a field's list of choices (such as Status after this) and the quick menus (such as a ticket's status in Details) stayed open when you clicked into the reply, or any other text box with a toolbar, and on a computer the only way to close one without picking was to click somewhere else on the page. A click into the text box now closes them, and each list has Cancel at the top (Done where you tick several). On the canned response list it is beside the search, and puts you back in the reply.
  • Tickets: an agent without sales access who edited a ticket turned billable off. Email reply was offered when no mail server was set up. The timer threw an error on resolved tickets, and the due date was not shown.
  • Assets: editing some asset types wiped the OS, firmware, purchase and warranty fields. The add form broke when there were no OS values, and bulk assigning a contact or location could change assets of another client.
  • Assets: the quick look's notes could save over the wrong asset's notes, and its Credentials tab showed without permission.
  • Racks: every rack save wiped the model. Non-admins could not archive a rack, and positions outside the rack were accepted. Deleting or transferring an asset, or deleting a client, left rack items and racks behind.
  • Contacts: saving a contact with the client portal turned off cleared their portal sign-in method. Replacing a photo tried to delete a folder. The contacts list scrolled sideways on phones, and archived assets and credentials showed on the contact page.
  • Files and documents: deleting a folder orphaned its files, documents and subfolders. They now move up a level. The upload size check was skipped, and skipped uploads were not reported.
  • Files and documents: searching with quotes did not match. PDF export needed edit access, and deleting a file or document left its links behind.
  • Licenses: editing a Subscription or Open Source license cleared its type. Adding a device or user twice caused an error, and license deletes ignored the destructive deletes setting.
  • Vendors: deleting a vendor from a template unlinked every other vendor from that template. Vendor websites without http:// did not open. New vendor templates lost the phone country code, and pushing a template update with nothing ticked caused an error.
  • Domains: lookups, edits, the background check and API updates wiped the expiry date and records when a lookup came back empty. Edits also dropped a past expiry date, and every edit waited on slow lookups.
  • Domains: expiry alerts were sent for archived domains, and bulk archive needed delete rights. A new client's website was not added as a domain when its expiry could not be found, and the WHOIS summary was cut short.
  • Certificates: deleting one logged a blank name, and certificates without an issuer organization did not read correctly.
  • Projects and services: deleting a project now keeps its tickets, and deleting a client deletes its projects. Archived services are left out of counts and lookups.
  • Records of one client can no longer be linked to another client's records: locations, contacts, vendors, assets, networks, domains, certificates, credentials, documents, files and projects.
  • 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.
  • Accounts: the Accounts page, the dashboard and the account pickers counted archived payments, income and expenses in a balance, while Transactions left them out, so the same account could show two balances.
  • Accounts: New Account saved whatever was typed, such as no name, the name of another account or a balance of words. Edit could only change the name and notes.
  • Transfers: a transfer could be saved from an account to itself, for nothing, or with no date. Editing one took which expense and income to change from the form, so a changed form could rewrite any expense or income. Neither side recorded who entered it.
  • Transactions: opening it for an account that no longer exists wrote PHP warnings. It now says so and shows every account's.
  • Client Portal: a file attached to an internal note could be downloaded from the client portal by a contact who could see the ticket, by its number. Only the ticket's own files and those on replies the client can read download now.
  • Client Portal: files on a closed ticket could not be downloaded from the portal.
  • Client Portal: a new ticket could be linked to another client's device, or to a category that isn't a ticket category, by changing the form. Those are ignored now, and a plain contact can only pick a device they use.
  • Client Portal: a reply of nothing but empty lines was accepted, and a new ticket could be raised with no details.
  • Client Portal: Pay invoices and Recurring invoices wrote PHP warnings onto the page when no payment provider was set up.
  • Client Portal: on most pages the browser blocked the arrows on drop-downs, the close crosses and the editor setup, as the portal's security policy didn't allow them.
  • Client Portal: paying an invoice with a saved card didn't check the contact had billing access, and couldn't pay a partly paid invoice. A double click could charge the card twice.
  • Client Portal: the saved cards page asked Stripe about every card each time it opened, and showed an error instead of the cards when Stripe couldn't be reached. It started a Stripe checkout on every visit too, where it now starts one when Add a card is clicked.
  • Client Portal: a technical contact could change the primary contact's details, including the email they sign in with, by changing the edit form. Contacts can only be changed by the primary contact now, never their own record or another primary contact's, and only their own company's.
  • Client Portal: a contact whose portal access was turned off stayed signed in until their session ran out.
  • Client Portal: documents added in the portal recorded the contact as their author in place of their portal user, so ITFlow showed the wrong person or no one. An uploaded file's name went into its document unescaped.
  • Client Portal: a page turning a contact away after it had started could leave only the top bar on screen, as the redirect it fell back on was blocked by the portal's security policy.
  • Client Portal: Forgot password accepted a link with no token, so anyone could have the portal email its "password was reset" message to any address. Nothing changed, but the email went out.
  • Client Portal: password links were kept in the database as they were sent, so a copy of the database held working links. Only a fingerprint of each is kept now.
  • Client Portal: changing a password in the portal didn't check the 8 character minimum.
  • 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 &amp; instead of &.
  • The empty password strength meter showed on the credential form, and page numbers ran off the screen on phones.
  • Guest invoice payment pages broke instead of showing their error message.
  • Recurring tickets: if cron missed a night, that recurring ticket stopped raising tickets until someone fixed its next run date. The missed ticket is now raised on the next run and the schedule carries on.
  • Networks: Export from All Clients with no client picked did not work.
  • The add category button on the products filter did nothing.
  • Tags: saving a client, contact or asset with the same tag picked twice stopped with an error, and those forms took any tag sent with them, another kind's included. Every record form now keeps only its own kind's tags, each once.
  • Tags: the + beside the Tags field in record forms opened Admin's New Tag window, which reloaded the page and lost what had been typed into the form, and showed an error to anyone who wasn't an admin.
  • Tags: a tag saved with no color, or a color word such as dark, showed white text on no color at all.
  • Tags: the Archived button on Admin > Tags showed the same tags as without it, as nothing could be archived.
  • Categories: editing a category whose color was saved as a word, such as lightblue, turned it black, as the color picker can't show words. The ticket list, a ticket's quick look and the recurring tickets list left those colors out, and the reports drew them in a color of their own.
  • Categories: the icons of contact and asset note types didn't show in the type pickers, and making a category from the + beside a category picker left out its description.
  • Categories: the + beside a category picker reloaded the page once the category was made, losing the form that was being filled in. On the quote made from a ticket it made an expense category, for a picker of income categories.
  • Clients: saving a client with no referral made a referral with no name, and typing one that matched an archived referral made a second of it.
  • Licenses: the tab counts ignored the license type filter, so a tab could say more than it showed.
  • Racks: the Archived button broke onto two lines on a laptop-sized screen.
  • Roles: a role's name went into the "created" and "edited" messages as it was typed, so markup in it ran on the page.
  • Roles: an admin role could have admin taken off while its agents were the admins using it, your own included, shutting them out of Admin. Archived roles couldn't be seen or brought back.
  • Roles: being turned away left "You are not permitted to do that!" waiting on the next page opened.
  • 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.
  • Tickets: a ticket raised from a recurring ticket never emailed its contact or the new-ticket address, as the settings it read were never loaded.
  • Tickets: the email telling someone their email had become a ticket showed an apostrophe in its subject, or in their name, with a backslash in front of it.
  • Tickets: SLA alert emails were queued when outgoing mail was not set up. For a ticket raised or answered by email, the new ticket and client replied emails showed an apostrophe in the subject, the client's name or the reply with a backslash in front of it.
  • Admin > Mail: the test email was queued when outgoing mail was not set up, or when what was typed was not an email address, and greeted everyone as "Chap".
  • Ticket templates: New Tickets on the clients list gave only the first client's ticket the template's tasks, dropped their times, and couldn't be given a template at all from the window. It took any priority, category or agent posted to it, and its "primary contact" never did anything.
  • Ticket templates: changing one of a template's tasks moved it to the top of the list.
  • New Ticket and recurring tickets: once the window had been opened more than once, Add Task added a task row for every time it had been opened.
  • Forms with tabs, like a ticket template's: with a required field left empty on a tab that wasn't showing, Save did nothing and said nothing. That tab now opens, on the field.
  • Email on the contacts list queued an email to a contact with no email address, which then failed in the mail queue. Email on the clients list included archived contacts. Both took any From address posted to them.
  • Emails: a saved card's confirmation and the email about a change to an agent's account were only sent when an SMTP host was filled in, so they were skipped when mail goes through Microsoft 365 or Google with no host. The saved card's email began "Were writing".
  • 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.
  • License templates: the type picker showed a stray "<" at the top.
  • Vendor templates: Delete was only offered to the built-in Administrator role, not to other admin roles.
  • Vendor templates: Add to clients skipped a client whose vendor from the template had been archived, as if it still had it.
  • Vendors: a support site saved with a scheme other than http or https is no longer made a link.
  • App Logs: the category filter did nothing - it read a misspelt field - and the date filter was slow on a large log.
  • App logs: an entry over 1,000 characters with an accented letter or a symbol where it was cut could fail to save, stopping what was logging it.
  • Admin > Settings > Defaults: Payment Method and Expense Payment Method listed the payment method categories that moved to Admin > Payment Methods in 2.3.1, so a new install had nothing to pick. They list Admin > Payment Methods now.
  • Messages, hints and window titles were reworded where they were unclear, and bulk windows say "1 asset" or "3 assets" correctly.
  • Setup: a wrong database password, host or name ended setup on a PHP error page instead of saying what was wrong, and a config.php that couldn't connect broke every setup page.
  • Setup: the messages after a step - the account made, an avatar left off, a restore that failed - were never shown.
  • Setup: the database step ran ITFlow's tables over a database that already had tables in it, and wrote config.php before knowing they had been made.
  • Setup: the company, region and Finish steps could be sent before the steps they rely on - Finish could close setup with no company.
  • Setup: Finish with sharing turned on printed the ITFlow project's reply and stopped there instead of going to the sign-in page, and waited up to a minute when the project couldn't be reached.
  • Setup: Finish could land back on setup rather than the sign-in page, while PHP's opcache still held the config.php from a moment before.
  • Setup: the show-password eye and copy buttons did nothing - the page's script stopped at the editor, which setup doesn't load.
  • Setup: on a phone the wizard was squeezed beside its menu, a few letters to a line.
  • Setup: a password with a space at the start or end could not sign in - setup saved it without them.
  • Setup: the account, company and region steps saved whatever they were sent - no name, an email that isn't one, a language, currency or timezone not on the list - and an avatar or logo was taken on its name alone.
  • Setup: the company's phone was saved without its country code.
  • Setup: a backup larger than PHP takes was never answered - the page came back as it was, without the message about restoring from the command line.
  • Setup: after a restore, the message that it had worked was lost on the way to the sign-in page.
  • setup_cli.php --non-interactive stopped and waited at the optional questions - the address, phone, website and branch.
  • Settings > Theme: the colour names wrapped a letter or two to a line - the light and dark picker on Preferences had taken the swatches' class name, and its width.
  • Settings > Theme: any name sent was saved as the theme, and the favicon upload said it had worked whatever was sent.
  • Settings > Modules: saving with a white-label key that had run out wiped it, and the key's details were shown without escaping.
  • Settings > Company details: an email or website that wasn't one was saved as it was.
  • Settings > Localization: a language, currency or timezone not on the list was saved.
  • Settings > Tickets, Projects, Quotes and Invoices: the next number could be set back to one already used, so two tickets, projects, quotes or invoices got the same number.
  • Settings > Tickets, Quotes and Invoices: an email address that wasn't one was quietly blanked when saved.
  • Settings > Tickets: the time before a resolved ticket closes was only checked by the browser, and saving logged PHP warnings.
  • Settings > Defaults: the start page could be anything typed in, and was put into every page's logo link as it was. A default account archived since was dropped the next time Defaults was saved.
  • Settings > Single sign-on: the Microsoft Entra client secret was put into the page, in the password field's value.
  • Settings > Security: requiring a login key with none set saved as not required, saying it had been saved.
  • Settings > Mail: connecting Microsoft 365 switched both Sending and Receiving to Microsoft 365, whichever one it was connected for - moving sending off a working server, or starting to read a mailbox that wasn't set up.
  • Settings > Mail: the SMTP and IMAP passwords, the OAuth client secret and its tokens were put into the page.
  • Settings > Mail: a From address that wasn't an email address was saved blank, and From names lost their punctuation - "PittPC, LLC" was saved as "PittPC LLC".
  • Settings > Mail: a password or secret lost a space at either end when saved, and anything from a < up to the next > (or to its end).
  • Settings > Mail: pressing Enter on the OAuth tab went off to Microsoft to connect, rather than saving. Changing the OAuth app or its refresh token went on using the old access token until it ran out.
  • Payment Providers: a Stripe key that Stripe turned away was saved anyway, with an error after; the Edit window showed the secret key; saving it logged and said an empty provider name, with PHP warnings; the Enable Expense switch did nothing; and there was no way to turn a provider off.
  • Accounts: archiving the account online payments go into pointed to Admin > Settings > Online Payment, which no longer exists - it is Admin > Payment Providers.
  • Saved payment methods: removing a card quietly stopped autopay on the recurring invoices that used it, with nothing on them to say so.
  • Settings > Notifications, and recurring invoices: the note that recurring invoices are made as drafts pointed to the billing settings, but the switch is in Notifications.
  • Mail Queue: cancelling an email marked it Failed, and an email to a bad address or with no from address showed Failed with no time.
  • Mail Queue: Resend was only offered once all four tries were used up, and the next failure quietly gave a resent email three more.
  • AI: Reword on the admin pages' editors (document templates and the like) always failed - it asked a page that isn't there.
  • AI: the AI provider window showed its API key; adding or changing one said "AI Model ... created" or "edited"; and the model window picked the last provider in the list whichever one the model was on.
  • AI: with no prompt on its model, Reword sent the text with nothing to say what to do with it.
  • AI: a ticket summary and a document template draft went into the page as the model wrote them, so something in a ticket could get a script into the summary. They go through the same clean-up as documents now.
  • setup_cli.php: a name with an apostrophe in it stopped the install part-way, a password with a space at either end couldn't sign in, and it wrote config.php before knowing the tables could be made - over a database that already had tables in it, too.
  • API: looking a contact up by phone number (contact_phone_or_mobile) found any client's contact by its mobile number, even with a key whose agent can only see some clients. It only finds the clients' they can open now.
  • Products: the Receive window listed the last year of expenses - vendor, description and amount - to anyone who can change products, without Financial access, and every client's to an agent who only sees some clients. It lists them only for a role with Financial access, and only for clients the agent can open, and receiving stock links only to one of those.
  • Accounts: an agent who only sees some clients saw every client's payments and income on the account ledger, the reconcile page and the ledger export - the client and what each was for. Those lines stay, so the balance still adds up, but show just the date, kind and amount, with Another client. Searching, and the client filter, only find clients the agent can open.
  • Tax Collected: the exempt sales by client named every exempt client to an agent who only sees some clients. The ones they can't open are added up as one line, Clients you cannot open.
  • Agents who only see some clients saw the names of the others in the client filter of the asset, certificate, contact, credential, domain, location, license and ticket export windows, and in Transfer to Client on the assets list; and in the expenses and recurring expenses on a company vendor's page. My Activity named and linked a client they had been refused, and New Client's duplicate check named any client with a name like the one typed, so typing names could list them.
  • Admin > Cron: the window for a job opened for any agent, not only admins (saving it was already refused).
  • Online payments: switching Stripe off in Admin > Payment Providers hid the Pay button, but an invoice's pay link still took a card, and saving a card still worked. Both stop while it is off. A payment already made still comes back and is booked.
  • Online payments: opening Stripe's return address again booked the same card payment a second time if its invoice had been reopened since - edited, or a payment taken off.
  • Records per page: the list footer changed it without the page's security token, and a value of 0 broke every list for that agent. Only the sizes it offers are taken now.
  • Admin > Debug stopped with a fatal error at the bottom of the page.
  • Reports: exporting a report logged a name with an apostrophe in it as O\'Brien.
  • Network interfaces: setting several interfaces to a network that had just been deleted put them on a network that no longer exists.
  • API: credentials/create and credentials/update with the wrong api_key_decrypt_password saved the username and password so they could never be read again, and credentials/read with the wrong one answered success with every username and password false. The wrong one is refused now.
  • API: credentials/create and credentials/update ignored credential_vendor_id and credential_software_id, and took another client's contact or asset. The vendor and license are linked now, and another client's contact, asset, vendor or license is left off.
  • API: assets/update without asset_ip, asset_mac or asset_network_id wiped those on the asset's primary interface.
  • API: tickets/resolve answered success False although it had resolved the ticket. software/read by software_name failed with a database error. clients/create without client_currency_code made a client with no currency - it gets the company's now. certificates/create said only that it failed - it says what was wrong. clients/archive logged as a contact being archived. A request that wasn't GET or POST printed its method back.
  • Custom links: an email (mailto:) or phone (tel:) link in the agent, admin or reports menu went to "unsupported://" instead. The client portal's menu took any address, javascript: included.
  • Custom links: deleting one said "Custom Link deleted" without its name, and the list's new-tab tick never showed.
  • Ticket emails: resolving a ticket with the Resolve button or on the board emailed the client a Resolved email with nothing about the ticket in it. It now carries the conversation.
  • Email parser: a reply sent as plain text kept the whole email it quoted - it is now cut at the reply line, like an HTML reply.
  • Ticket link: reopening a resolved ticket from it left the resolution SLA as it was, and closing it didn't stop the SLA clock. Both now work as they do from the client portal.
  • Ticket link: a rating could be changed afterwards, or set to any word by editing the address, and the custom action for a rating never ran.
  • Ticket link: the client's photo never showed on their own messages.
  • Ticket link: the key in the link matched in any mix of capital and small letters. It has to match exactly now.
  • Admin > Debug counted every row of every table and every file in uploads on each visit, which could take minutes on a big install. Rows are estimated unless you ask for an exact count, and counting files stops after a few seconds.
  • Admin > Debug checked the certificate of whatever host the browser asked for rather than ITFlow's own, said HTTPS was off behind a proxy that handles it, and counted shell_exec being on as a pass.
  • Admin > Debug flagged every whole-number column as a different type on MySQL 8, which no longer shows their display widths.
  • Ticket statuses: deleting one left the tickets in it pointing at a status that no longer existed.
  • Ticket statuses: a hand-made request could rename one of the five built-in statuses or switch it off, and saving a built-in one said "Custom Ticket Status edited".
  • Ticket statuses: badges always had white text, unreadable on a light colour.
  • Tickets: the status filter dropped a switched-off status as soon as the filter was changed.
  • Payment methods: renaming one left every record that used it under the old name, so lists, filters and reports split it in two, and deleting one left its records with a name that couldn't be picked again.
  • Payments: editing a payment made online (recorded as Stripe), or one with a method no longer in the list, showed no method and couldn't be saved without picking another.
  • Payment methods: the same name could be added twice, and the description had no label.
  • Clients: a client with a contract couldn't be deleted, because the database refused it.
  • Contract templates: the after-hours rate of a new template was never saved, rates were cut to whole amounts, and net terms written as "Net 30", as the field suggested, were saved as 0.
  • Contract templates: Edit showed the rates empty, so saving a template set them to 0.
  • Contract templates: the admin menu's link to Contract Templates was commented out, so the page could only be reached by typing its address.
  • API: a key acting as an agent limited to certain clients could archive or restore another client's contacts, credentials, documents, domains, locations and networks, archive or restore the client itself, and resolve or close its tickets, by naming that client in the call - only creating, editing and deleting checked the client named. Archiving a contact that way also archived its portal login.
  • API: an asset, contact, network or document made or edited through the API could be given another client's contact, location, vendor, network or folder, which then showed on this client's record.
  • Assets: linking a contact to an asset from the contact's page took another client's contact, whose name and details then showed on the asset to anyone who could open it.
  • Agents limited to certain clients could see the names of other clients' records: in the file name of the asset import template, the filter line of an export, and the messages after moving files to a folder, unlinking a credential, contact or asset, removing an asset from a ticket, and moving tickets into a project or interfaces onto a network in bulk.
  • Quotes: Accept and Decline on the page a client opens from a quote email were plain links, so a mail scanner or link preview that opens every link could answer the quote. They are now buttons.
  • Exports: exporting with filters that match nothing left a blank page. It now says nothing matches and stays on the list.
  • Assets: unlinking a contact from an asset with a page opened earlier took off whichever contact the asset had been given since.
  • Archiving, restoring or deleting an asset, contact, note, invoice, quote, payment or recurring invoice, or saving one, that someone else had just deleted showed an error page or warnings and wrote history for a record that was gone. It now says the record no longer exists.
  • Settings: the ticket prefix's letters-and-hyphens check didn't run in the browser - its pattern is refused by current browsers. ITFlow still checked it on saving.
  • Assets: importing assets gave a row with no physical location or notes those of the row before it, saved rows without a purchase date with 0000-00-00 instead of none, and raised warnings for an Assigned To or Location that isn't one of the client's. A file with any column past the twelve was refused.
  • Assets: a type sent through the API or an import in other capitals, such as server, was saved that way, so the asset didn't get its type's icon or fields. It is saved as the list spells it now.
  • Assets: Transfer to Client dropped the type of every interface it copied, so SFP cages came across as untyped.

Developer Updates

  • Database 2.8.0 to 3.0.8, one file per step under admin/database_updates/, with db.sql matching 3.0.8:

    • 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.
    • 2.8.3: domain_tags (tag type 7), and domain_auto_renew, domain_dmarc, domain_dkim and domain_refreshed_at.
    • 2.8.4: certificate_issued_at, certificate_names, certificate_fingerprint, certificate_check_status and certificate_checked_at, backfilled from the stored PEM.
    • 2.8.5: recurring_ticket_paused_at, and orphaned ticket task, approval, history and SLA rows removed.
    • 2.8.6: project_start, project_budget_hours, project_contact_id and a ticket_project_id index.
    • 2.8.7:
      • service_archived_at, service_reviewed_at and service_review_interval, plus a service_review_due index
      • the category and backup columns widened
      • the service link tables deduplicated and keyed on the pair, service_domains foreign keys, and a new service_tickets
    • 2.8.8:
      • on events: event_repeat_interval, event_repeat_until, event_repeat_skip, event_sequence and event_created_by
      • on attendees: attendee_user_id and attendee_invited_at
      • user_calendar_feeds, and email_queue.email_cal_str changed to text
    • 2.8.9:
      • network_type and network_vendor_id, with types backfilled from the name and the CIDR prefix moved into the address
      • ip_type and ip_mac, and interface_vlans
      • interface_network_id and network_location_id indexes
    • 2.9.0:
      • on shared_items: item_version, item_name, item_payload, item_vault_key, item_passphrase, item_verify_email, item_code, item_code_sent_at, item_failed_attempts, item_notify, item_last_viewed_at, item_created_by, item_received_at, item_ended_at, item_ended_by and item_end_reason, with keys on the client, the type and record, and the expiry
      • item_note widened to 1000, item_view_limit made NOT NULL DEFAULT 0 (NULL was unlimited), existing item_key values replaced by their SHA-256, and item_name filled from the shared record
      • settings.config_share_max_hours (720) and config_share_require_protection (0)
    • 2.9.1: recurring_invoice_items.item_seat_source, item_seat_filter (JSON) and item_seat_minimum, and recurring_invoices.recurring_invoice_anchor_day, backfilled from the day of each next date.
    • 2.9.2: settings.config_time_increment (15), config_time_rounding ('nearest'), config_time_minimum (0) and config_time_item_name ('Support [Hourly]').
    • 2.9.3: quote_items.item_frequency (NULL for one time, or month, quarter or year), quotes.quote_monthly_amount, invoices.invoice_quote_id and recurring_invoices.recurring_invoice_quote_id. Invoices are linked to their quote from the "Invoice created from quote QUO-n" history the old Make Invoice wrote, matched on the client too.
    • 2.9.4: products.product_cost, product_vendor_id, product_vendor_part, product_notes, product_frequency (NULL once, or month, quarter, year), product_seat_source (NULL, contacts or assets) and product_track_stock, set for products with any product_stock rows. Keys on item_product_id in invoice_items, quote_items and recurring_invoice_items, and on product_stock.stock_product_id.
    • 2.9.5: product_stock.stock_qty becomes decimal(15,2), with stock_type (received, sold, returned, adjusted or written_off, default adjusted), stock_unit_cost, stock_invoice_id (keyed) and stock_user_id. Existing rows take their invoice from a note ending "Invoice n" and their type from the sign and the note. products.product_reorder_level and product_reorder_qty.
    • 2.9.6: products.product_asset_type (one of the asset types, NULL for none), product_make, product_model and product_warranty_months. assets.asset_product_id, asset_invoice_id and asset_invoice_item_id, keyed on the product and the invoice.
    • 2.9.7: product_prices - a product's price and cost history: price_old, price_new, price_cost_old, price_cost_new, price_source (edit, import or receive), price_user_id and price_product_id, keyed.
    • 2.9.8: settings.config_invoice_reminder_days ('1,30' - days from the due date, negative before it), config_invoice_reminder_repeat (30), config_statement_monthly (0), config_statement_day (1) and config_billing_email_attach_pdf (1), and clients.client_reminders (1).
    • 2.9.9: expenses.expense_created_by (0 for cron and payment providers), backfilled from the audit log's Expense Create entries, and expense_refund_of_id.
    • 3.0.0 (after 2.9.9, the way 2.0.0 followed 1.9.9): recurring_expenses.recurring_expense_frequency becomes varchar(10) (month, quarter or year, from 1 and 2), with recurring_expense_anchor_day (backfilled from the next date) and recurring_expense_end_date. recurring_expense_day and recurring_expense_month are dropped. expenses.expense_recurring_expense_id, backfilled by vendor, category, client and description from the day each recurring expense was set up.
    • 3.0.1: expenses.expense_billable (0), expense_markup (decimal(7,2), 0.00), expense_invoice_id (keyed) and expense_invoice_item_id. recurring_expenses.recurring_expense_billable (0), recurring_expense_markup and recurring_expense_recurring_invoice_id. settings.config_expense_markup (0.00).
    • 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.
    • 3.0.6: payments.payment_import_key and revenues.revenue_import_key (keyed), and income_import_rules (import_rule_match unique, import_rule_action - payment, income or transfer - import_rule_client_id, import_rule_category_id, import_rule_account_id, import_rule_used_count).
    • 3.0.7: no schema change. accounts.account_type is set from the name for every account without a kind from 1 to 6 (1 checking, 2 savings, 3 credit card, 4 cash, 5 payment processor, 6 other), the same way as accountTypeGuess(), with the 2023 liability types (20 to 29) as 3. Setup and scripts/setup_cli.php create the Cash account as type 4.
    • 3.0.8: account_reconciliations (reconciliation_account_id and reconciliation_statement_date keyed together, reconciliation_starting_balance, reconciliation_ending_balance, reconciliation_notes, reconciliation_created_by, reconciliation_created_at, reconciliation_finished_by, reconciliation_finished_at - NULL while in progress), and payments.payment_reconciliation_id, revenues.revenue_reconciliation_id and expenses.expense_reconciliation_id (keyed).
    • 3.0.9: account_reconciliations.reconciliation_opening (0, 1 for an account brought forward as reconciled) and reconciliation_adjustment (decimal(15,2), 0.00 - what its opening balance was corrected by), and payments.payment_deposit_transfer_id and revenues.revenue_deposit_transfer_id (NULL, keyed - the transfer out of a cash account they were deposited with).
  • 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.

  • New pages: agent/license.php, location.php, rack.php, service.php and vendor.php. The credentials, domains, certificates and tickets lists accept ?open=ID to open a record's quick look on load.

  • Select picker: js/itflow_select.js is behind every <select class="select2">, and is loaded first in includes/footer.php.

    • The native <select> is the only state. Setting .value, option.selected, replacing innerHTML or calling form.reset() redraws the picker by itself, and change fires as usual.
    • Options take:
      • data-image, data-initials, data-tone, data-shape, data-soft, data-color, data-icon, data-description and data-keywords
      • written by selectOptionClient(), selectOptionUser(), selectOptionContact(), selectOptionColor(), selectOptionPriority() and selectOptionAsset() in functions/format.php
    • The <select> itself takes data-placeholder, data-tags="true", data-search and data-avatar="initials". From script, use select.itflowSelect.open(), .close(), .clear() and .setValue(v), and itflowSelectAll(root).
    • js/tom_select.js and libs/tom-select/ are removed. Custom code that called new TomSelect or .tomselect needs updating.
  • Tags: tagPills() in functions/tags.php renders the pills and the [data-tag-picker] button, and js/tag_picker.js saves through agent/ajax.php. The *_bulk_assign_tags.php modals are removed.

  • Quick pickers: quickPick() in functions/ticket_fields.php and js/quick_pick.js. Changes go through agent/ajax.php using the same field functions the modals used, so history, audit, SLA and notifications are unchanged. ticket_assign.php, ticket_billable.php and ticket_priority.php modals are removed.

  • Links: functions/links.php is a relation registry, with:

    • linkAdd, linkRemove, linkRemoveAll, linkedRecords, linkCandidates and linkGroupsHtml
    • agent/post/link.php with link_items and unlink_item, plus the legacy link and unlink names
    • one modals/link/link.php, with same-client checks on every relation
  • Search: functions/search.php drives both the palette (agent/ajax.php?global_search, js/global_search.js) and agent/global_search.php, gated by permission and clientScopeSql().

  • Notifications: functions/notifications.php with notificationTypeModules() maps notification types to modules. js/notifications.js drives the panel, and modals/notifications.php is removed.

  • Client scope: enforceClientAccess() now runs for every selected client in the client bulk handlers and on the existing record's client in the expense, recurring expense, revenue, trip, task approval and credit handlers. clientScopeSql() is applied in the dashboard and per-client reports.

  • Confirmations: links with confirm-link ask through js/confirm_modal.js, which treats btn-danger and btn-outline-danger as destructive. One beforeunload guard covers every TinyMCE editor on the page, replacing the per-editor window.onbeforeunload.

  • js/autosubmit.js (data-autosubmit) replaces inline onchange submits in the client portal, which let the Content-Security-Policy go back on for client/recurring_invoices.php.

  • Copy: js/app.js handles .clipboardjs itself on click, using navigator.clipboard with a textarea fallback inside the trigger's modal. It still reads data-clipboard-text, and an empty value is refused. ClipboardJS is no longer loaded on agent pages and is now only used by agent/user/mfa_enforcement.php. .itflow-copy buttons show on hover, and .itflow-copy-always keeps them visible.

  • Calendar:

    • Ticket scheduling moved into functions/ticket.php and is shared with the calendar.
    • Feeds and invites write TZID and VTIMEZONE, with INTERVAL, UNTIL and EXDATE.
    • The mail queue sends invitations and cancellations as a text/calendar part, and other calendar files as invite.ics.
    • Ticket visit files are built without zapcal, so libs/zapcal is no longer used by anything.
  • Certificates: certificateCheckAndSave() runs the host check for the form, the API and the refresher. The refresher runs daily in a time-boxed batch, writes history and notifies when an OK certificate turns bad. Domains: domainLookup() and domainApplyLookup(), and the refresher takes 50 a run.

  • Recurring tickets: cron skips paused ones and raises a missed run once.

  • Agent user limit: getAgentUserLimit(), getActiveAgentUserCount(), getAgentSeatsTaken($except_invite_id = 0) (active agents and invites waiting in date), agentUserLimitReached($except_invite_id = 0) and agentUserLimitMessage() (which names the invites waiting) in functions/app.php. These are checked when adding, inviting, activating or restoring an agent, and when an invite is accepted.

  • Users:

    • userSetPassword() writes the hash and the vault key from the same untrimmed password.
    • userCheckIdentity() validates the email format and checks it is unique across agent and client portal accounts.
    • Signatures go through documentHtmlPurify().
    • disable_mfa is now a POST (disable_your_mfa) that needs the current password.
    • admin/modals/user/user_invite.php is removed.
    • Saving preferences no longer touches users.user_extension_key. The form had no extension field, so every save cleared it, and nothing else reads the key.
  • Dashboard: user_settings.user_config_dashboard_financial_enable and user_config_dashboard_technical_enable are no longer read. The columns are left in place.

  • Reports:

    • functions/reports.php (loaded by functions.php) holds everything the report pages share:
      • the catalogue, reportCatalog(), which drives the side menu and the Reports page, and access through reportEnforce(), reportCan() and reportCatalogForUser()
      • periods (reportPeriod(), reportRangeSql(), reportBuckets(), reportPeriodQuery()) and request filters (reportClientId(), reportAgentId(), reportClientSql())
      • the page parts: reportHeader(), reportStats(), reportChart(), reportTable() and reportSectionOpen()
      • CSV export with reportExporting() and reportCsv(), which guards against formulas and writes an audit log entry
      • shared queries: reportRenewals(), reportLifecycleAssets(), reportOsEndOfLife() (the REPORT_OS_END_OF_LIFE list), reportCoverage() and reportSlaCompliance()
    • A report page starts with agent/reports/includes/inc_report.php and reportEnforce('key'), works out its figures, sends a CSV if asked (if (reportExporting()) reportCsv(...)), and only then includes inc_report_layout.php to draw the page. To add a report, add it to reportCatalog() under the file name, and it shows in the menu and on the Reports page for whoever may open it.
    • reportTable() columns take label, type (money, count, hours, duration, decimal, percent, date), html, csv, csv_label, screen => false (export only), sort and title. A row of ['_heading' => ...] draws a heading row. Times export as decimal hours and the heading says so.
    • Charts are drawn by js/reports.js from the JSON in canvas[data-report-chart], with Chart.js. It also sorts table[data-report-sort], submits [data-report-autosubmit] filters and runs the budget editor.
    • agent/reports/includes/inc_all_reports.php is kept for custom report pages and now loads the two new includes.
    • Tax Collected is worked out by taxReportSales() and taxReportPurchases() in functions/tax.php (see Taxes, database 3.1.2).
    • 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. 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.
  • Client export:

    • functions/client_export.php (loaded by functions.php) holds both outputs. clientExportSections() lists the sections with the module and the part of ITFlow each needs, and clientExportOptions() keeps only what the agent may read, whatever is posted.
    • clientExportDatasets() and clientExportRows() define every table once, and the PDF and the zip both read from them. The CSV headings and JSON keys come from the same column list.
    • clientExportPdf() builds the PDF with TCPDF through the ClientExportPdf class in functions/client_export_pdf.php, which adds the header, the footer with "Page x of y", and an Error() that throws, so a document TCPDF cannot draw falls back to plain text instead of ending the PDF. Document images are inlined from uploads/documents/<id>/ only, and other image URLs are never fetched.
    • clientExportArchive() builds the zip in uploads/tmp, stores already-compressed files without recompressing them, encrypts every entry with ZipArchive::EM_AES_256 when passwords are included, and removes the file when the request ends, however it ends. Files and attachments are only read from the client's own upload folders.
    • The window is agent/modals/client/client_export_data.php with agent/js/client_export.js, and the handler takes export_client_pdf or export_client_archive in agent/post/client.php. The itflow_download cookie tells the window the download has started.
    • Removed: agent/modals/client/client_download_pdf.php, getClientPackSections(), getClientPackSectionAccess(), resolveClientPackSections() and renderClientPackSections().
  • Shared links:

    • functions/share.php (loaded by functions.php). A link's secret is 32 random bytes, base64url, after the # (shareUrl()). item_key is its SHA-256 (shareKeyHash()), and the content key is an HMAC-SHA256 of it (shareContentKey()), never stored.
    • Logins, secure notes and request answers are sealed with AES-256-GCM (shareSeal() / shareUnseal()) in item_payload. A request also keeps its content key under the vault (item_vault_key, encryptCredentialEntry()), so only an agent whose vault is open can read the answer.
    • shareCreate() makes a link, shareState() says where it is (active, waiting, received, used, expired, revoked, locked, destroyed, saved or discarded), shareClaim() takes a view through claimSharedItemView(), and shareEnd() ends it and wipes its secrets.
    • shareSweep() wipes secrets once a link ends or expires, and SHARE_GRACE_MINUTES (30) after its last view. It runs on every guest request and on the list. sharePrune() runs nightly and forgets links SHARE_KEEP_DAYS (90) after they end. Unsaved answers go after SHARE_RECEIVED_KEEP_DAYS (30).
    • Passphrases and codes are kept with password_hash(). shareTakeAttempt() takes each try with a conditional UPDATE, so a burst of guesses cannot pass SHARE_ATTEMPTS (5).
    • Permissions: shareCanSee() needs Support, plus Credential for login links and requests. shareCanMake() needs Support at level 2, plus Credential to share a login and Credential at level 2 to ask for one.
    • Guest side:
      • guest/share.php with js/guest_share.js, and guest/share_ajax.php, which takes JSON POSTs: open, passphrase, send_code, code, reveal, totp, destroy and submit.
      • What a browser has passed and opened is kept in its session for the grace time.
      • guest/guest_download_file.php takes a JSON POST and serves only from the client's upload folder.
      • guest/guest_view_item.php serves links made before 2.9.0 (item_version 1, key and ek in the query string, the login under aes-128-cbc).
      • The page sends Referrer-Policy: no-referrer, X-Robots-Tag: noindex, frame-ancestors 'none' and no-store.
    • Agent side:
      • agent/modals/share/share_add.php with agent/js/share_add.js, posting share_create to agent/ajax.php.
      • agent/shares.php, for one client or All Client Docs. ?share=ID opens a link's quick look, agent/modals/share/share.php, whose history comes from the audit log (log_type Share, log_entity_id the link).
      • agent/post/share.php handles share_revoke, share_revoke_all, share_bulk_revoke, share_request_save (credentialFormSet()) and share_request_discard.
    • Notifications go to item_created_by only. Links with no owner fall back to appNotify().
    • Removed: agent/modals/share_modal.php, agent/js/share_modal.js, agent/modals/credential/credential_share.php, share_generate_link in agent/ajax.php, get_share_totp_token in guest/guest_ajax.php, and deactivate_shared_item in post/misc.php.
  • Billing:

    • functions/billing.php (loaded by functions.php) serves invoices, quotes and recurring invoices through one map, billingTypes(): each kind's table, column prefix, items table and key, history column, page, list and URL parameter. billingRow() reads one with its client and category (scoped to the agent's clients unless told otherwise), and billingItems(), billingTotals(), billingNumber(), billingMoney() and billingHistory() work on any of the three.
    • Lines: billingItemSave() adds or edits a line (amounts rounded by billingLineAmounts(), stock moved by the difference for invoice lines of product_type product), billingItemDelete() removes one, billingItemsOrder() reorders, and billingRecalc() sets the total from the lines less the discount and, for an invoice already paid against, runs updateInvoiceStatusFromPayments(). billingCanEditItems() is the lock. The edit handlers for all three kinds now use billingRecalc().
    • State and pills: invoiceState() (open, overdue, draft, paid, cancelled or non-billable, with balance and days), invoiceDuePill(), invoiceStatusBadge(), invoiceCanPay(), quoteExpired(), quoteStatusBadge(), quoteExpiryPill(), getQuoteBadgeColor(), recurringInvoiceNextPill(), recurringInvoiceStep() (a period on, keeping the anchor day and falling back to a shorter month's last day), recurringInvoiceUpcoming() and recurringInvoiceCatchUp(). paymentAccounts() returns every account with its balance in one query.
    • includes/billing_paper.php draws the document for the agent pages and the guest pages from a $paper array, with the line controls when edit is set. The add-a-line inputs post through form="billingAddLine".
    • agent/post/billing.php handles billing_item_save (POST type, doc_id, optional item_id, name, description, qty, price, tax_id, product_id), billing_item_delete (GET), recurring_invoice_set_status and recurring_invoice_catch_up. billing_items_order in agent/ajax.php takes type, doc_id and items (ids, comma separated) and checks client access.
    • js/billing_items.js: itflowBillingItems() on the pages and itflowBillingLineForm() for any line form (the Edit Line window).
    • Windows: agent/modals/billing/billing_item.php (Edit Line), agent/modals/billing/billing_form.php (the fields shared by New and Edit for all three kinds, posting the names the existing handlers read) and agent/modals/payment/payment_form.php (shared by Add Payment, Batch Payment and Edit Payment). agent/modals/invoice/invoice_add_ticket.php is now an ajax window, and add_ticket_to_invoice takes ticket_ids[].
    • PDFs: billingPdf($type, $row, $variant) returns the file name and data, and billingPdfSend() sends it. The drawing is in functions/billing_pdf.php (the BillingPdf TCPDF class and billingPdfRender()), loaded only when a PDF is made. export_invoice_pdf, export_invoice_packing_slip, export_quote_pdf and the guest downloads use it. The guest downloads compare the URL key with hash_equals() and return 404 when it is wrong.
    • Guest pages: billingGuestViewed() marks a sent document viewed, logs the view in plain text and notifies. billingClientOpenInvoices() is the client's statement.
    • CSS: the .itflow-paper-* and .itflow-billing-* classes in css/itflow_custom.css, with print rules that print only the document.
    • Making invoices from recurring: recurringInvoiceGenerate($id, $by, $early, $run) is the one path, used by cron/nightly_tasks.php and force_recurring. It refuses when $run (the run date the caller means) is no longer the next date, or, without $early, when that date is still to come. It claims the run with a conditional UPDATE on the next date, then refreshes the lines, applies the period words, copies the lines (with item_product_id, but no stock moves), logs history on both documents and emails when set to. It returns the new invoice id, or 0.
    • Cron bills active, unarchived recurring invoices due from RECURRING_INVOICE_GRACE_DAYS (7) ago up to today, passing each one's next date as $run. force_recurring takes run from the page's link, and a link without it bills the current next date.
    • Schedules: recurringInvoiceFrequencies() (month, quarter, year), recurringInvoiceMonths(), recurringInvoicePeriodWord(), recurringInvoiceMonthly() and recurringInvoiceMonthlySql() (use it in SQL sums), recurringInvoiceAfter(), recurringInvoicePeriod() and recurringInvoiceTokens(). validateRecurringFrequency() accepts quarter. The edit handler keeps recurring_invoice_anchor_day unless the next date changes, and the add and edit handlers run their dates through validateDate().
    • Seats: recurringSeatSources(), recurringSeatFilter(), recurringSeatCount() (null when a license is gone or isn't the client's), recurringSeatDescribe(), recurringSeatOptions(), recurringSeatLines() and recurringInvoiceRefresh(), which syncs the seat quantities and takes each line's tax at its current rate. billing_item_save takes seat_source (empty for typed), seat_tag, seat_types[], seat_software_id, seat_count (used or total) and seat_minimum on recurring lines. A form without seat_source leaves the seat settings alone.
    • New handlers: recurring_invoice_sync (GET, agent/post/billing.php) and recurring_seat_count in agent/ajax.php, which returns {count, quantity, label} or {error} and needs Sales and access to the client. itflowBillingSeatForm() in js/billing_items.js drives the Edit Line seat fields.
    • Billing time (functions/billing.php): billingTimeRules() reads the settings once a request (falling back to the old rules before 2.9.2 is applied, so they are not in load_global_settings.php), billingTimeIncrements(), billingTimeRulesText(), billingTimeHours(), billingTimeRate(), billingTimeDescription(). billingUnbilledSql() is the one definition of unbilled (billable, status Resolved or Closed, not archived, ticket_invoice_id = 0); billingUnbilledTickets(), billingUnbilledByClient() and billingUnbilledCount() read it, scoped to the agent's clients. The dashboard, client overview, sidebars and the Unbilled Work report use them.
    • billingTimeInvoice($client_id, $hours, $opts, &$error) is the one path that bills time - Unbilled Time, add_invoice_from_ticket, add_ticket_to_invoice. It validates first, makes the draft with billingInvoiceCreate() when needed, claims the tickets with a conditional UPDATE (deleting a new draft that ends up with none), adds the lines through billingItemSave(), and logs ticket history, invoice history and the audit log. billingTimeWriteOff() sets ticket_billable = 0. invoiceUnbilledTickets() is replaced by billingUnbilledTickets(), and roundToNearest15Min() is no longer used.
    • New page agent/unbilled_time.php with js/unbilled_time.js, and handlers unbilled_invoice, unbilled_invoice_clients and unbilled_write_off in agent/post/billing.php. add_ticket_to_invoice takes ticket_ids[], hours[id], bill_time, item_name, price, tax_id and lines (each or one). add_invoice_from_ticket keeps its field names. remove_ticket_from_invoice writes to the ticket's history.
    • Quotes (functions/billing.php): quoteFrequencies(), quoteFrequency() (anything else is one time), quoteLineGroups($items, $discount) (lines grouped by frequency with subtotal, tax, taxes by name, discount and total each; the discount sits on the one-time group, or the first recurring one when there is none), quoteGroups($quote), quoteHasRecurring(), quoteMonthly() and quoteAmountText(). quote_amount is the one-time group's total and quote_monthly_amount the recurring ones as a month, both set by billingRecalc(). billingTotals() for a quote adds groups, recurring and monthly. billing_item_save takes frequency on quote lines, and a form without it leaves the line's frequency alone.
    • quoteConvert($quote_id, $opts, &$error) is the one path that invoices a quote, used by add_quote_to_invoice. $opts: date, first_period, recurring[frequency] with to (a recurring invoice id, or 0 for a new one), start and auto_send, and by. It checks every target first, claims the quote with a conditional UPDATE from Accepted to Invoiced, and makes everything in one transaction, rolled back and logged with logApp() on failure. Lines are copied with billingCopyLines(). It returns invoice_id, recurring (id => number) and numbers, or null with $error. quoteMadeFrom() lists what a quote became. A recurring invoice that only got lines is found through its history entry, which carries history_quote_id as well as history_recurring_invoice_id, so billingHistory() leaves such rows off the quote's history. delete_quote keeps those rows and clears invoice_quote_id and recurring_invoice_quote_id.
    • agent/modals/quote/quote_to_invoice.php posts those fields. Quote lines carry data-group, with tr.itflow-paper-group headings in includes/billing_paper.php, and the Sortable in js/billing_items.js drags only tr[data-item-id], within its group when the table is grouped.
    • reportCsv() writes a totals row as given, without the column's csv function, which read fields a totals row doesn't have. The quotes export takes quote_monthly_amount.
    • Removed: add_invoice_item, edit_invoice_item, delete_invoice_item, add_quote_item, edit_quote_item, delete_quote_item, add_recurring_invoice_item, edit_recurring_invoice_item, delete_recurring_invoice_item and link_invoice_to_ticket (it wrote a column that does not exist), update_invoice_items_order, update_quote_items_order and update_recurring_invoice_items_order in agent/ajax.php, agent/modals/invoice/invoice_item_edit.php, agent/modals/quote/quote_item_edit.php, agent/modals/recurring_invoice/recurring_invoice_item_edit.php, guest/guest_quote_upload_file_modal.php and js/product_autocomplete.js.
  • Billing emails, in functions/billing_email.php (loaded by functions.php):

    • Schedule: billingReminderRules($fresh) reads the 2.9.8 settings once a request, falling back to 1 and 30 days late before 2.9.8 is applied, so they are not in load_global_settings.php. Also billingReminderDaysParse(), billingReminderOnDay($days, $rules) (days late, negative before the due date: a listed day, or every repeat days after the last listed day), billingReminderScheduleText() (the settings page's script mirrors it), billingReminderNext($invoice, $rules, $late_only), billingReminderAutopay() and billingReminderWhen().
    • Layout: billingEmailHtml($email) draws every billing email from one array (kind, tone, title, greeting, intro, note, summary, lines, button, after, footnote) as inline-styled tables, for mail clients. The templates return [$subject, $email]: billingInvoiceEmail($invoice, $opts) (kind invoice or reminder, and note; a paid invoice gets the receipt), billingQuoteEmail(), billingPaymentEmail($applied, $payment), billingStatementEmail() and billingNotifyEmail().
    • Sending: billingEmailRecipients($client_id, $contact_ids, $type) returns the client's own contacts with an address, once each, or the defaults from documentDefaultContactFilterSql() when $contact_ids is null. billingEmailQueue($recipients, $subject, $email, $opts) (from and attach) queues one row each and returns "Name (email)" labels for the history. Also billingEmailFrom() and billingEmailReady().
    • Attachments: billingEmailPdf($pdf) writes the PDF under uploads/tmp/billing_mail/<random>/ for the queue's attachment manifest, and billingEmailPdfCleanup($days) removes folders older than that. The nightly run calls it with 7.
    • Receipts and notices: billingPaymentSend($invoice_id, $amount, $payment, $notify) is used by add_payment, saved-card and guest Stripe payments and cron autopay. With $notify it also emails config_invoice_paid_notification_email through billingPaymentNotify(). guest/guest_post.php calls billingQuoteAnsweredNotify($quote_id, $accepted).
    • Statements: billingStatement($client_id, $from, $to, $include_paid) returns the invoices with paid, balance and days late, the total, what is overdue and the aging buckets. Also billingStatementPeriod(), billingStatementPdf() and billingStatementSend($statement, $contact_ids, $opts). The drawing is billingStatementPdfRender() in functions/billing_pdf.php, where billingPdfConfig() now loads the footer settings when the globals are not set, as in cron.
    • Handlers: email_invoice takes kind (invoice or reminder), note (up to 2000 characters) and attach_pdf, and email_quote and send_statement take note and attach_pdf. New GET export_client_statement_pdf=client_id, with dtf, dtt and include_paid. edit_invoice_settings takes config_send_invoice_reminders, reminder_before, reminder_on_due, reminder_after, reminder_repeat, config_statement_monthly, config_statement_day and config_billing_email_attach_pdf. edit_notification_settings no longer writes config_send_invoice_reminders. Client add and edit take reminders with a hidden reminders_field, and a form without the field leaves client_reminders alone.
    • agent/modals/invoice/invoice_email.php?kind=reminder opens on the reminder. Recurring auto-send, the batch payment receipt and the nightly reminders use the same functions. The nightly late fee goes through billingItemSave(). Reminders write "Reminder emailed to ..." history, and today's "Cron Emailed Overdue Invoice" entries from before the update still count as sent. Monthly statements are logged with logAudit("Client", "Statement", ...), which is also what stops a second one the same day.
  • Products:

    • functions/products.php (loaded by functions.php): productTypes(), productSeatSources(), productBilledText(), productMargin() (percent of the price, null without a cost), productMarginPill(), productStockSql(), productTile(), productRow() (with category, tax, vendor and stock), productVendors(), productCopy(), productDelete() (with its stock), productUsage() (recurring, open quotes, invoice lines, sold over 12 months and a month's recurring, scoped with clientScopeSql()), productHistory() and productStockMoves().
    • productSave($in, $product_id, &$error) is the one path that writes a product - New, Edit, Copy, Import and notes. On an existing product only the keys given change. It validates the type, numbers, an Income category, the tax and one of our vendors, clears Counted when billed once and Track stock for a service.
    • Stock moves only when product_track_stock = 1, in billingItemSave(), billingItemDelete() and api/v1/invoice_items/create.php, where it used to follow product_type = 'product'.
    • getProductsForAutocomplete() adds frequency, seat_source and track_stock, and js/billing_items.js uses itflowBillingBilledText() and itflowBillingPick() to fill in Billed and Counted on the add-a-line row.
    • billingMargin($type, $items) and billingMarginHtml() work out the margin at today's product cost, by frequency for a quote, for the invoice, quote and recurring invoice pages.
    • New page agent/product.php?product_id=. agent/modals/product/product_form.php holds the fields of product_add.php and product_edit.php. Handlers: copy_product (GET), and product_set_notes in agent/ajax.php for the page's notes. import_products_csv maps columns by heading and takes update_existing. agent/post/product_model.php is removed. The list's bulk Restore posts bulk_restore_products.
    • Export columns for products: type, code, cost, margin, billed, vendor, part number, stock, location and notes. The products export takes vendor and the list's type tabs.
    • Seeds: demoEnsureProducts($mysqli, $currency, $vendors) with demoProductExtras(), and a seventh column on starterContentProducts() for how each is billed (month, month:contacts).
    • Stock ledger, in functions/products.php: productStockMove($product_id, $qty, $type, $opts) is the one path that writes product_stock ($opts: note, unit_cost, invoice_id, item_id, expense_id). The unit cost defaults to the product's cost for every type but received, and it records stock_user_id and calls productReorderCheck(), which raises a Low Stock notification (a module_sales type) when a move takes stock down to the reorder level or out. Also productStockTypes(), productWriteOffReasons(), productOnHand(), productReorderSql() (the one definition of to reorder), productReorderCount(), productStockQty(), productStockShortfall($items) (a message for the first short product, or '') and productStockReturnInvoice($invoice_id, $why), which returns what an invoice still has out from the net of its moves, so it can't return twice. productStockMoves($id, $limit) adds the running balance, the invoice, expense and agent.
    • Callers: billingItemSave() and billingItemDelete() record sold and returned with the invoice and line. billingCopyLines() carries item_product_id (dropped when the product is gone) and records sold when copying onto an invoice. quoteConvert() and add_invoice_copy check productStockShortfall() before creating anything. cancel_invoice and delete_invoice call productStockReturnInvoice(), and api/v1/invoice_items/create.php links its move to the invoice and line.
    • Handlers: product_stock_save (POST product_id, mode and note, with qty, unit_cost, update_cost and expense to receive, counted to count, or qty and reason to write off) from agent/modals/product/product_stock.php?id=&mode=, and product_stock_count (POST counted[product_id] and note) from the new page agent/products_count.php. Removed: agent/modals/product/product_stock_add.php and add_product_stock.
    • productSave() and productCopy() take reorder_level and reorder_qty, with import aliases and export columns for them and the stock value. The products list and export take type=reorder.
    • $num_products_reorder in agent/includes/get_side_nav_counts.php, Stock to reorder in dashboardAttention(), and inventory in reportCatalog() (Sales & Billing, module_sales, accounting) for agent/reports/inventory.php.
    • Sold hardware, in functions/products.php: productAssetTypes() (the $asset_types_array list), productAssetType(), productAssetIcon(), productHardwareSql(), productHardwareUnits() (a line's quantity rounded up, none for a credit), productWarrantyUntil(), productHardwareInvoiceSql() (not drafts or cancelled, not made by a recurring invoice), productHardwareShortSql(), productHardwarePending($days), productInvoiceHardware($invoice_id), productAssets($product_id) and productAssetCandidates(). productSaleAssets($invoice_id, $lines, $units, $shared, &$error) checks every row first, then adds or links them in one transaction, each new one with its primary interface, and each with a history line and an audit log entry.
    • Handler invoice_assets_save (POST invoice_id, status, location, lines[item_id][make|model|warranty] and units[item_id][n][name|serial|contact|existing]; needs Sales, and Support at edit) from agent/modals/invoice/invoice_assets.php?invoice_id=. billingItemDelete() clears asset_invoice_item_id on the line's assets and delete_invoice clears both invoice links - the assets stay.
    • productSave() and productCopy() take asset_type, make, model and warranty_months, with import aliases and export columns for them. The products list and export take type=hardware, and invoices.php takes hardware=1. dashboardAttention() adds Hardware not on assets for agents with Support access, billingPdfRender() reads the serials for the packing slip, and the asset page joins the invoice and the product. Seeds: columns 12 to 15 on demoDataProducts() and an eighth and ninth on starterContentProducts(). CSS: .itflow-hw-*.
    • Price history: productSave($in, $product_id, &$error, $source) records a change to an existing product's price or cost through productPriceRecord() - edit_product passes edit, import_products_csv passes import and product_stock_save passes receive when a delivery sets the cost. Also productPriceSources(), productPrices() and productPriceLastChange(). productDelete() and the demo data removal delete the history too.
    • Repricing: productRepriceKinds(), productRepriceUnionSql() (every line from a product on recurring invoices not archived, draft invoices and draft quotes, as one union), productRepriceOldSql() (at a price in the product's history), productRepriceFilterSql(), productRepriceCount($filters) (lines, by kind, products, and the change a month on recurring invoices) and productRepriceLines($filters) (by product, with old and suggested). productRepriceApply($changes, &$error) checks every line and price, then changes them in one transaction through billingItemSetPrice($type, $doc_id, $item_id, $price) (new in functions/billing.php - the line's amounts at its tax rate, then billingRecalc()), with a history line on each document.
    • New page agent/products_reprice.php (?product_id=, client_id, kind and doc_id) posting products_reprice_save (pick[] of kind:item_id, and price[kind:item_id]). dashboardAttention() adds Lines at an old price, and the invoice, quote and recurring invoice pages link their own. CSS: .itflow-reprice-*.
  • Expenses, in functions/expenses.php (loaded by functions.php):

    • expenseRow($id, $scoped = true) reads an expense with its vendor, category, account, client and creator, transfer_id when it is the out side of a transfer, the expense it refunds and refunded_amount. It is scoped to the agent's clients unless told otherwise.
    • expenseSave($in, $id, &$error) is the one path that writes an expense from a form - New, Edit, Copy and Refund. It checks the date, a non-zero amount, one of our vendors, an Expense category, an account, a client in scope and a listed payment method. An archived vendor, category or account is kept only when the expense already has it. refund_of makes the amount negative and sets expense_refund_of_id. A new expense takes the account's currency and records expense_created_by.
    • expenseDelete($id, &$error) refuses a transfer's expense. Otherwise it removes the receipt, clears product_stock.stock_expense_id and refunds' expense_refund_of_id, then deletes.
    • Receipts: expenseReceiptTypes(), expenseReceiptPath() (realpath-checked under uploads/expenses/), expenseReceiptKind(), expenseReceiptName(), expenseReceiptStore($id, $file, &$error) (replaces and deletes the old file) and expenseReceiptRemove().
    • The list and export share expenseFiltersSql($src, &$summary) (q, vendor, category, account, client, method, show, dtf, dtt, with the client scope and the vendor-only rule), expenseJoinsSql() and expenseShows(), the pills.
    • Also expenseStats(), expenseRecurringMonthlySql(), expenseVendorDefaults() (the last expense's category, account and method by vendor), expenseVendors(), expenseCategories(), expenseCategoryDot() and expenseLabel().
    • New agent/expense_receipt.php?expense_id= serves the receipt after the Financial and client checks: inline with &action=view for photos and PDFs, otherwise as a download. uploads/expenses/.htaccess denies the folder, and .gitignore keeps it.
    • New windows: the quick look agent/modals/expense/expense.php?id=, expense_form.php (the fields of expense_add.php, expense_edit.php, expense_copy.php and expense_refund.php) and expense_bulk_edit.php?field= (category, account, client or method).
    • Handlers: add_expense (with refund_of) and edit_expense (with receipt_remove) post vendor, amount, date, category, account, payment_method, client_id, reference, description and file. New are expense_receipt_save (POST expense_id, file) and bulk_edit_expenses (POST field, value, expense_ids[]). export_expenses takes client, method and show, and the export adds expense_payment_method, expense_receipt_display and user_name columns.
    • agent/expenses.php takes ?client=, ?method=, ?show= and ?open= (opens a quick look, as search does). globalSearchGroups() has an expenses group under module_financial. CSS: .itflow-category-dot and .itflow-expense-receipt-empty.
    • Removed: agent/post/expense_model.php, agent/modals/expense/expense_bulk_edit_category.php, expense_bulk_edit_account.php and expense_bulk_edit_client.php, and the bulk_edit_expense_category, bulk_edit_expense_account and bulk_edit_expense_client handlers.
    • Recurring expenses, in the same file:
      • They use the recurring invoice schedule helpers: recurringInvoiceStep(), recurringInvoiceFrequencies(), recurringInvoiceMonths() and recurringInvoiceMonthlySql(). expenseRecurringMonthlySql() now wraps the last.
      • RECURRING_EXPENSE_GRACE_DAYS (7), recurringExpenseRunningSql() (status 1, not archived, with a vendor) and recurringExpenseMonthlyTotal(). The dashboard and the Expenses, Recurring Revenue and Spend by Vendor reports use them.
      • recurringExpenseRow($id, $scoped) adds made_count and made_year. Also recurringExpenseState() (active, paused or ended - ended is status 0 with the next date past the end date), recurringExpenseEnded(), recurringExpenseAfter(), recurringExpenseUpcoming(), recurringExpenseRunsUntil(), recurringExpenseCatchUp(), recurringExpenseScheduleText(), recurringExpenseOrdinal(), recurringExpenseNextPill(), recurringExpenseShows(), recurringExpenseFiltersSql(), recurringExpenseMade() and recurringExpenseLabel().
      • recurringExpenseSave($in, $id, &$error) is the one path that writes one. The anchor day follows the next date when that changes, and an end date before the next date ends it.
      • recurringExpenseRecord($id, $by_user_id, $run, $by) is the one path that records a run, used by cron and Record Now. It claims the run with a conditional UPDATE on the next date and refuses when $run is no longer the next date. It dates the expense min(run, today), carries the method and reference, sets expense_recurring_expense_id and expense_created_by, and sets status 0 after the last run before the end date.
      • recurringExpenseDelete() unlinks the expenses it recorded.
      • Handlers: create_recurring_expense and edit_recurring_expense post vendor, amount, frequency, next_date, end_date, category, account, payment_method, client_id, reference and description. GET handlers: recurring_expense_set_status (with status), recurring_expense_record (with run), recurring_expense_catch_up and delete_recurring_expense, which now needs Financial at 3.
      • New windows: the quick look agent/modals/recurring_expense/recurring_expense.php?id= and recurring_expense_form.php, the fields of recurring_expense_add.php and recurring_expense_edit.php. agent/recurring_expenses.php takes ?status= (active, soon, late, paused, ended, all), vendor, category, client and ?open=. agent/expenses.php and its export take ?recurring_expense_id=.
      • Cron: the recurring expense block in cron/nightly_tasks.php records active ones due from RECURRING_EXPENSE_GRACE_DAYS ago up to today, passing each next date as $run. It sends an Expense Created notification per expense, linking expenses.php?open=, and an Expense notification the day one falls one day past the grace. The nightly notification for every past next date is gone.
      • dashboardExpectedExpenses() walks each one's runs with recurringExpenseRunsUntil().
      • Seeds: demoBuildCompanyFinancials() gives its recurring expenses recurring_expense_anchor_day and a next date one period after the last of their history, and links that history.
    • Billing expenses on, in the same file:
      • expenseUnbilledSql() is the one definition of an expense to bill: billable, on no invoice, for a client, with a vendor and a positive amount, not archived. expenseUnbilled($client_id, $ids) reads them in the agent's client scope with cost (less refunds - fully refunded ones are left out), price (expenseBillPrice($cost, $markup)) and waiting. Also expenseUnbilledByClient(), expenseUnbilledCount($client_id) (count and value, in SQL, for the menus, dashboard and client overview), expenseMarkupDefault() and expenseMarginText().
      • expenseBillInvoice($client_id, $prices, $opts, &$error) is the one path that bills expenses. It validates the prices and tax, uses one of the client's invoices whose lines can change or makes a draft with billingInvoiceCreate(), claims the expenses with a conditional UPDATE, adds a line each through billingItemSave() (expenseBillLine() names it), sets expense_invoice_item_id, and writes invoice history and the audit log. expenseBillWithTime($client_id, $hours, $prices, $opts, &$error) bills tickets with billingTimeInvoice() then expenses on the same invoice, for Unbilled Time.
      • expenseUnbill($id, &$error) takes one off - its line through billingItemDelete(), or only the link on a cancelled invoice - refusing on a paid or non-billable one (expenseCanUnbill($status)). billingItemDelete() frees the expense billed on a line it deletes, and delete_invoice frees the invoice's expenses. expenseWriteOff() sets expense_billable = 0. expenseOnInvoice($invoice_id) lists an invoice's with cost and billed. expenseUnbilledMerge($clients, $client_id) adds expenses to billingUnbilledByClient()'s clients.
      • expenseSave() takes billable and markup (0 to 1000, the default when blank) and refuses a new client while billed. expenseRow() adds the invoice's number and status and billed_subtotal. expenseShows() adds to_bill and billed.
      • Recurring: recurringExpenseSave() takes billing (bill, covered or empty, with a client), markup and recurring_invoice_id, which must be one of the client's from recurringExpenseCoverOptions(). recurringExpenseRecord() makes the expense billable with the markup when the recurring one is billed on. recurringExpenseRow() adds the covering recurring invoice. recurringExpensesCovering($recurring_invoice_id) and recurringInvoiceCosts($recurring_invoice_id) (revenue a month before tax, the running costs, margin and percent). delete_recurring_invoice clears recurring_expense_recurring_invoice_id.
      • Handlers: GET expense_unbill (Financial and Sales at 2) and POST invoice_add_expenses (invoice_id, expense_ids[], expense_price[id], tax_id). unbilled_invoice and unbilled_write_off take expense_ids[] and expense_price[id] too (Financial at 2 when they are posted), and unbilled_invoice_clients bills each client's expenses for agents with Financial at 2. bulk_edit_expenses takes field=billable (value yes with markup, or no). edit_invoice_settings saves config_expense_markup. The export adds expense_billing_display and expense_billed_on.
      • New window agent/modals/invoice/invoice_add_expenses.php?invoice_id=. recurring_expense_add.php takes ?recurring_invoice_id=. agent/invoice.php has the Expenses card and agent/recurring_invoice.php the Costs card for agents with Financial access. js/unbilled_time.js counts [data-unbilled-expense-row] rows with their [data-unbilled-expense-pick] and [data-unbilled-expense-price]. CSS: .itflow-unbilled-price.
      • dashboardAttention() and clientOverviewAttention() add Expenses to bill, and clientOverviewMoney() adds unbilled_expenses and unbilled_expense_value. get_side_nav_counts.php and inc_all_client.php set $num_unbilled_expenses.
    • Statement import, in the new functions/expense_import.php (loaded by functions.php):
      • expenseImportRead($path, &$error) reads a CSV up to EXPENSE_IMPORT_MAX_BYTES (2 MB) and EXPENSE_IMPORT_MAX_LINES (2000): byte order mark, Windows-1252, the delimiter the first lines agree on, and the header as the line before the first with a date. expenseImportDateFormats(), expenseImportDate($value, $format), expenseImportLooksLikeDate() and expenseImportAmount().
      • expenseImportGuess($header, $rows) maps date, description, memo, amount or debit and credit, and reference by header name then by content, with date_format and sign. expenseImportMapClean() checks a posted or saved one. expenseImportSavedMap() and expenseImportSaveMap() keep it in accounts.account_import_map, keyed on the column names (expenseImportColumnsKey()).
      • expenseImportLines($rows, $map) gives each line date, description (with the memo), match_text, amount (charges positive), reference and problem. expenseImportReview($import) runs expenseImportMatch() (rules from expenseImportRules(), then vendor names through expenseImportWords()), expenseImportKeys() (SHA-256 of account, date, amount, text and repeat, as key_hash) and expenseImportDuplicates() (imported, and entered within EXPENSE_IMPORT_DUPLICATE_DAYS). expenseImportMatchKey() and expenseImportVendorName().
      • expenseImportSave($import, $picks, &$error) checks every ticked line first, makes new vendors once (vendorFormSet()), inserts the expenses with expense_import_key, upserts expense_import_rules on import_rule_match, and stamps the key on hand-entered expenses a left-out line matched.
      • New page agent/expense_import.php (?step=columns and ?step=review, the statement in $_SESSION['expense_import']) and js/expense_import.js (itflowExpenseImport()). Handlers in the new agent/post/expense_import.php: expense_import_upload (POST account, payment_method, file), expense_import_columns, expense_import_save (POST picks, one JSON list of n, take, vendor or new with new_vendor, category, client, billable - under max_input_vars for long statements), GET expense_import_discard and expense_import_rule_delete. New window agent/modals/expense/expense_import_rules.php. CSS: .itflow-import-*.
    • Paid personally, in functions/expenses.php:
      • expenseSave() takes account=personal with paid_by (one of expensePayers()), saving expense_account_id = 0 and expense_paid_by, in the company currency. Money back can't be paid personally, and a paid back expense keeps who paid it. expenseSave() and expenseDelete() refuse the payment row of a reimbursement, as they do a transfer's.
      • expenseToReimburseSql(), expenseToReimburse($user_id, $ids) (by agent, scoped), expenseToReimburseCount() and expenseReimburse($user_id, $ids, $opts, &$error), which claims the expenses with a conditional UPDATE, then inserts the payment as an expense with no vendor and category on the account and the reimbursements row pointing at it. reimbursementRow(), reimbursementExpenses() and reimbursementDelete().
      • expenseRow() adds paid_by_name, reimbursement_date, reimbursed_account_name, reimbursement_out_id and reimbursed_to_name (its joins on accounts and users are aliased now). expenseShows() adds to_reimburse, and expenseFiltersSql() takes paid_by.
      • Handlers: POST expense_reimburse (user_id, expense_ids[], account, date, payment_method, reference) and GET expense_reimbursement_delete (Financial at 3). bulk_edit_expenses with field=account clears expense_paid_by and skips paid back ones. The export adds expense_paid_by_display.
      • New windows agent/modals/expense/expense_reimburse.php (?user_id= or ?expense_ids[]=) and expense_reimbursement.php?id=. agent/transactions.php, its export and agent/post/transaction.php give the payment the type Reimbursement, with the agent as the other side. dashboardAttention() adds Expenses to reimburse.
  • Trips, in the new functions/trips.php (loaded by functions.php):

    • Access: tripCanSeeAll() (Financial at 1), tripCanManage() (Financial at 2), tripIsMine($row) (drove or logged it), tripCanEdit($row), tripCanDelete($row) (theirs, or Financial at 3) and tripScopeSql() - clientScopeSql('trips.trip_client_id'), plus their own trips only without Financial access. Every read below goes through it.
    • tripRow($id, $scoped = true) reads a trip with its client, driver (driver_name, driver_avatar), created_by_name, location and ticket, through tripJoinsSql() (users aliased driver). tripsFor($column, $id, $limit) and tripTotalsFor($column, $id) read the trips of a ticket, location or client, and tripTableHtml($rows) draws them for a location's Trips tab.
    • tripSave($in, $id, &$error) is the one path that writes a trip from a form - New, Edit, Copy and Return. It takes date, driver, client_id, ticket_id, location_id, source, destination, miles (one way), round_trip, odometer_start, odometer_end and purpose. trip_miles is the whole trip: both odometer readings make it, otherwise the miles one way, doubled for a round trip. A ticket brings its client (and needs Support access), a location and ticket must be the client's, and without tripCanManage() the driver is the agent. It sets trip_created_by on insert. tripDelete($id, &$error) checks tripCanDelete().
    • The list and export share tripFiltersSql($src, &$summary) (q, driver, client, location_id, ticket_id, show, dtf and dtt) and tripShows(), the pills (mine, client, ticket and own). Also tripStats(), tripDrivers(), tripClientSites(), tripClientTickets(), tripKnownRoutes() (one way miles by lowercased from|to, both directions, the latest first), tripStartPlaces(), tripLastStart(), tripOfficeAddress(), tripLocationAddress(), tripOneWay(), tripLabel(), tripMapUrl(), tripUnit() and tripMilesText().
    • New windows: the quick look agent/modals/trip/trip.php?id=, trip_form.php (the fields of trip_add.php, trip_edit.php and trip_copy.php), trip_bulk_edit.php?field= (driver or client) and trip_bulk_delete.php, with the menu in agent/includes/trip_bulk_menu.php. trip_add.php takes ?client_id=, ?location_id= and ?ticket_id=, and trip_copy.php takes ?return=1.
    • Handlers in agent/post/trip.php: add_trip and edit_trip (with trip_id) post the fields above through tripSave(). GET delete_trip. New are bulk_edit_trips (field, value, trip_ids[]; Financial at 2) and bulk_delete_trips (Financial at 3). export_trips takes the list's filters and adds the user_name, round_trip_display, trip_one_way, location_name, ticket_display, trip_start_odometer and trip_end_odometer columns. The old fields roundtrip and user are gone.
    • agent/trips.php takes ?driver=, ?client=, ?location_id=, ?ticket_id=, ?show= and ?open=, and is open to every agent. side_nav.php lists Trips under Finance for everyone, globalSearchPages() offers it with accounting on, and inc_all_client.php counts $num_trips through tripTotalsFor().
    • agent/ticket.php has the Trips card and Log a trip. agent/location.php has the Trips tab. agent/reports/mileage.php joins the location and ticket and links each date to trips.php?open=.
    • Kept in step: ticketDeleteForGood() and locationClearReferences() clear trip_ticket_id and trip_location_id. ticketMergeInto() with $move_replies moves the trips. change_client_ticket moves the ticket's trips to the new client, without a location.
    • Seeds: demoBuildClient() gives the quarterly visits the primary location's address and id, and adds a trip for each onsite ticket resolved in the last six months. Every seeded trip has trip_created_by.
    • CSS: .itflow-trip-route.
    • Removed: agent/post/trip_model.php.
    • Mileage money, in the same file:
      • tripRules() reads the settings (unit, rate, category_id, vendor_id, charge, bill_rate, included, item_name). tripUnit() and tripUnitOne() follow the unit, and tripMilesText() with them. Also tripPayBackOn(), tripBillingOn(), tripRateInput() (up to four decimals), tripRateMoney(), tripRateText(), tripDistanceText(), tripBillPrice($row) (the charge plus the rate on the distance beyond included) and tripBillRuleText().
      • Paying back: tripClaimSql(), tripsToClaim($user_id, $ids) (by driver, scoped), tripToClaimCount() and tripClaim($user_id, $trip_ids, $opts, &$error). It makes one expense per client with expense_paid_by the driver, expense_account_id 0, the vendor from tripMileageVendor() and the category from tripMileageCategory() (both made and stored in the settings when missing; tripMileageCategoryShown() reads without making), and claims the trips into trip_expense_id with a conditional UPDATE before the amount is set. expenseDelete() clears trip_expense_id.
      • Billing: tripUnbilledSql(), tripUnbilled($client_id, $ids), tripUnbilledCount(), tripUnbilledMerge($clients, $client_id) (after expenseUnbilledMerge(), adding trips, trip_value and trip_miles), tripBillLine(), tripBillInvoice($client_id, $prices, $opts, &$error) (as expenseBillInvoice()), tripCanUnbill(), tripUnbill(), tripsOnInvoice() and tripWriteOff(). expenseBillWithTime() takes $trip_prices as a sixth argument and returns trips. billingItemDelete() and delete_invoice clear trip_invoice_id and trip_invoice_item_id.
      • tripSave() takes own_vehicle and billable, keeping the distance, driver and vehicle of a trip on a mileage expense and the distance, client and billable of a billed one. tripDelete() refuses either. tripRow() adds claim_expense_id, claim_date, claim_amount, reimbursement_date, invoice_prefix, invoice_number, invoice_status, billed_subtotal and ticket_billable. tripClientTickets() adds each ticket's billable, and tripDriverVehicles() gives each driver's vehicle on their latest trip.
      • tripShows() adds to_pay and to_bill when paying back or billing is on, tripFiltersSql() takes expense_id and invoice_id, and tripsFor() and tripTotalsFor() take trip_expense_id and trip_invoice_id.
      • Handlers in agent/post/trip.php: trip_claim (POST user_id, trip_ids[], rate, date, and pay_now with account, payment_method and reference; Financial at 2), GET trip_unbill (Financial and Sales at 2) and POST invoice_add_trips (invoice_id, trip_ids[], trip_price[id], tax_id). bulk_edit_trips takes field=vehicle and field=billable, and leaves trips paid for or billed alone. unbilled_invoice, unbilled_invoice_clients and unbilled_write_off in agent/post/billing.php take trip_ids[] and trip_price[id] (unbilledPostTripPrices(), Financial at 2). The export adds vehicle_display, paid_back_display and billing_display, and its distance column is labelled in the unit.
      • New windows agent/modals/trip/trip_claim.php (?user_id= or ?trip_ids[]=, one driver's) and agent/modals/invoice/invoice_add_trips.php?invoice_id=. agent/unbilled_time.php has the Travel to bill card, with data-unbilled-trip-row, -pick, -price and -all in js/unbilled_time.js. agent/invoice.php has the Travel card.
      • 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).
  • Import Deposits, in the new functions/income_import.php (loaded after income.php), on top of functions/expense_import.php's reader:

    • incomeImportLines($rows, $map, &$charges) keeps the money in from expenseImportLines(), made positive, counting the charges. incomeImportOpenInvoices() is every invoice still owed in the agent's client scope, oldest due first, with its balance. incomeImportRules(), incomeImportMethod($text, $default), incomeImportInvoiceNumbers($text, $prefixes) (after an invoice prefix in use or the word invoice - never a bare number), incomeImportPickInvoices($invoices, $amount) (exact, sum - the fewest adding up to it, of up to 14 - oldest or over), incomeImportNameWords() and incomeImportNameMatch() (whole, or cut short), incomeImportProviderAccounts() and incomeImportKind($text) (payout or transfer).
    • incomeImportMatch($lines, $account_id, $method) sets each deposit's action (payment, income or transfer), client_id, invoice_ids, how, category_id, from_account_id, method, matched (number, rule, name, amount, payout or transfer) and match_note. expenseImportKeys() keys them. incomeImportDuplicates($account_id, $lines) sets imported (from payment_import_key or revenue_import_key) or entered_type, entered_ids and entered_label (a payment, revenue or transfer leg on the account within INCOME_IMPORT_DUPLICATE_DAYS, or one day's payments summing to it). incomeImportReview($import, &$charges) runs them all and sets state (new, imported, entered, recorded or problem).
    • incomeImportSave($import, $picks, $opts, &$error) checks every ticked line - incomeImportCanPay() (Sales and Financial at 2), the client in scope, its open invoices with running balances across the statement, an income category, another account - before inserting the payments (updateInvoiceStatusFromPayments(), history, triggerCustomAction('invoice_pay'), billingPaymentSend() with $opts['receipts']), revenues and transfers (incomeImportTransfer()), upserting income_import_rules and stamping the keys of entered lines left out.
    • New page agent/income_import.php (?step=columns and ?step=review, the statement in $_SESSION['income_import']) and js/income_import.js (itflowIncomeImport(), with each client's open invoices). Handlers in the new agent/post/income_import.php: income_import_upload, income_import_columns, income_import_save (POST picks, one JSON list of n, take, action, client, invoices, category, account, and receipts), GET income_import_discard, income_import_rule_delete, and income_import_from_statement and expense_import_from_statement, which copy the statement in the session from one side to the other. New window agent/modals/income/income_import_rules.php.
    • The columns step is now agent/includes/statement_columns.php ($columns_form_id, $columns_submit, $columns_side), shared by agent/expense_import.php and agent/income_import.php. expenseImportGuess() takes the sign from the words on the lines when they are clear.
    • CSS: .itflow-import-details and .itflow-import-invoices.
  • Accounts, in the new functions/accounts.php (loaded after income_import.php):

    • accountTypes() (id => label and icon), accountTypeGuess($name) and accountType($row).
    • accountBalanceSql($column, $to_date = null) is the one balance rule: the opening balance plus payments and revenues, less expenses, leaving out archived rows, up to a date when given. accountBalance($id, $to_date), accountsList($archived = false, $keep_id = 0) (false open, true archived, null both; each row with type, balance, month_in, month_out, last_date, line_count and defaults), accountRow($id), accountDefaults() (the settings defaults and active payment providers' accounts), accountBalanceText($account) and accountsStats($accounts). paymentAccounts() returns accountsList(false, $keep_id), and dashboardAccountBalances() uses it too.
    • accountInUse($id) (payments, revenues, expenses, recurring expenses, payment providers and reimbursements), accountSave($in, $id, &$error) (name, type, currency_code, opening_balance, description, notes), accountArchive(), accountRestore() and accountDelete(), which clears the settings defaults that pointed at it.
    • accountLedgerSql($account_id = 0) is every line as one row set, txn_kind one of accountLedgerKinds() (payment, revenue, transfer_in, expense, transfer_out, reimbursement), with txn_id, txn_transfer_id, txn_account_id, txn_date, txn_created_at, txn_amount (signed), txn_what, txn_description, txn_category_id, txn_client_id, txn_other_account_id, txn_other_name, txn_method, txn_reference, txn_invoice_id, txn_currency, txn_imported, the account's and category's names, client_name, other_account_name, other_account_type (a transfer's other account's kind) and txn_balance, the running balance per account. Select from it AS ledger. The page and export share accountLedgerFiltersSql($src, &$summary, $with_show, $all_accounts) (q, kind or the old type names through accountLedgerTypeNames(), category, client, method or payment_method, amount_min, amount_max, show, dtf, dtt) and accountLedgerShows(). With every account, the transfers pill is the out side only. accountLedgerModal($row) is the quick look of a line.
    • Transfers: transferRow($id), transferLabel($row), transferWhat($row) ('Card payment' into a type 3 account), transferSave($in, $id, &$error) (date, amount, account_from, account_to, transfer_method, notes; updates both legs on edit, sets expense_created_by and revenue_created_by on insert) and transferDelete($id, &$error).
    • agent/transactions.php is rebuilt on these, taking ?account=, ?show= (in, out, transfers), ?kind= or ?type=, ?category=, ?client=, ?method= or ?payment_method=, ?amount_min=, ?amount_max=, ?q= and the date filter, and sorting on a whitelist only. agent/transfers.php redirects to transactions.php?show=transfers, mapping account_from or account_to to account. agent/accounts.php takes ?show=archived and ?q=.
    • Handlers: agent/post/account.php - add_account, edit_account, GET archive_account and restore_account (Financial at 2) and GET delete_account (at 3). agent/post/transfer.php - add_transfer and edit_transfer (with transfer_id; the form's expense_id and revenue_id are no longer read) at 2, GET delete_transfer at 3. export_transactions in agent/post/transaction.php reads the ledger with the page's filters; the transactions export columns add transaction_account, transaction_what, transaction_client, transaction_in and transaction_out, with transaction_amount off by default.
    • New windows: agent/modals/account/account_form.php (shared by account_add.php, which takes ?type=, and account_edit.php), agent/modals/transfer/transfer.php?id= (quick look) and transfer_form.php (shared by transfer_add.php, which takes ?from= and ?to=, and transfer_edit.php). transaction_export.php takes the ledger's filters.
    • Seeds: demoDataAccounts() adds Business Visa, and accounts take accountTypeGuess(). demoBuildCompanyFinancials() puts the card's categories on it and pays it off from Operating Checking each month with demoTransfer(). Company costs paid from the bank are ACH.
    • CSS: .itflow-account-name and .itflow-ledger-what.
    • Removed: agent/post/transfer_model.php.
  • Reconcile, in the new functions/reconcile.php (loaded after accounts.php):

    • reconcileKinds() (the payments, revenues and expenses columns), reconcileLineRef('payment-12'), reconcileRow($id) (with account, finished and card), reconcileOpen($account_id), reconcileLast($account_id), reconcileStartingBalance($account_id), reconcileTotals($row) (starting, ending, in, in_count, out, out_count, count, cleared, difference), reconcileLines($row, &$later) and reconcileHistory($account_id).
    • reconcileStart($account_id, $in, &$error) (statement_date, ending_balance - owed, for a card - and notes; after the last finished one, not after today; pre-ticks lines with an import key), reconcileUpdate(), reconcileTick($id, $keys, $tick, &$error), reconcileFinish() (only at a difference of 0.00, storing the starting balance), reconcileDiscard() and reconcileUndo() (the latest finished one on its account, with none in progress). Ticking sets *_updated_at to itself, so it doesn't count as a change to the line.
    • Locks: reconcileLock($kind, $id) is the finished reconciliation a line cleared on. reconcileCheckChange($kind, $id, $date, $amount, $account_id, &$error, $release = true) refuses a change to a cleared line's date, amount or account, and takes a line ticked on one in progress off it when it moves account. reconcileCheckDelete(), reconcileInvoiceLock($invoice_id), reconcileClientLock($client_id) and reconcileAccountReconciled($account_id). They are called from expenseSave(), expenseDelete(), incomeRevenueSave(), incomeRevenueDelete(), transferSave() (both sides checked before either changes), transferDelete(), reimbursementDelete($id, &$error) (which now gives the error), accountSave() (the opening balance), and the edit_payment, delete_payment, refund_payment_stripe, delete_invoice, delete_client, bulk_edit_income, bulk_delete_income, bulk_edit_expenses and bulk_delete_expenses handlers and api/v1/clients/delete.php. reconcileGlanceHtml(), reconcileFormNoteHtml() and reconcileLockedButtonHtml() show it in the quick looks and Edit windows.
    • accountLedgerSql() adds txn_line (payment-12, revenue-5, expense-9), txn_reconciliation_id, txn_cleared_id and txn_cleared_date. accountLedgerShows() adds uncleared, and accountLedgerFiltersSql() takes reconciliation (one statement's lines). accountLedgerWhat($row) is a line's who or what, shared by Transactions and Reconcile. accountsList() rows add reconciled_to and reconciling_id, and accountInUse() counts reconciliations.
    • New page agent/reconcile.php?account= and js/reconcile.js (itflowReconcile()). Handlers in the new agent/post/reconcile.php: reconcile_start (account_id), reconcile_update and reconcile_finish (reconciliation_id) at Financial 2, GET reconcile_discard at 2 and reconcile_undo at 3. Ticking is POST reconcile_tick to agent/ajax.php (reconciliation_id, lines[], tick), answering with the totals.
    • agent/transactions.php takes ?reconciliation= and ?show=uncleared, and the transactions export adds transaction_cleared (off by default). agent/modals/expense/expense_add.php and agent/modals/revenue/revenue_add.php take ?account_id=.
    • Seeds: demoReconcileAccounts() reconciles Operating Checking and the Business Visa monthly, and demoDataRemove() removes the reconciliations on the demo's accounts.
    • CSS: .itflow-reconcile-table, .itflow-reconcile-ticked, .itflow-reconcile-balanced and .itflow-reconcile-bar, which sticks to the bottom of the window - .app-main is overflow: visible on that page so it can.
    • Brought forward: on an account never reconciled, reconcileStart() takes start_from ('statement'; anything else starts from the opening balance), start_date (on or before statement_date) and start_balance (owed, for a card), and first calls reconcileBringForward($account, $to, $balance) - a finished reconciliation with reconciliation_opening 1, starting at the opening balance and ending at the bank's, clearing every line up to the day before start_date. reconcileOpening($account_id) is that row, reconcileOpeningGap($opening) the bank's balance less ITFlow's there, and reconcileCorrectOpening($id, &$error) adds the gap to accounts.opening_balance and the row's starting balance once, keeping it in reconciliation_adjustment. reconcileUndo() deletes a brought forward row, freeing its lines and taking the adjustment back off the opening balance. reconcileRow() adds opening, reconcileLock() returns reconciliation_opening (worded by reconcileLockText() and reconcileGlanceHtml()), and the statement after it pre-ticks only the imported lines after the day brought forward to. accountLedgerSql() adds txn_cleared_opening.
    • GET reconcile_correct_opening (the brought forward reconciliation's id) in agent/post/reconcile.php, at Financial 2, logged as an Account Edit. reconcilePostInput() adds start_from, start_date and start_balance.
  • Deposits, in functions/accounts.php: transferDepositCandidates($account_id, $limit = 200) (payments and non-transfer revenues on the account, not in a deposit or archived, after the latest transfer out of it with nothing linked, by date then *_created_at - each with kind, id, key, date, amount, what, client_name, method and reference), transferDepositLink($transfer_id, $from_account_id, $keys), transferDepositItems($transfer_id), transferDepositedIn($kind, $id), transferDepositedGlanceHtml($deposit) and transferDepositRelease($kind, $id, $account_id).

    • transferSave() takes deposit (payment-12, revenue-5; only on a new transfer, only lines on the account it leaves) and clears the links when an edit moves the account it leaves. transferDelete() clears them. transferPostInput() adds deposit[]. edit_payment, bulk_edit_income (Set Account) and incomeRevenueSave() call transferDepositRelease() before a line moves account. Linking and clearing leave *_updated_at as it was.
    • agent/modals/transfer/transfer_form.php puts each cash account's waiting lines (account type 4) in [data-transfer-deposit]'s data-deposits JSON for a new transfer, and renders them as deposit[] checkboxes. transfer.php lists the deposit, and payment.php and revenue.php show the deposit it went in.
    • CSS: .itflow-deposit-list.
  • 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.

  • CSS: [hidden] { display: none !important; } at the top of css/itflow_custom.css, so the hidden attribute wins over d-flex and the other display utilities. .pagination wraps.

  • Client portal shell: client/includes/header.php and footer.php are rewritten - the top bar, the phone tab bar and the #portalMenu offcanvas all draw from portalNav(). A page that sets the Content-Security-Policy keeps it, and the header widens it to default-src 'self'; img-src 'self' data:. Dark mode is the itflow_portal_theme cookie (dark or light), read by the header for the first paint.

    • The new js/portal.js, loaded on every portal page: the dark mode switch ([data-portal-theme-toggle]), TinyMCE on textarea.tinymce (moved out of the inline script in the footer that the policy blocked; data-required checks the editor on submit), attachment drop zones ([data-portal-drop] with a file input and [data-portal-files]), form[data-portal-once], and .table-responsive round any table a page left bare.
    • client/functions.php adds portalNav(), portalAvatarHtml($name, $photo_url, $size), portalSeesAllTickets(), portalTicketScopeSql($mine_only), portalTicketState($row) and portalTicketStateHtml($row), portalTicketTabs(), portalTicketColumnsSql(), portalTicketEventText($html) and portalHtmlIsEmpty($html). portalEmptyState() draws emptyStateHtml(), and verifyContactTicketAccess() takes Any.
    • client/tickets.php takes tab (open, you, resolved, closed, all), scope (mine), q and page. client/ticket_view_all.php redirects there. add_ticket takes file[] and checks the subject, details, category and asset. client/ticket_attachment.php refuses a file on an internal or archived reply.
    • Removed: ticketPortalStatusHtml() from functions/ticket.php, replaced by portalTicketStateHtml().
    • CSS: the .itflow-portal-* classes, from -header, -nav, -tabbar and -menu to -panel, -state-*, -tabs, -ticket, -steps, -msg, -event, -drop, -file-chip, -facts, -stat and -todo. The black table heads on the pages not rebuilt yet are softened to sit in the light shell.
  • Client portal billing:

    • New pages: client/invoice.php, quote.php and recurring_invoice.php (?id=, the session's client's only, never a draft or a paused recurring invoice), payments.php (page), receipt.php (?id= any payment of the receipt), pay.php (the checkout) and pdf.php (?invoice= or ?quote=). invoices.php takes tab (open, paid, all), q and page; quotes.php takes tab (waiting, accepted, all), q and page; statement.php takes view=year; unpaid_invoices.php takes select=ID,ID; saved_payment_methods.php takes add=1 to open Stripe's form; ticket_add.php takes subject.
    • client/post.php: portal_quote_answer (quote_id, answer accept or decline, name, note, authorised), portal_quote_file (quote_id, file[]), portal_pay (invoice_ids[], method - a saved card's id or new - and pay_token, which is the Stripe idempotency key) and portal_pay_return (Stripe's return from the checkout). add_payment_by_provider is removed. export_statement_pdf draws billingStatementPdf() and takes paid=1. create_stripe_customer goes on to saved_payment_methods.php?add=1.
    • The new functions/billing_pay.php: billingStripeProvider(), billingStripeClient(), billingStripeCustomer($client_id), billingOnlinePayAllowed($amount), billingCardParts($description), billingSavedCards($client_id), billingPaySelection($client_id, $invoice_ids, &$error), billingPaySplit() and billingPaySplitParse(), billingStripeIntentParams(), billingStripeChargeSaved(), billingStripeCheckoutIntent(), billingStripeBook($pi, $client_id, $by), billingClientPayments(), billingPaymentReceipt($payment_id, $client_id) and billingPaymentMethodText(). A portal PaymentIntent carries itflow_invoices (invoice_id:cents,...) and itflow_client_id in its metadata, plus itflow_invoice_id when it pays one invoice. billingStripeBook() takes a MySQL GET_LOCK on the PaymentIntent and books it once, a payment per invoice referenced Stripe - pi_....
    • functions/billing.php: billingQuoteAnswer($quote_id, $accept, $by), billingQuoteAddFiles($quote_id, $files, $by) and billingQuoteFiles($quote_id). guest/guest_post.php answers quotes and takes their uploads through them, with the same messages. billingGuestViewed() takes $who for a contact in the portal.
    • functions/billing_email.php: billingPaymentReceiptSend($applied, $payment, $notify) sends one receipt for a payment over one invoice or several, and billingPaymentSend() calls it. billingPaymentNotifyMany() is the paid notification for several invoices. billingQuoteAnsweredNotify() takes who answered and their note.
    • client/functions.php: portalBillingGate() (billing access and the accounting module), portalStateBadge(), portalInvoiceState($row, $paid), portalQuoteState($row), portalQuoteWaitingSql(), portalBillingViewed($type, $row) (once a sign-in) and portalAskUrl($subject). portalNav() adds Payments, and only lists Pay invoices and Cards & autopay when Stripe is set up.
    • A page that sets $portal_stripe before includes/inc_all.php gets the portal's Content-Security-Policy with Stripe's script, frames and API added (Stripe.js, Checkout and Link, as Stripe lists them), and Stripe.js with the new js/portal_stripe.js - the Payment Element on pay.php and the embedded Checkout for Add a card. js/portal.js adds [data-portal-print] and the Pay invoices form (form[data-portal-pay], [data-pay-cents], [data-pay-all], [data-pay-label]).
    • includes/billing_paper.php takes client to leave out the agent-only note on a seat line.
    • cron/nightly_tasks.php reconciles Stripe fees by payment reference, so a card payment over several invoices gets one fee expense. refund_payment_stripe refunds the payment's amount.
    • CSS: .itflow-portal-amount, -big-amount, -state-late, -outcome, -note, -payrow, -paybox, -methods, -method, -paybar, -stripe-element, -card-brand, -aging, -table and -receipt, and print rules that leave only the document.
  • Client portal Your IT:

    • New pages: client/asset.php (?id=, within portalAssetScopeSql(), so a plain contact reaches the devices they use), contact.php (?id=, or ?new=1 for the primary contact) with its form in client/includes/contact_form.php, document_add.php (the editor, or ?upload=1 for a file) and licenses.php (tab all, soon or expired, and q). assets.php takes tab (all, computers, servers, network, phones, other), scope=mine, q and page; domains.php takes tab=certificates; documents.php takes folder (a folder's id, or 0 for those in none), q and page; contacts.php takes q. certificates.php redirects to domains.php?tab=certificates, and contact_add.php and contact_edit.php to contact.php. ticket_add.php takes asset to pick the device.
    • client/post.php: add_contact and edit_contact are replaced by portal_contact_save (contact_id, 0 for new; the contact fields; invite and method for a new one), portal_contact_access (contact_id, invite or off, and method) and portal_contact_left (contact_id), all needing contactCan('contacts_manage') and refusing a primary contact or the contact's own record. A form that fails is kept in $_SESSION['portal_contact_form'] for the page to fill back in. client_add_document and client_upload_document check portalItGate(), refuse a blank name or empty document, set document_created_by and file_created_by to the portal user and notify.
    • client/functions.php: contactCan('contacts_manage') (the primary contact only), portalItGate() (primary or technical, and the module), portalAssetScopeSql(), portalAssetGroups(), portalTile($icon, $size), portalRenewalState($renewal, $active_label), portalContactRoles($row), portalContactAccess($row) (none, off, local or azure, with a label, icon and class), portalContactLastSignIn($user_id), portalSignInMethods(), portalContactInvite($contact_id, $method, $by) and portalContactAccessOff($contact_id). The invite makes or turns back on the contact's portal user and, when email is set up, sends login_reset.php's own set-password link, or the sign-in page for Microsoft 365. enforceContactCan() sends the contact home with a message rather than to logout.
    • client/includes/check_login.php ends the session when the user has no sign-in method (user_auth_method not local or azure).
    • client/includes/header.php starts output buffering, so a gate after includes/inc_all.php sends a real redirect. A page that sets $portal_csp_inline_styles before includes/inc_all.php gets style-src 'self' 'unsafe-inline' added to the portal's policy, for HTML from documentHtmlPurify(); document.php sets it.
    • CSS: .itflow-portal-tile with -sm and -lg (the grey icon tiles), .itflow-portal-roles, .itflow-portal-pdf and .itflow-portal-document. The tiles in a list row are left off on phones.
  • Client portal accounts (database 3.1.0):

    • New table portal_announcements (title, body, tone info/warning/danger, announcement_client_id 0 for every client, starts and ends, created by, archived). New columns contacts.contact_ticket_emails (all or replies) and contacts.contact_company_ticket_emails (none, new or all), and settings.config_client_portal_require_mfa.
    • Password links: user_password_reset_token holds the SHA-256 of the token and user_password_reset_token_expire is used at last. The migration hashes any link still out and gives it a day. cron/nightly_tasks.php forgets only the links that ran out.
    • The new functions/portal.php: PORTAL_RESET_LINK_MINUTES, PORTAL_INVITE_LINK_DAYS, PORTAL_RESET_REQUEST_GAP_MINUTES, PORTAL_TICKET_EMAILS, PORTAL_COMPANY_TICKET_EMAILS and PORTAL_ANNOUNCEMENT_TONES; portalPasswordLink($user_id, $minutes), portalPasswordLinkUser($email, $token, $client_id) and portalPasswordLinkClear($user_id); portalContactLastSignIn() (moved from client/functions.php); portalRememberedBrowsers(), portalMfaReset() and portalMfaQrSvg(); portalAccountEmail(); portalAnnouncementsFor($client_id), portalAnnouncementBodyHtml() and portalAnnouncementState(); and portalViewAsAccess($agent_user_id, $contact_id).
    • login.php: a portal user's secret is users.user_token and a remembered browser a remember_tokens row, as for agents. pending_mfa_login carries type (client or agent) and client_user_id, and the code step only matches the account whose password was checked. A client's row is matched on contact_user_id, as users.user_id comes back NULL from the user_settings join. Wrong codes are logged as Client Login / MFA Failed and counted per account. $_SESSION['login_message_type'] = success shows the message in green.
    • client/login_reset.php is rewritten: it logs Client Login Reset Requested, Reset Sent and Password Reset, and limits asks to 10 per address in 10 minutes. portalContactInvite() makes its link with portalPasswordLink().
    • client/includes/check_login.php sets $session_auth_method, $session_mfa_on and $portal_mfa_setup_needed (only profile.php and post.php open until set up), and $portal_view_as for View as contact - from $_SESSION['portal_view_as'] in an agent's session, checked with portalViewAsAccess() on every page.
    • client/post.php: edit_profile is replaced by portal_password_change (current_password, new_password, confirm_password), with portal_mfa_enable (verify_code, against $_SESSION['portal_mfa_secret']), portal_mfa_disable, portal_forget_browsers and portal_email_prefs (ticket_emails, company_ticket_emails). While viewing as a contact, everything but exit_view_as, logout (which only leaves the view) and export_statement_pdf is turned away. client/pay.php refuses, and invoice and quote views, file and attachment downloads write nothing.
    • client/functions.php: portalViewingAs(); contactCan('accounting') is false while viewing as a contact without billing; portalReauthenticate() reads $session_auth_method.
    • client/profile.php is rebuilt (?mfa=setup opens setting up two-step sign-in) and client/activity.php takes tab (signins, changes) and page, without DataTables.
    • functions/ticket.php: ticketMailContactAndWatchers() takes $kind (created, closed, or update, the default) and sends update and closed to the colleagues who chose every update; ticketContactWantsEmail($preference, $kind), ticketColleagueRecipients($ticket_id, $kind) and ticketMailColleaguesNew($ticket_id) - called from the agent's and the portal's add_ticket and from the email parser. The parser's and recurring tickets' created emails follow the contact's choice.
    • agent/post/contact.php: portal_view_as and reset_contact_portal_mfa (GET, with the CSRF token).
    • Admin: admin/settings_client_portal.php with admin/post/settings_client_portal.php (edit_client_portal_settings) and admin/post/portal_announcement.php (add_portal_announcement, edit_portal_announcement, archive_/restore_/delete_portal_announcement), and the windows in admin/modals/portal_announcement/.
    • CSS: .itflow-portal-announcement with -icon, -title, -body and is-warning/is-danger, .itflow-portal-viewas and .itflow-announcement-dot.
  • Taxes (database 3.1.1):

    • taxes.tax_percent is decimal(9,4) (it was a float), with new tax_description, tax_registration_number and tax_replaced_by (0, the rate a billed rate was changed to). New table tax_components (component_tax_id keyed, component_name, component_percent decimal(9,4), component_compound, component_registration_number, component_order) - a rate without rows is its own single part, and tax_percent is the whole rate its parts come to. New clients.client_default_tax_id (0, the default), client_tax_exempt, client_tax_exempt_number and client_tax_exempt_expires, and settings.config_tax_default_id, config_tax_time, config_tax_trips, config_tax_expenses (all 0) and config_tax_discount_before (1).
    • The new functions/tax.php (loaded after billing_pay.php): taxSettings(), taxRow($tax_id) (cached, with components - name, percent, compound, registration - and grouped) and taxForget(), taxPercentText(), taxLabel(), taxEffectivePercent($components), taxAmounts($base, $tax) (tax and parts - label, name, percent, amount, registration, registration_for - each part to the cent), taxCurrent($tax_id) (follows tax_replaced_by to a current rate, else 0), taxClient($client_id), taxClientSetSql($in, $current), taxExemptText($client, $with_expiry), taxDocWarning($type, $doc_id, $client_id, $items, $open, $can_edit), taxDefaultFor($client_id, $kind, $product_tax_id) (goods, time, trip, expense, product), TAX_USUAL (-1) and taxChoice($input, $client_id, $kind), taxUsage(), taxUsageAll(), taxBilled($tax_id) (on an invoice or quote past draft), taxFromInput($in), taxSameRate(), taxCreate(), taxUpdate($tax_id, $fields, $move_drafts), taxChangeRate(), taxRepoint($from, $to, $move_drafts), taxDocIds($type, $tax_id), taxRecalcOpen(), taxArchive($tax_id, $move_to), taxRestore(), taxUnused(), taxDelete(), taxSetDefault(), taxMovedText(), taxOptionsHtml($taxes, $selected, $usual_label, $usual_percent) and taxSelectOptions().
    • functions/billing.php: billingLineTax($quantity, $price, $tax_id, $base), billingLinesOpen($type, $status), billingDiscountShares($items, $discount) (by subtotal, to the cent, largest remainder), billingRecalcTaxes($type, $doc_id, $discount), billingTaxParts($items), billingRemoveTax($type, $doc_id) and billingTaxRegistrationLines($totals). billingRecalc() works each line's tax out again while its lines can change - on a quote, the discount comes off the one-time group, else the first recurring one. billingTotals() taxes are by part, with tax_registrations added, and quoteLineGroups() the same per group. billingBillTo() adds tax_exempt_text. recurringInvoiceRefresh() updates quantities and subtotals and leaves the tax to billingRecalc(). billingItemSave() checks the rate exists. billingTimeInvoice(), expenseBillInvoice() and tripBillInvoice() take TAX_USUAL.
    • getProductsForAutocomplete($mysqli, $client_id) gives each product's tax and tax_percent for that client. The line windows, the add-a-line row, Unbilled Time and the ticket Invoice and Quote windows build their tax select with taxOptionsHtml().
    • Admin: admin/tax_rates.php is rebuilt (q, sort - tax_name, tax_percent or invoice_count - order, page and show=archived, archived=1 still taken), with admin/modals/tax/tax_form.php shared by tax_add.php and tax_edit.php, the new tax_archive.php, and js/tax_form.js. admin/post/tax.php: add_tax (name, description, registration, percent, or part_name[key], part_percent[key], part_compound[key] and part_registration[key]; make_default), edit_tax (tax_id, drafts_asked, move_drafts), archive_tax (now POST, tax_id, move_to), GET restore_tax, default_tax and delete_tax, and edit_tax_settings (default_tax_id, tax_time, tax_trips, tax_expenses, tax_discount_before).
    • Clients: agent/modals/client/client_tax_fields.php on the Billing tab of client_add.php and client_edit.php posts tax_fields, default_tax_id, tax_exempt, tax_exempt_number and tax_exempt_expires, saved by agent/post/client.php through taxClientSetSql(). api/v1/clients/client_model.php does the same from the API fields. GET billing_remove_tax (the document's id, type) in agent/post/billing.php, at Sales 2.
    • api/v1/invoice_items/create.php takes tax_id default (a product's line by taxDefaultFor() with product) and works the invoice out again with billingRecalc().
    • Seeds: demoDataTaxes() rows add a description, registration number and parts, demo clients get client_default_tax_id, and a profile's tax_exempt (number, expiry) makes it exempt. demoDataRemove() counts clients using a rate and removes its parts.
    • CSS: .itflow-paper-tax-label, so a line's rate wraps under its tax.
  • Tax reporting and tax on expenses (database 3.1.2):

    • New expenses.expense_tax_id and expense_tax_amount (decimal(15,2), part of expense_amount, negative on money back), and recurring_expenses.recurring_expense_tax_id and recurring_expense_tax_amount, copied to each expense by recurringExpenseRecord().
    • functions/tax.php: taxIncluded($gross, $tax), taxSplit($amount, $row, $base, $no_rate_label) (a tax amount shared over a rate's parts - parts, registrations and numbers, the registration number by part label), TAX_REPORT_BASES, taxReportMonths(), taxReportInvoice($items, $discount, $exempt), taxReportSales($from, $to, $basis) (invoiced or paid; parts, numbers, sales, taxable, nontaxable, exempt, exempt_clients, invoices) and taxReportPurchases($from, $to) (parts, purchases, expenses). billingTaxParts() uses taxSplit().
    • functions/expenses.php: expenseTaxFromInput($in, $amount, $keep, &$error) reads tax_id and tax_amount for expenseSave() and recurringExpenseSave(); a refund starts from the rate of what it refunds. expenseRow() and recurringExpenseRow() add tax_name and tax_percent, and expenseVendorDefaults() adds tax. The fields are agent/modals/expense/expense_tax_fields.php, on expense_form.php and recurring_expense_form.php.
    • agent/reports/tax_summary.php takes basis (invoiced, paid), view and year, and export=csv with table tax (Section, Figure, Registration, the periods and the year) or exempt.
    • Exports: invoice_subtotal, invoice_discount_amount, invoice_tax and invoice_tax_parts, quote_tax (up front, from quoteLineGroups()), recurring_invoice_tax, expense_before_tax, expense_tax_amount and expense_tax_rate, and client_tax_rate, client_tax_exempt_display, client_tax_exempt_number and client_tax_exempt_expires, all default => false. The quote and recurring invoice exports select their ids for it.
    • API: api/v1/taxes/read.php (tax_id, archived; each rate with tax_components and tax_default), mapped to module_sales. billingApiTaxFields($type, $row) adds invoice_subtotal, invoice_tax, invoice_taxes (name, amount) and invoice_tax_registrations to api/v1/invoices/read.php, and the quote_ ones to api/v1/quotes/read.php. api/v1/invoice_items/read.php adds tax_name and tax_percent.
    • Seeds: demoDataTaxes() names Allegheny County's state part PA Sales Tax, and the company's own-use purchases (office supplies, tools, equipment, the van) carry PA Sales Tax.
  • Tags (database 3.1.3):

    • New tags.tag_description (varchar(255), NULL). The migration writes every tag_color as #rrggbb (short hex grows, an eight-digit one loses its alpha, Bootstrap's color words become their hex, anything else #6c757d) and every tag_icon as the bare Font Awesome name (fas fa- and anything but a-z0-9- taken off, NULL when empty).
    • functions/tags.php: tagEntity() adds title, plural and icon, and tagsForRecords() adds tag_description and tag_archived_at. New: TAG_COLORS and TAG_ICONS (what the tag window offers), tagColor() (#rrggbb, grey otherwise), tagTextColor() (#212529 or #fff by luminance), tagIcon() (the bare name, tag when none), tagPill($tag), tagKinds() (type => entity), tagKindPhrase($type), tagEntityByType($type) (adds entity), tagRow(), tagUsageCounts(), tagUsageBreakdown() (all, archived), tagUsage(), tagSeatLineItems($tag_ids), tagSeatLineCounts(), tagSeatLines(), tagFromInput($in, $existing) (returns [$fields, $error]: name, description, type, color, icon), tagCreate(), tagUpdate(), tagArchive(), tagRestore(), tagDelete($tag_id, &$error) (refused while a seat line counts by it), tagMerge($from_ids, $into_id) (same kind only; records moved, seat filters repointed, returns how many records), tagOptionsFor($entity, $keep_ids), tagOptionsHtml($entity, $selected) and tagSetForRecord($entity, $record_id, $values).
    • tagSetForRecord() is the one save for a record form's tags[]: numeric values are tag ids, anything else (from an admin) a typed name, matched case-insensitively or made with tagNextColor(). Only that kind's tags are kept, each once, and an archived tag only on a record that already had it. agent/post/client.php, contact.php and asset.php call it, and locationSetTags(), credentialSetTags(), vendorSetTags() and domainSetTags() now wrap it.
    • The record forms' Tags fields are <?= tagOptionsHtml($entity, $selected) ?> with data-tags="true" for admins, and their links to admin/modals/tag/tag_add.php are gone. The list pages' tag filters and the seven export windows leave out archived tags that aren't chosen.
    • tagPills() and tagPill() set --itflow-tag-text beside --itflow-tag-color, and a pill's title leads with the description. agent/ajax.php: tag_picker returns each tag's description (with "Archived"), tag_picker_set refuses putting an archived tag on (409), and tag_picker_create matches case-insensitively and restores an archived match. js/tag_picker.js shows and searches the description.
    • Admin: admin/tags.php is rebuilt (type, q, sort - tag_name or used - order, page and show=archived, archived=1 still taken; sorted and paged in PHP), with admin/modals/tag/tag_form.php shared by tag_add.php (type) and tag_edit.php (id), the new tag_merge.php (tag_ids[]), and js/tag_form.js. admin/post/tag.php: add_tag and edit_tag (tag_id, name, type, description, color, icon), GET archive_tag, restore_tag and delete_tag, POST merge_tags (tag_ids[], into_tag_id), and bulk_archive_tags, bulk_restore_tags and bulk_delete_tags (tag_ids[]). Audit entries are Tag Create, Edit, Archive, Restore, Delete and Merge, naming the kind.
    • Removed: agent/post/tag.php and agent/post/tag_model.php (an older copy of add_tag for the window opened from agent pages), and admin/post/tag_model.php.
    • agent/modals/billing/billing_item.php adds a seat line's own tag to its choices when recurringSeatOptions() leaves it out.
    • CSS: .itflow-tag takes its text color from --itflow-tag-text; new .itflow-tag-swatch, .itflow-tag-icons, .itflow-tag-icon and .itflow-tag-icon-custom.
    • tagMerge() moves ticket_template_tags and document_template_required_tags to the tag merged into, so a ticket template keeps its tags and a document template the clients it is required of.
  • Tags on more records (database 3.1.4):

    • New link tables software_tags, service_tags, network_tags, document_tags and ticket_tags - the record's id and tag_id, keyed together and on tag_id, gone with the record or the tag. Tag types 8 (license - the software table), 9 (service), 10 (network), 11 (document) and 12 (ticket) in tagKinds() and tagEntity(); a document's client_list says its list (files.php) is one client's.
    • functions/tags.php: tagListUrl($entity, $tag) (the list filtered by it, or global_search.php?query= for documents), tagFilterIds() (?tags[]=), tagFilterSql($entity, $ids, $column), tagSearchSql($entity, $like, $column), tagFilterOptionsHtml($entity, $selected, $records_where) (the tags on records the list can show, its client joined, plus those chosen), tagOptionHtml($row, $selected), tagFormFieldHtml($entity, $selected, $placeholder) (with the tags_form marker), tagIdsForRecord(), tagChangeText($before, $after), tagExportFieldHtml(), tagNamesSql() and tagExportFilterSql($entity, $values, &$summary, $column), tagMatchSql() and tagSearchSpec() for search, and tagApiRow() and tagApiAttach($rows, $entity) for the API. tagsForRecords() adds tag_type.
    • Licenses, services, networks, documents and tickets: the lists read tags[]; the forms post tags[] and tags_form; add_software, add_service, add_network, add_document and add_ticket save them with tagSetForRecord(), and the edits do when tags_form is posted. edit_ticket writes the change to the ticket's history, as does the picker (agent/ajax.php tag_picker_set) for a ticket. ticketMergeInto() copies the merged ticket's tags that aren't archived.
    • Exports: tags[] filters, and software_tags, service_tags, network_tags and ticket_tags columns, off until ticked.
    • js/tag_picker.js: data-tag-bulk="document_ids[]" takes only the ticked boxes of that name.
    • Global search: a group's tags ([entity, id column]) also matches its records' tag names and hands back each item's matching tags (name, color, text, icon); the new tags group (module null - it checks each kind's module in its own query) gives each item its tag. CSS: .itflow-search-tag-icon, .itflow-search-item-text and .itflow-search-item-tags.
    • API: api/v1/tags/read.php (kind - a kind's name, software or its type - record_id, tag_id with tag_record_ids, tag_name, archived, limit, offset), update.php (kind, record_id, tags as an array or comma-separated ids or names, mode set, add or remove, and client_id for a key limited to certain clients) and create.php (admin keys; kind, name, description, color, icon). enforce_api_rbac.php maps tags to '', so tagApiKind() checks the module of the kind asked for. read_output.php adds tags to each row when the endpoint sets $api_tag_entity, as the twelve kinds' reads now do; credentials/read.php adds them itself.
    • Seeds: starterContentTags() has types 8 to 12, and demo data tags its documents, services, licenses and tickets (demoTicketTags()).
  • Categories (database 3.1.5):

    • The migration writes every category_color as #rrggbb - short hex grows, an eight-digit one loses its alpha, the 148 CSS color words become their own hex, anything else NULL - and every category_icon as fa- and the name (fas fa-, markup and anything but a-z0-9- taken off, NULL when empty), leaving category_updated_at alone. No column changes; setup/seed_data.php writes hex colors.
    • New functions/categories.php, loaded by functions.php: categoryKinds() - each category_type with label, one, many, icon, about, by (id or name), ordered, max (name length), and uses (table, column, one, many, url with {id} or {name}, archived and updated columns, and text for an id kept as text, tickets.ticket_category and recurring_tickets.recurring_ticket_category). Also categoryKind() (adds kind), categoryKindPhrase(), categoryUseText(), CATEGORY_COLORS, CATEGORY_ICONS, categoryColor() (#rrggbb or ''), categoryIcon() (the bare name or ''), categoryNextColor(), categoryChip($row), categoryRow(), categoryRows($kind) (in picker order), categoryNameKey(), categoryUsage($kind) (id => all, archived, uses), categoryLooseNames($kind), categoryUsageFor(), categoryUsageHtml(), categoryUsageText(), categoryFromInput($in, $existing) ([$fields, $error]: type, name, description, color, icon), categorySetSql(), categoryCreate() (last in the order), categoryUpdate($row, $fields) (renames a by-name kind on its records; returns how many), categoryMoveRecords($config, $where_sql, $to_sql) (keeps each record's *_updated_at), categoryArchive(), categoryRestore(), categoryDeleteBlocker($row, $used), categoryDelete($id, &$error), categoryMerge($from_ids, $into_id) (same kind; returns records moved), categoryFoldBudget() and categoryReorder($kind, $ids).
    • Admin: admin/categories.php is rebuilt (category, q, sort - category_order, category_name or used - order, page and show=archived, archived=1 still taken; sorted and paged in PHP, as what uses each is counted there), with admin/modals/category/category_form.php shared by category_add.php (category, or none to pick the kind) and category_edit.php (id), the new category_merge.php (category_ids[]), and js/category_form.js. admin/post/category.php: add_category and edit_category (category_id, type, name, description, color, icon), GET archive_category, restore_category, delete_category and move_category (direction up or down), POST merge_categories (category_ids[], into_category_id), reorder_categories (category, category_ids[] - a page of the current ones - and offset, how many come before them; answers JSON), and bulk_archive_categories, bulk_restore_categories and bulk_delete_categories (category_ids[]). Audit entries are Category Create, Edit, Archive, Restore, Delete and Merge, naming the kind.
    • Removed: agent/post/category.php (the + window posts to admin/post.php from any page), and admin/post/category_model.php and agent/post/category_model.php.
    • Pickers: categoryPicker($kind, $keep, $opts) - the current ones in the kind's order plus $keep (ids, or names for a kind held by name) even when archived, a kept name that is no category as a row with loose and no id, keyed by the value posted; $opts since (also those archived after then) and exclude (names). categoryOptionHtml($row, $selected) (data-color - an asset status's badge color, data-icon - a software type's icon from its name when it has none, data-description, and "(archived)" or "(not set up)"), categoryOptionsHtml($kind, $selected, $opts). expenseCategories(), incomeCategories() and billingCategories($since, $keep_id) return categoryPicker(). Every record form and Bulk Action window that picks a category writes its options with categoryOptionsHtml().
    • The +: categoryAddButtonHtml($kind, $class) - admins only, opening admin/modals/category/category_add.php?category=&inline=1, marked data-category-add. js/app.js notes the picker beside it in window.itflowCategoryTarget; js/category_form.js posts the inline window with fetch, and add_category with inline answers JSON - ok, value, option (the picker's <option>, selected) and message, or ok false and error. Without inline it goes back to the page it came from, or to the kind in Admin > Categories. The category windows post to /admin/post.php.
    • List filters: categoryFilterOptionsHtml($kind, $selected, $used_sql, $opts) - the categories the list's records have ($used_sql selects their ids, or names for a kind held by name), archived ones and loose names included, plus $selected; without $used_sql, the current ones. The list writes its own "any" option. The category filters of expenses, recurring expenses, income (other income's categories and those of the invoices paid), invoices, products, tickets, recurring tickets and clients' referrals, and the expense, income, invoice, product and ticket export windows, use it.
    • New list parameters: agent/quotes.php?category=, agent/recurring_invoices.php?category=, agent/assets.php?status= (a name; the type tabs and $tab_counts take it in), agent/software.php?software_type= ($tab_counts take it and license_type in) and agent/racks.php?type= (a name). buildExportModalUrl() hands each to its export window, and export_quotes and export_recurring_invoices take category, export_assets status and export_software software_type, each in the filter summary. Export columns: category_name for invoices, quotes and recurring_invoices, and asset_status for assets, off until ticked; the quote and recurring invoice handlers select category_name.
    • categoryKinds(): modules - the modules whose records hold a kind, any one of which reads it (Income is module_sales or module_financial) - and uses URLs for quotes, recurring invoices, assets, licenses and racks.
    • categoryMarkHtml($color, $icon, $dot) - the icon in the color (.itflow-category-icon), else $dot (sprintf with a style attribute or '') or .itflow-category-dot. expenseCategoryDot($color, $icon) returns it, and the ticket lists, a ticket and its quick look pass their .itflow-ticket-dot. category_icon is now selected by incomeUnionSql() (both halves), incomeRevenueRow(), incomePaymentRow(), the account ledger in functions/accounts.php, and the expense, recurring expense, ticket, recurring ticket and ticket quick look queries.
    • API: api/v1/categories/read.php (GET; kind - a category_type or its label, any case - category_id (archived or not), category_name (any case), archived, limit, offset) answers category_id, category_name, category_description, category_type, category_color and category_icon (or null), category_order, category_created_at, category_updated_at, category_archived_at, category_used and category_used_by (by table; both null for a user limited to certain clients, as they count every client's records). api/v1/categories/categories_model.php: categoryApiCanRead(), categoryApiReadableKinds(), categoryApiKind() (refuses an unknown kind or one the user can't read) and categoryApiRow(). enforce_api_rbac.php maps categories to ''.
    • categoryEnsure($kind, $name) - a name typed for a kind held by name as its category's own spelling, matched in any case, archived or not, or made (with the next color, logged); '' for none. add_client, edit_client and the client import use it for the referral, in place of their own insert.
    • js/app.js: a modal shown over another (show.bs.modal) takes a z-index 20 above it, its backdrop just under it, and modal-open goes back on the body when the top one closes.
    • selectOptionColor() turns an icon kept as fa-name into fas fa-name.
    • admin/settings_default.php reads paymentMethods(), keeping a saved value that is no longer one.
    • CSS: .itflow-category, -mark, -name and -badge (categoryChip()), .itflow-category-swatch-none, .itflow-category-kinds and .itflow-category-grip.
  • Roles (database 3.1.6):

    • The migration deletes user_role_permissions rows of a role or module that is gone or of level 0 or less, keeps the highest level (held to 3) where a role has a module twice, and adds PRIMARY KEY (user_role_id, module_id).
    • lookupUserPermission() reads the session role's levels in one query and keeps them for the request (a static, keyed by role). It still returns 3 for an admin and false for none.
    • New functions/roles.php, loaded by functions.php: ROLE_LEVELS, ROLE_LEVEL_TONES, roleModuleInfo() (by module_name: label, icon, about, levels - what each adds - and max), roleModules() (module_id => that, from the modules table, a module it doesn't know named from its row), roleLevels($role_ids), roleRows($archived) (with levels, agents and agents_archived), roleRow(), roleAgents($role_ids, $limit), roleAccessHtml($row), roleFromInput($in, $role_id) ([$fields, $error]: name, description, admin, levels), roleAdminBlocker(), roleArchiveBlocker(), roleDeleteBlocker(), roleChangeText($before, $fields), roleSave($fields, $role_id) (an admin role keeps its levels), roleSetArchived() and roleDelete().
    • Admin: admin/roles.php is rebuilt (q, sort - role_name, agents or role_updated_at - order, page and show=archived, archived=1 still taken). admin/modals/role/role_form.php is shared by role_add.php (copy - a role to start from) and role_edit.php (id), with js/role_form.js. admin/post/roles.php: add_role and edit_role (role_id, name, description, admin, levels[module_id] 0 to 3 - the old role_name, role_description, role_is_admin and module_id##module_name fields are gone), and GET archive_role, restore_role and delete_role. Audit entries are User Role Create, Edit (with what changed), Archive, Restore and Delete.
    • The hard-coded role_id !== 3 checks on the roles page are gone; the guards are roleAdminBlocker() and the archive and delete blockers.
    • CSS: .itflow-role-agents and .itflow-role-level-list.
    • Refusals: enforceUserPermission() and enforceAdminPermission() go through permissionRefuse($message) in functions/auth.php. It answers a window (ITFLOW_MODAL, now defined by includes/modal_header.php) with its own JSON content, ajax.php with a 403 and {ok: false, error}, a handler or a page that hasn't drawn anything yet with flashAlert() and redirect(permissionBackUrl()), and a page already drawing with a .itflow-refused card and the footer. Also permissionRefusedText($module, $level, $has), permissionRoleName() and permissionBackUrl() (the referrer on this host unless it is this page, else the dashboard). The session alert is no longer set before the old exit. WORDING_ROLECHECK_FAILED is kept but no longer used.
    • reportCanOpen($key) in functions/reports.php, for links into a report. The credentials, unbilled time, expenses, recurring expenses, income and trips pages use it, and the dashboard's Profit card opens Transactions for the period without it.
    • LINK_ENTITIES has a view module for asset, software, service, domain and certificate (Support) and contact and vendor (Clients), so linkTypesFor() leaves them out for an agent without it. notificationTypeModules() files Shared link opened, Shared link locked, Login sent back and Share Viewed under Support.
    • agent/includes/inc_client_top_head.php includes modals/client/client_delete.php only where Delete Client is in the menu. agent/includes/client_overview_side_nav.php checks Credentials.
    • The role window marks each module data-role-module="module_name" and has data-role-needs-clients; the user form has a data-user-role-access block per role.
  • API Keys (database 3.1.7):

    • The migration adds api_keys.api_key_last_used_at (DATETIME, null). api/v1/validate_api_key.php sets it on a call that gets past the key check, at most once a minute, in config_timezone, as the API doesn't set PHP's or MySQL's clock to it.
    • New functions/api_keys.php, loaded by functions.php: apiKeyRow($id) (the key with its agent's user_name, user_avatar, user_type, user_status, user_archived_at and user_role_id), apiKeyExpired($row), apiKeyAgentProblem($row, $role), apiKeyStatePill($row, $role), apiKeyAgents() (active agents), apiKeyFromInput($in, $current_expire) ([$fields, $error]: name, expire, user_id) and apiKeyAgentFieldHtml($selected) (the run_as_user picker, with a data-api-key-access block per agent).
    • Admin: admin/api_keys.php is rebuilt (q, sort - api_key_name, api_key_expire, user_name or api_key_last_used_at - order, page and show=expired). 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().
  • Emails:

    • New functions/email.php, loaded by functions.php: emailHtml($email) draws every email - billingEmailHtml() is now a wrapper for it that greets nobody without a name - with new fields marker (the reply line, first), preview (inbox preview text), message (html, by, when, label - HTML someone wrote, through emailMessageHtml()), callout, contact (the foot's contact line) and the secondary tone, and name '' for "Hello,". EMAIL_TONES and EMAIL_REPLY_MARKER. emailMessageHtml($html) purifies it (documentHtmlPurify()), drops the reply line, makes / links and images absolute, and caps image widths. emailFrom($which) (system or ticket, read from the settings as saved). emailQueue($recipients, $subject, $email, $opts) - recipients with email, name, greet and footnote, raw values, escaped for addToMailQueue(), each address once; from and attachments in $opts.
    • functions/ticket.php: ticketEmailContent($row, $kind, $opts) (created with how agent, email or scheduled, reply, resolved, closed, watcher) and ticketMailSend($ticket_id, $subject, $email, $opts) (attachments, to_contact, to_watchers, to, kind) replace ticketMailContactAndWatchers() and ticketMailCompany(). Also ticketEmailFrom(), ticketEmailUrl(), ticketEmailContactLine(), ticketEmailSla() (in place of getTicketSlaEmailNotice(), removed) and ticketMailClosed(). ticketMailResolved($ticket_id, $note) takes the note as raw HTML. The reply handlers read the reply back from ticket_replies for the email, so it carries the signature as saved.
    • recurringTicketRaise() declares $config_ticket_client_general_notifications as a global, and tells the new-ticket address through ticketMailNewTicketAddress(). cron/nightly_tasks.php calls ticketMailClosed() for each ticket it closes. cron/ticket_email_parser.php's new-ticket reply and closed-ticket bounce, api/v1/ticket_replies/create.php, add_ticket_watcher, bulk_ticket_reply, bulk_resolve_tickets and add_ticket_task_approver go through them too.
    • functions/email.php: emailSettings() reads the mail settings from the database once per request, and emailReady() says whether outgoing mail is set up - so cron scripts, which keep escaped copies of the settings or none, can use them. emailFrom() reads through it. emailQueue() takes cal_str in $opts.
    • functions/ticket.php, emails to agents: ticketAgentEmailContent($row, $kind, $opts) (new with how portal, email or scheduled; replied with how, message and by; assigned with by; scheduled with when, where and cancelled; sla with track, breached and due), ticketAgentUrl($row), ticketMailAgent($user_id) (an active agent's email and name, or null), ticketMailAgents($recipients, $subject, $email) (from the ticket mailbox), ticketMailNewTicketAddress($ticket_id, $how), ticketMailAssigneeReplied($ticket_id, $how, $reply_html, $by), ticketMailAssigned($user_id, $ticket_ids, $by) and recurringTicketMailAssigned($user_id, $subjects, $by) - not when an agent assigns themselves. ticketEmailContent() has a scheduled kind, and ticketMailSend() passes cal_str. ticketMailSchedule() sends the agent's email through ticketAgentEmailContent() and the client's through ticketMailSend(), both with the calendar file.
    • Callers: ticketChangeAssignee(), recurringTicketChangeAssignee(), bulk_assign_ticket, add_ticket and edit_ticket (agent/post/ticket.php), recurringTicketNotifyAgent() (agent/post/recurring_ticket.php), the portal's add_ticket, add_ticket_comment and stripe_save_card (client/post.php, the last through billingEmailQueue()), the parser's addTicket() and addReply(), login.php's sign in alerts, add_user, shareNotifyCreator() and test_email_smtp.
    • cron/ticket_sla.php: sendSlaAlert($ticket, $track, $breached, $due) - $track is response or resolution - in place of sendSlaAlert($ticket, $subject_line, $body_line). $from_email and $from_name are gone.
    • functions/user.php: userSignInUrl() (with the login key when one is required), userMailAppName(), userMailNewAccount($name, $email, $password, $force_mfa) (true when queued) and userMailSignInAlert($name, $email, $kind, $ip, $user_agent) (new or mfa). userNotifyChange() sends through emailQueue(). userQueueMail() is removed.
    • Every email now goes through emailQueue() - addToMailQueue() is called only from there and from a preview's Send me a copy. emailHtml() takes content (HTML that is the email itself, made safe by emailMessageHtml() and shown as it is) and button.show_url (the address printed under the button), and leaves out the small heading when kind is empty. emailQueue() takes queued_at and always (queue without mail set up - billingEmailQueue() passes it, as billing's Send windows always queued). New emailMailboxes() (the From addresses as system, ticket, invoice and quote), emailTestSend($to, $mailbox), emailBulkSend($recipients, $mailbox, $from_name, $subject, $body, $greet, $queued_at) and emailBulkPost($recipients, $picked), which the contacts and clients handlers call. Their windows share agent/includes/bulk_email_fields.php and post mailbox and greet in place of mail_from.
    • Capturing: between emailCaptureStart() and emailCaptureStop(), emailQueue() keeps each email (to, from, subject, html, attachments, cal_str) instead of queuing it, emailReady() is true, and triggerCustomAction() does nothing (emailCapturing()).
    • New functions/email_preview.php: emailPreviewCatalog() (each email's group, label, who gets it, what sends it, and the call that sends it), emailPreviewSample() (the sample records), emailPreview($key) (in a transaction that is rolled back, logging an app error if anything ended it early) and emailPreviewSendCopy($key, $to, $name). admin/mail_preview.php returns one as JSON for the Previews tab, and send_email_preview in admin/post/settings_mail.php sends a copy.
    • Pulled out of the handlers so the previews can call them: ticketMailCreated() (from agent/post/ticket.php, now with $how and $to, which the email parser uses), ticketMailReply($ticket_id, $reply_html, $by, $resolved, $attachments) (the reply handler, bulk reply and the API), ticketMailWatchersAdded(), ticketMailTaskApproval($approval_id, $by), ticketMailClosedReply($ticket_id, $from_email) and ticketSlaAlert($ticket_id, $track, $breached, $due, $address) in functions/ticket.php - sendSlaAlert() in cron/ticket_sla.php is gone - and billingMailCardSaved() in functions/billing_email.php.
    • functions/portal.php: portalMailReady(), portalMailInvite($user_id, $by) (used by add_contact, edit_contact - which take send_email - and the portal's portalContactInvite()), portalMailPasswordReset($user_id) and portalMailAccountChange($email, $name, $change) (password, password_link, mfa_on, mfa_off, mfa_off_by_us). portalAccountEmail() sends through emailQueue().
    • functions/share.php: shareMailHtml() and shareQueueMail() are removed. shareEmailLink() returns a bool, and new shareEmailCode($row, $code) and shareMailFrom(). shareMailReady() goes by emailReady(). calendarSendInvites() sends through emailQueue(), and calendarCanEmail() is emailReady().
  • Ticket templates:

    • Database 3.1.9: ticket_templates gains ticket_template_category_id, ticket_template_priority (NULL leaves it), ticket_template_billable (NULL leaves it), ticket_template_assigned_to and ticket_template_due_hours; new ticket_template_tags (ticket tags, cascading like ticket_tags); tickets.ticket_template_id with a key.
    • New functions/ticket_template.php: ticketTemplateRow(), ticketTemplateTasks(), ticketTemplateTagIds(), ticketTemplateFields($post, &$error), ticketTemplateSave($id, $fields), ticketTemplateSetTags(), ticketTemplateCopy(), ticketTemplateUsage($ids), ticketTemplateDefaultsHtml(), ticketTemplatePickerData(), ticketTemplateDueText(), ticketTemplateDueAt(), ticketTemplateMinutesText() and ticketTemplatePlaceholdersIn(); placeholders in TICKET_PLACEHOLDERS, ticketPlaceholderValues($ticket_id), ticketPlaceholderFill($text, $values, $html) and ticketPlaceholdersFill($ticket_id); and ticketTemplateFinish($ticket_id, $template_id, $defaults, $tasks), which every path that raises from a template calls - add_ticket, bulk_add_asset_ticket, bulk_add_client_ticket (rewritten, with use_primary_contact now a switch in its window) and projectApplyTemplate(), which now takes each ticket template's settings. recurringTicketRaise() records the recurring ticket's template and calls ticketPlaceholdersFill(). projectTemplateSubject() is gone.
    • agent/includes/inc_ticket_template_select.php writes each template's settings as data-template-* on its option and takes $ticket_template_field, $ticket_template_apply_on_load and $ticket_template_hint; agent/js/ticket_tasks_modal.js writes them into the form's fields marked data-template-field (subject, details, priority, category, assigned, due, tags, billable) and the task rows. ticket_add.php takes ticket_template_id, and agent/tickets.php?new_from_template= opens it.
    • Admin: admin/modals/ticket_template/ticket_template_form.php is shared by the add and edit windows, which are ajax windows now; copy_ticket_template, archive_ticket_template and restore_ticket_template are new. admin/post/ticket_template.php no longer requires agent/post/ticket.php and agent/post/task.php, and edit_ticket_template_task moved there from agent/post/task.php.
    • functions/tags.php: tagIdsFromValues($entity, $values) and tagKeepIds($entity, $ids, $had) are split out of tagSetForRecord(), and tagFormFieldHtml() takes the select's extra attributes.
    • Database 3.2.0: fill-ins. ticket_template_fill_ins (fill_in_name, fill_in_type - text, date, list or yesno - fill_in_options one per line, fill_in_required, fill_in_help, fill_in_order; unique per template and name, cascading with the template) holds only the ones set up or dragged into order - the rest of a template's bracketed words are asked as optional text, so templates made elsewhere (starter content, demo data) need nothing. ticket_fill_ins and recurring_ticket_fill_ins keep the answers with their question as it was (name, type, options, required, value, order), cascading with the ticket or recurring ticket. task_templates gains task_template_when_fill_in and task_template_when_value (NULL: always).
    • functions/ticket_template.php: TICKET_PLACEHOLDER_NAME (what may go in brackets), TICKET_FILL_IN_TYPES, ticketFillInName(), ticketFillInOptions(), ticketTemplateFillInRowsFor($ids), ticketTemplateFillIns($template, $tasks, $rows) (the rows, then the text's other bracketed words, each with in_text) and ticketTemplateFillInsFor($id), ticketFillInAnswers($fill_ins, $posted, &$error, $required) (checks fill_ins[Name]), ticketFillInText(), ticketFillInsStored($kind, $id) / ticketFillInsStore($kind, $id, $answers) for ticket and recurring_ticket, ticketFillInFieldHtml(), ticketTemplateTasksFor($ids) (tasks carry when), ticketTemplateTaskApplies(), ticketTemplateAddTasks($ticket_id, $template_id, $answers) (replaces addTasksFromTicketTemplate(), removed from functions/app.php), ticketTemplateWhenChoices(), ticketTemplateWhenFromPost(), ticketTemplateTaskWhenOffered(), ticketTemplateWhenSelectHtml(), ticketTemplateTaskWhenText() and ticketTemplateRenameFillIn(). ticketPlaceholderFill() fills any names it is given, matching the editor's &nbsp; between words; ticketPlaceholdersFill() adds the ticket's answers; ticketTemplatePlaceholdersIn() reads HTML as text. ticketTemplateFinish() takes $answers (null: kept unanswered) and ticketTemplatePickerData() the fill-ins.
    • Handlers: add_ticket, bulk_add_asset_ticket, bulk_add_client_ticket, add_recurring_ticket and edit_recurring_ticket check fill_ins[] before saving anything; recurringTicketRaise() copies a recurring ticket's answers onto each ticket. New edit_ticket_fill_ins and agent/modals/ticket/ticket_fill_ins.php. Admin: save_ticket_template_fill_in, delete_ticket_template_fill_in, reorder_ticket_template_fill_ins (JSON) and admin/modals/ticket_template/ticket_template_fill_in.php; add_ticket_template_task and edit_ticket_template_task take when (Name|Choice).
    • Export and import: new functions/ticket_template_transfer.php - ticketTemplatesExport($ids, $archived) (the file's contents: format itflow-ticket-templates, version 1, templates), ticketTemplatesExportFileName(), ticketTemplatesImportRead($contents, &$error) (checks and tidies a file - details through documentHtmlPurify() - and takes the starter pack's array the same way), ticketTemplatesImport($templates, $existing, $only_existing) (skip, copy or replace; categories through categoryFromInput()/categoryCreate(), tags through tagCreate()) and ticketTemplatesImportSummary(). Handlers export_ticket_templates (POST, ticket_template_ids[] and archived), export_ticket_template (GET, one) and import_ticket_templates, with ticket_template_export.php and ticket_template_import.php windows.
    • Starter Content: starterContentTicketTemplates() is written in the file format and starterLoadTicketTemplates() loads it through ticketTemplatesImport(); new starterUpdateTicketTemplates() and the update_starter_ticket_templates handler replace the ones already here.
    • Report: agent/reports/ticket_templates.php, ticket_templates in reportCatalog() under Service Desk.
    • admin/ticket_templates.php takes q, sort - ticket_template_name, task_count, used_count or last_used - order, page and show=archived (archived=1 still taken); its use is counted in the query, as ticketTemplateUsage() counts it.
    • Database 3.2.1: request forms. ticket_templates gains ticket_template_portal, ticket_template_portal_title, ticket_template_portal_description, ticket_template_portal_icon (one of TICKET_TEMPLATE_PORTAL_ICONS), ticket_template_portal_who (everyone or managers) and ticket_template_portal_all_clients; new ticket_template_portal_clients (cascading with the template and the client). fill_in_client, ticket_fill_in_client and recurring_ticket_fill_in_client say whether the client is asked a fill-in and sees its answer (default yes).
    • Request forms: ticketTemplatePortalForms($client_id, $manager) and ticketTemplatePortalForm() (what a contact may use - $manager is portalSeesAllTickets()), ticketTemplatePortalTitle(), ticketTemplatePortalIcon(), ticketTemplateMentionsAsset(), ticketTemplatePortalAnswers() (the client's answers checked, the agents' left blank), ticketTemplatePortalRaise() and ticketTemplatePortalRequests($ids); ticketTemplatePortalClientIds() and ticketTemplateSetPortalClients(). ticketTemplateFields() and ticketTemplateSave() take the portal fields, and ticketTemplateFinish() takes the history line. client/ticket_add.php takes ?form= (problem for the usual form) and posts add_ticket_from_form to client/post.php; its What happens next is client/includes/ticket_next_steps.php. [Agent] is no longer filled in with a client's name for a ticket raised in the portal with no agent - only an agent who raised it counts.
    • inc_ticket_template_select.php draws a [data-fill-ins-box] under the picker ($ticket_template_fill_in_values starts it with answers) and the options carry data-template-fill-ins; ticket_tasks_modal.js draws the fields, hides and disables the task rows for other answers, and binds its document listeners once (a window flag). js/app.js opens the tab of a required field left empty on a hidden tab (a capturing invalid listener).
  • Canned responses:

    • Database 3.2.2: canned_response_categories (canned_response_id, category_id) takes over from canned_responses.canned_response_category_id, which is dropped - a response with none is offered on every ticket. canned_response_uses (canned_response_use_canned_response_id, canned_response_use_user_id, canned_response_use_ticket_id, canned_response_use_created_at) keeps each time one goes into a reply.
    • New functions/canned_response.php: CANNED_RESPONSE_PLACEHOLDERS, cannedResponseRow(), cannedResponseCategoryIds(), cannedResponseCategoriesFor($ids), cannedResponseFields($post, &$error) (name, body, category_ids), cannedResponseSave($id, $fields), cannedResponseSetCategories(), cannedResponseCopy(), cannedResponsePreview(), cannedResponsesForTicket($category_id) (category and general), cannedResponseValues($ticket_id, $agent_name), cannedResponseFill($html, $ticket_id, $agent_name) (purified, then filled through ticketPlaceholderFill()) and cannedResponseRecordUses($ids, $ticket_id, $user_id).
    • Admin: admin/canned_responses.php takes q, category (an id, or every), sort - canned_response_name, use_count or last_used - order, page and show=archived. admin/modals/canned_response/canned_response_form.php is shared by canned_response_add.php (category) and canned_response_edit.php (id), with js/canned_response_form.js. admin/post/canned_response.php: add_canned_response and edit_canned_response (canned_response_id, name, body, category_ids[]), GET copy_canned_response, archive_canned_response, restore_canned_response and delete_canned_response. Audit entries are Canned Response Create, Edit, Archive, Restore and Delete.
    • agent/ajax.php?get_canned_response takes ticket_id and answers the body filled in for that ticket, after the client access check. The picker adds canned_response_ids[] to the reply form, and add_ticket_reply records them when there is a reply.
    • Database 3.2.3: canned_responses gains canned_response_reply_type (0 internal note, 1 public note, 2 email reply - NULL leaves it), canned_response_ticket_status (NULL leaves it) and canned_response_minutes, added to the time worked.
    • functions/canned_response.php: CANNED_RESPONSE_REPLY_TYPES, CANNED_RESPONSE_MAX_MINUTES, cannedResponseStatuses() (the ones a reply can set), cannedResponseMinutesText(), cannedResponseSetsUpHtml($row), cannedResponseActions($row) and cannedResponsePickerGroups($category_ids) - label => rows, each category with any then Every ticket; cannedResponsesForTicket() returns it for one. cannedResponsePlaceholders($html, $ticket_id, $agent_name) fills without purifying, and cannedResponseFill() purifies then calls it. cannedResponseFields() takes reply_type, ticket_status and minutes.
    • The picker: agent/includes/inc_canned_response_picker.php ($canned_picker_groups, $canned_picker_id, $canned_picker_editor, $canned_picker_ticket_id - 0 for Update & Reply) and agent/js/canned_response_picker.js, which exposes window.itflowCannedPick(pickerId, id) and asks before a submit with bracketed words left. js/app.js itflowCannedAutocompleter(editor) adds the / menu to the .tinymce and .tinymceTicket editors whose textarea names a picker in data-canned-picker. get_canned_response answers found, id, name, body, reply_type, reply_type_name, ticket_status, status_name, minutes and minutes_text.
    • bulk_ticket_reply fills the placeholders for each ticket with cannedResponsePlaceholders() - its email too - and records canned_response_ids[] on each. agent/js/ticket_time_tracking.js resets the timer on the reply form's submit, when it isn't prevented, rather than on a click of Send.
    • Categories: a use in categoryKinds() can be a link table - link (owner, records, join). categoryUsage() joins it, categoryMoveRecords() adds the new category and takes the old ones off, and the API's used_by counts them under records (still canned_responses).
    • Database 3.2.4: canned_responses gains canned_response_user_id (0 for a shared one, otherwise the agent whose own it is - indexed) and canned_response_created_by. New canned_response_files (canned_response_file_id, canned_response_file_canned_response_id - deleted with its response - canned_response_file_name, canned_response_file_reference_name, canned_response_file_size, canned_response_file_created_at); the files are kept in uploads/canned_responses/<id>/.
    • functions/canned_response.php: cannedResponseUsableSql($user_id) and cannedResponseUsable($row, $user_id) (shared, or theirs). cannedResponseSave($id, $fields, $owner_id = 0) sets the owner and maker of a new one. cannedResponseCopy($id, $owner_id = null) copies the files too, and is the same owner's unless given one. cannedResponsePickerGroups($category_ids, $user_id) and cannedResponsesForTicket($category_id, $user_id) take the agent - Yours first, the agent's own for those categories or every ticket - and each row has file_count. Files: CANNED_RESPONSE_MAX_FILES, cannedResponseFileDir(), cannedResponseFilesFor($ids), cannedResponseFiles($id), cannedResponseSaveFiles($id, $field = 'files') (returns the names refused), cannedResponseRemoveFiles($id, $file_ids), cannedResponseCopyFiles($from, $to), cannedResponseDeleteFiles($id), cannedResponseAttachFiles($file_ids, $ticket_id, $reply_id, $user_id) (copies onto the ticket and returns what saveTicketAttachments() does - only the files of responses the agent may use) and cannedResponseFileSizeText(). cannedResponseUnfill($html, $ticket_id, $agent_name) returns [$html, $placeholders], swapping in the text only.
    • functions/files.php: TICKET_ATTACHMENT_EXTENSIONS, which saveTicketAttachments() uses.
    • The response window's fields moved to includes/canned_response_form.php, with a Files tab (files[], remove_file_ids[]), shared by the admin windows and the account ones, which post as multipart. admin/canned_responses.php takes show=personal. admin/post/canned_response.php has GET share_canned_response, copy_canned_response makes a shared copy, and add and edit keep and remove files.
    • Your account: agent/user/user_canned_responses.php (q, category, sort, order, page, show=shared), in the account nav for Support at Modify. agent/user/modals/canned_response_add.php (category), canned_response_edit.php (id) and canned_response_save.php (ticket_id, editor, picker) with agent/js/canned_response_save.js. agent/user/post/canned_response.php: add_your_canned_response - with respond_json=1 (and ticket_id, and share for an admin) it answers JSON: ok, id, name, preview, sets_up, group (where the picker on that ticket offers it, or ''), shared, message and refused, or ok: false with error - edit_your_canned_response, and GET copy_your_canned_response, copy_canned_response_to_yours, archive_your_canned_response, restore_your_canned_response and delete_your_canned_response, each the agent's own only. Their audit entries say "their own".
    • agent/ajax.php: get_canned_response answers only a shared one or the agent's own, with files (id, name, size). POST canned_response_unfill (csrf_token, ticket_id, html) answers html, original and placeholders, for Support at Modify. agent/canned_response_file.php?file_id= (action=view for inline) streams a file to those who may use its response, and to admins.
    • add_ticket_reply and bulk_ticket_reply take canned_response_file_ids[], put on the reply with cannedResponseAttachFiles() and sent with its email. The picker is drawn on a ticket even with none on offer ([data-canned-empty]), ends with [data-canned-save-open] and a link to the agent's own, and shows the files in [data-canned-files]; the ticket's reply box always names it in data-canned-picker.
    • Export and import: new functions/canned_response_transfer.php - cannedResponsesExport($ids, $archived, $with_files) (the file's contents: format itflow-canned-responses, version 1, responses, each with name, body, categories by name, reply_type - internal, public, email or null - status by name, minutes and, when exported with them, files as name and base64 data, each up to CANNED_RESPONSE_TRANSFER_FILE_MAX), cannedResponsesExportFileName(), cannedResponsesImportRead($contents, &$error) (checks and tidies a file - the body through documentHtmlPurify(), statuses matched to cannedResponseStatuses(), files to TICKET_ATTACHMENT_EXTENSIONS - and takes the starter pack's array the same way), cannedResponsesImport($responses, $existing, $only_existing) (skip, copy or replace, against shared ones only; a response without files keeps its own), cannedResponseImportCategory(), cannedResponseAddFileData($id, $name, $bytes) and cannedResponsesImportSummary(). Handlers in admin/post/canned_response.php: POST export_canned_responses (canned_response_ids[], archived, files), GET export_canned_response and POST import_canned_responses (file, existing), with admin/modals/canned_response/canned_response_export.php and canned_response_import.php. Audit entries are Canned Response Export and Import.
    • Starter Content: a pack can say how its ones already here are updated (update - button, confirm - in starterContentPacks()), which draws the Update button. New canned_responses pack: starterContentCannedResponses() in the file format, starterLoadCannedResponses() through cannedResponsesImport(), and starterUpdateCannedResponses() with the update_starter_canned_responses handler.
    • Report: agent/reports/canned_responses.php (client_id, agent - who used it - and the period; export=csv by response, table=agents by agent), canned_responses in reportCatalog() under Service Desk.
  • Document templates:

    • Database 3.2.5: documents.document_template_id (indexed; 0 for none) - the template a document was made from.
    • New functions/document_template.php: documentTemplateDir(), documentTemplateRow($id) (with created_by_name and updated_by_name), documentTemplateUsage($ids) (count, clients, last - documents not archived), documentTemplateFields($post, &$error) (name, description, content), documentTemplateSave($id, $fields) (images through saveBase64Images(), the unused ones through cleanupUnusedImages(); a new one's first save leaves document_template_updated_at empty), documentTemplateCopyImages(), documentTemplateCopy($id), documentTemplateDelete($id) (clears the template from its documents and takes its folder away), documentTemplateMakeDocument($id, $client_id, $name, $description, $folder_id) (the template's name and summary when none given), documentTemplatePreview(), documentTemplateBracketsIn() and documentTemplateMarkBrackets().
    • Admin: admin/document_templates.php takes q, sort - document_template_name, used_count, last_used or changed_at - order, page and show=archived. admin/document_template.php is rebuilt on the ticket template page's layout. admin/modals/document_template/document_template_form.php is shared by document_template_add.php and document_template_edit.php (id); new document_template_use.php (id) posts add_document_from_template to agent/post.php. admin/post/document_template.php: add_document_template (redirects to its page), edit_document_template, GET copy_document_template, archive_document_template, restore_document_template and delete_document_template (from_page). Audit entries are Document Template Create, Edit, Archive, Restore and Delete.
    • add_document_from_template makes the document with documentTemplateMakeDocument(), checks the client exists and redirects to /agent/document.php. agent/modals/document/document_add_from_template.php takes document_template_id to pick one, and shows each one's use.
    • Database 3.2.6: new document_template_fill_ins (document_fill_in_id, document_fill_in_document_template_id - deleted with its template - document_fill_in_name, unique per template, document_fill_in_type - text, date, list or yesno - document_fill_in_options, one per line, document_fill_in_required, document_fill_in_help and document_fill_in_order).
    • functions/document_template.php: DOCUMENT_TEMPLATE_PLACEHOLDERS, DOCUMENT_TEMPLATE_SITE_PLACEHOLDERS, DOCUMENT_TEMPLATE_CONTACT_PLACEHOLDERS and DOCUMENT_TEMPLATE_TABLES (name => what it is); documentFillInName() ('' for a placeholder's or table's name), documentTemplateWordKind($word) (placeholder, table or fill_in), documentTemplateBracketsInAll($template) and documentTemplateWordsUsed($template) (its name, summary and content), documentTemplateFillInRowsFor($ids), documentTemplateFillIns($template, $rows) and documentTemplateFillInsFor($id) (the ones set up, then the rest in the text as optional text, each with in_text) - in the shape ticketFillInAnswers() and ticketFillInFieldHtml() take - documentTemplateRenameFillIn(), documentTemplateClientLocations() and documentTemplateClientContacts() (primary first), documentTemplateValues($client_id, $location_id, $contact_id, $agent_name) (-1 for none, 0 or another client's for the primary), documentTemplateTableData($table, $client_id) (head, rows, names), documentTemplateTableHtml() and documentTemplateFill($html, $client_id, $values). documentTemplateMakeDocument() takes $location_id, $contact_id and $answers (from ticketFillInAnswers()) after $folder_id, and fills the name, summary and content. documentTemplateCopy() copies the fill-ins, and documentTemplateMarkBrackets() marks each kind in its own color.
    • Admin: new admin/modals/document_template/document_template_fill_in.php (document_template_id, name). admin/post/document_template.php: save_document_template_fill_in (original_name to change or rename one), GET delete_document_template_fill_in (reset while still in the text, otherwise removed) and POST reorder_document_template_fill_ins (names[], answers JSON). Audit entries are Document Template Edit.
    • agent/ajax.php?document_template_asks=ID&client_id=ID returns {html} - what the template fills in, the location_id and contact_id pickers when it uses them, and its fill-ins as fill_ins[Name] - or '' without Support at edit level, access to the client, or a live template. add_document_from_template checks the answers with ticketFillInAnswers() and takes location_id and contact_id. New agent/js/document_template_make.js draws it in a form with data-doc-template-make, from its data-doc-template-select and data-doc-template-client fields into data-doc-template-asks; new js/document_template_form.js puts a data-document-template-word button's word in the editor (on a line of its own with data-document-template-table). ai_create_document_template tells the model the placeholders and tables.
    • Database 3.2.7: document_templates.document_template_required (0 no one, 1 every client, 2 the clients with one of its tags - none with no tags left) and document_template_review_months (0 never); new document_template_required_tags (document_template_id, tag_id - client tags, deleted with either); documents.document_reviewed_at and document_reviewed_by.
    • functions/document_template.php: DOCUMENT_TEMPLATE_REQUIRED_NONE, _EVERY and _TAGGED, DOCUMENT_TEMPLATE_REVIEW_MONTHS; documentTemplateRequiredTags($ids), documentTemplateSetRequired($id, $required, $tag_ids, $months) (leaves document_template_updated_at alone), documentTemplateScopeSql($required, $tag_ids) (SQL on clients - current, not leads), documentTemplateScopeText(), documentTemplateCoverage($id, $required, $tag_ids) (clients, covered, missing), documentTemplatesMissingFor($client_id), documentReviewedAtSql() (the latest of made, edited and reviewed), documentReviewDueSql() (on documents joined to document_templates), documentsReviewDue($where), documentTemplateDueCounts($ids), documentReviewState($row) (last, reviewed, next, due) and documentTemplateMakeForClients($id, $client_ids, $answers, $skip_existing). documentTemplateRow() returns the two new columns; documentTemplateCopy() copies the review period and tags, not required; documentTemplateDelete() no longer touches its documents' document_updated_at.
    • Admin: new admin/modals/document_template/document_template_required.php (id) and document_template_use_several.php (id, missing=1); document_template_use.php takes client_id; admin/document_templates.php takes show=required. admin/post/document_template.php: save_document_template_required (required, tags[], review_months) and make_documents_from_template (client_ids[], skip_existing, fill_ins; a Document Create audit entry per document, under its client).
    • Agent: agent/post/document.php - GET mark_document_reviewed and POST set_document_template (document_template_id, 0 for none; an archived one only if it is already the one), both Support at edit level, neither touching document_updated_at; new agent/modals/document/document_template_link.php (id). agent/files.php takes type=due. clientOverviewAttention() adds Documents missing and Document reviews due.
    • New functions/document_template_transfer.php: DOCUMENT_TEMPLATE_FILE_FORMAT (itflow-document-templates), DOCUMENT_TEMPLATE_FILE_VERSION (1) and DOCUMENT_TEMPLATE_TRANSFER_IMAGE_MAX; documentTemplateInlineImages() (a template's own images as data URIs), documentTemplatesExport($ids, $archived) (name, description, content, fill_ins, review_months), documentTemplatesExportFileName(), documentTemplatesImportRead($contents, &$error) (through documentHtmlPurify(); an image pointing at /uploads/ dropped and counted), documentTemplatesImport($templates, $existing, $only_existing) (skip, copy or replace, through documentTemplateSave() so data images land in the template's folder; added, replaced, skipped, images, ids) and documentTemplatesImportSummary(). New admin/modals/document_template/document_template_export.php and document_template_import.php; admin/post/document_template.php: POST export_document_templates (document_template_ids[], archived), GET export_document_template, POST import_document_templates (file, existing); Document Template Export and Import audit entries.
    • Starter Content: starterContentDocumentTemplates() is in the file format and loads through documentTemplatesImport(); new starterUpdateDocumentTemplates() and the update_starter_document_templates handler, with the pack's update entry.
    • Report: agent/reports/document_templates.php (client_id, agent - who made them - and the period; export=csv by template, table=clients by client), document_templates in reportCatalog() under Clients & Documentation.
  • License templates:

    • Database 3.2.8: software.software_template_id (indexed; 0 for none) - the template a license was made from, set at the upgrade on licenses named exactly as one template (ignoring case and spaces at the ends) and nothing else; software_templates.software_template_created_by and software_template_updated_by.
    • New functions/software_template.php: softwareTemplateRow($id) (with created_by_name and updated_by_name), softwareTemplateUsage($ids) (count, clients, seats, used, renewing, expired, last - current licenses of current clients), softwareTemplateFields($post, &$error), softwareTemplateSave($id, $fields), softwareTemplateCopy($id), softwareTemplateDelete($id) (clears the template from its licenses, leaving software_updated_at alone), softwareTemplateLiveId($id) and softwareTemplateMakeLicense($id, $client_id, $post) (through softwareFormSet()).
    • Admin: admin/software_templates.php takes q, sort - software_template_name, software_template_type, software_template_license_type, used_count, last_used or changed_at - order, page and show=archived; new admin/software_template.php (software_template_id). admin/modals/software_template/software_template_form.php is shared by software_template_add.php and software_template_edit.php (id); new software_template_use.php (id, client_id) posts add_software_from_template to agent/post.php. admin/post/software_template.php: add_software_template (redirects to its page), edit_software_template, GET copy_software_template, archive_software_template, restore_software_template and delete_software_template (from_page). Audit entries are Software Template Create, Edit, Archive, Restore and Delete.
    • Agent: add_software keeps the software_template_id the New License form posts when it is a current template. add_software_from_template makes the license with softwareTemplateMakeLicense() - seats, key, purchase, expire and purchase_reference - checks the client and redirects to /agent/software.php?client_id=. The license quick look shows the template.
    • Database 3.2.9: software_templates.software_template_seats (null for no limit), software_template_term (a SOFTWARE_RENEWAL_TERMS key, or null), software_template_vendor_template_id and software_template_product_id (0 for none).
    • functions/software_template.php: softwareTemplateRow() also returns the vendor template's name and archived date and the product's name, price, cost, currency, frequency, seat source and archived date; softwareTemplateFields() reads seats, term, vendor_template_id and product_id (one that exists, archived or not); new softwareTemplateLinkId($table, $column, $id), softwareTemplateRenewal($term, $purchase), softwareTemplatePriceText($template), softwareVendorFromTemplate($vendor_template_id, $client_id, $make = true, &$made) (the client's vendor made from it, then one of its name, then - with $make - one made through vendorFormSet()) and softwareTemplateMakeForClients($id, $client_ids, $post, $skip_existing = true, $make_vendor = true) (made, skipped, vendors). softwareTemplateMakeLicense($id, $client_id, $post, $make_vendor = true, &$vendor_made) takes the template's seats when seats isn't posted and its term from purchase when expire is blank.
    • Admin: new admin/modals/software_template/software_template_use_several.php (id) posts make_licenses_from_template (client_ids[], skip_existing, seats, purchase, expire, purchase_reference) to admin/post/software_template.php, which redirects to the template's page. Deleting a vendor template or a product (productDelete()) clears it from the license templates that had it, leaving software_template_updated_at alone.
    • Agent: the New License form's templates carry seats, term, vendor_template_id and vendor; the vendor picker is data-software-vendor (with data-vendor-can-make for someone with module_client 2) and its options data-vendor-template-id. add_software takes vendor=template:N, made with softwareVendorFromTemplate() when the agent has module_client 2. add_software_from_template and make_licenses_from_template audit a vendor they made as Vendor Create. The Renew window starts on the template's term when the licenses share one.
    • Database 3.3.0: software_templates.software_template_required (0 no one in particular, 1 every client, 2 the clients with one of its tags) and software_template_required_tags (software_template_id, tag_id; cascades with either), whose tags tagMerge() moves too.
    • functions/software_template.php: SOFTWARE_TEMPLATE_REQUIRED_NONE, _EVERY and _TAGGED; softwareTemplateRequiredTags($ids), softwareTemplateSetRequired($id, $required, $tag_ids) (client tags only, leaving software_template_updated_at alone), softwareTemplateScopeSql(), softwareTemplateScopeText(), softwareTemplateCoverage($id, $required, $tag_ids) (clients, covered, missing), softwareTemplatesMissingFor($client_id), softwareTemplateLicenses($id, $product_id) (each with seats, renewal and billing), softwareTemplateBilling($licenses, $product_id) (how - seats or product - item_id, recurring_invoice_id, number, paused, quantity, count, drift), softwareTemplateLicenseViews($has_product), softwareTemplateTrueUp($id, $seats_by_id), softwareTemplateSeatsText(), softwareTemplateRecurringFor($client_ids) and softwareTemplateBill($id, $software_id, $recurring_invoice_id, $count, &$error) (through billingItemSave() with the license seat source; one with no seat limit counts the seats in use).
    • Admin: admin/software_template.php takes view (over, spare, renewing, expired, unbilled) and loads js/bulk_actions.js; admin/software_templates.php takes show=required. New windows software_template_required.php (id), software_template_true_up.php (id, software_ids[]) and software_template_bill.php (id, software_ids[], count); software_template_use_several.php takes missing=1. New handlers save_software_template_required (required, tags[]), true_up_software_template (software_ids[], seats[id]; audited as Software Edit on each license) and bill_software_template (software_ids[], recurring[id], count; audited as Recurring Invoice Edit).
    • Agent: agent/software.php shows softwareTemplatesMissingFor() for one client (.itflow-licenses-missing, buttons to software_add.php?client_id=&template_id=); clientOverviewAttention() adds Licenses missing. The Renew window posts to /agent/post.php, so it works from Admin.
    • Export and import: new functions/software_template_transfer.php - softwareTemplatesExport($ids, $archived) (the file's contents: format itflow-license-templates, version 1, templates, each with name, version, description, type, license_type, notes, seats, term, vendor - name and the SOFTWARE_TEMPLATE_FILE_VENDOR_FIELDS, or null - and product by name), softwareTemplatesExportFileName(), softwareTemplatesImportRead($contents, &$error) (checks and tidies a file, and takes the starter pack's array the same way), softwareTemplateImportVendor($vendor, &$made), softwareTemplateImportProduct($name) (current products only), softwareTemplatesImport($templates, $existing, $only_existing) (skip, copy or replace; added, replaced, skipped, vendors, products not found, ids) and softwareTemplatesImportSummary(). Handlers in admin/post/software_template.php: POST export_software_templates (software_template_ids[], archived), GET export_software_template and POST import_software_templates (file, existing), with admin/modals/software_template/software_template_export.php and software_template_import.php. Audit entries are Software Template Export and Import.
    • Starter Content: new license_templates pack, after products so a load of every pack links the products - starterContentLicenseTemplates() in the file format, starterLoadLicenseTemplates() through softwareTemplatesImport(), and starterUpdateLicenseTemplates() with the update_starter_license_templates handler.
    • Report: agent/reports/license_templates.php (client_id and the period; export=csv by template, table=clients by client; the billing columns only with module_sales), license_templates in reportCatalog() under Clients & Documentation.
  • Audit logs:

    • Database 3.3.1: logs keys log_client_id (log_client_id, log_created_at) and log_entity_id (log_entity_id, log_type). Existing entries are moved to one action name each (Unarchive and Restored to Restore, Deleted, Merged, Closed, Reopened, Resolved and Sent to Delete, Merge, Close, Reopen, Resolve and Send, Client Access to Denied, the Stripe and Payment Provider Updates that deleted or saved to Delete or Create, Contract Template Update to Edit) and log_type client to Client. Entries that pointed at the wrong record are moved to the right one - a ticket reply edit or redaction to its ticket, a document edited through the API from its version to the document, Expense Reimburse from the reimbursement to the expense recording it (Reimbursement Undo to none), and the API's client unarchive from a Contact with no record to the Client - only while the log_entity_id key, added last, isn't there, so the step can run again.
    • The code logs the same names: logAudit() calls across agent/post, admin/post, api/v1, cron/nightly_tasks.php and functions/ticket.php now pass Restore, Delete, Merge, Close, Reopen, Resolve and Send; enforceClientAccess() logs Client Denied; editing and redacting a reply pass the ticket's id, api/v1/documents/update.php the document's, expenseReimburse() the expense recording the payment, and api/v1/clients/unarchive.php logs Client Restore on the client. The client portal's saved card logs Stripe Create and Delete, the saved payment method and online payment client handlers Delete, and contract templates Edit.
    • New functions/audit_log.php: auditLogKinds() (by log_type: icon, and for a record its table, id, name - a column or expression - client and url, with {id}, {client} and {client_q}), auditLogTypeIcon(), auditLogActionStyle() (tone, icon), auditLogActionPill(), auditLogViews() (label, icon, title, where), auditLogFiltersSql($src, &$summary) (q, client, user - an id or system - type, action, entity, view, dtf, dtt), auditLogJoinsSql(), auditLogRecords($rows) (one query per kind), auditLogRecordLink($row, $records, $label), auditLogDescription($row) (without the name in front), auditLogWho($row) (name, kind - agent, portal or system - html) and auditLogDevice($user_agent).
    • Admin: admin/audit_logs.php is rebuilt (q, client, user, type, action, entity, view - changes, deletions, signins, security or data - the date filter, sort - log_created_at, user_name, client_name, log_type, log_action, log_ip or log_id - order and page). New admin/modals/audit_log/audit_log.php (id) and audit_log_export.php, and admin/post/audit_log.php with export_audit_logs (the same filters, columns[] from the audit_logs export columns), written 500 rows at a time and audited as Audit Log Export.
    • agent/files.php takes open (a file's id) to open its quick look, as the credential, certificate, domain, expense, recurring expense and trip lists do.
    • Record history: auditLogKinds() gives each kind the module whose read access shows it to an agent (none: admins only). New auditLogRoleSql() (everything for an admin; else those kinds, and no Denied), auditLogHistory($type, $id, $limit = 50) (type, id, client_id, rows, count; role-scoped), auditLogHistoryUrl($history, $client_id) (Admin > Audit Logs for an admin, the client's Audit Log for an agent, '' otherwise), auditLogHistoryHtml($history, $opts) (client_id, empty, flush, compact), auditLogHistoryCard() (compact) and auditLogHistorySection() (collapsed, nothing when empty). Contacts, assets, networks and projects add a history tab; agent/rack.php, agent/document.php and the four admin template pages a card; the file, expense, recurring expense and trip quick looks a section. vendorHistory(), locationHistory(), softwareHistory(), serviceHistory() and productHistory() are removed - their pages use auditLogHistory(), whose count is now the tab's.
    • New agent/audit_log.php (client_id - without it, admins go to Admin > Audit Logs and agents to the clients - q, user, type, action, entity, view - changes, deletions, signins or data - the date filter, sort - log_created_at, user_name, log_type, log_action, log_ip or log_id - order and page), scoped to the client and auditLogRoleSql(), in client_side_nav.php after Calendar. clientOverviewActivity() applies auditLogRoleSql().
    • agent/user/user_activity.php is rebuilt (q, view, the date filter, sort - log_created_at, log_type, log_action, client_name, log_ip or log_id - order and page), on the agent's own entries.
    • The admin quick look's links to itself are absolute, so it opens from agent pages too.
    • Database 3.3.2: auth_logs is dropped (never written; nightly only emptied it); settings.config_login_alert_failures (int, 10; 0 never) - read with signInAlertLimit() rather than with every page's settings, so pages open before the update adds it; new api_key_usage (api_key_usage_key_id, api_key_usage_date, api_key_usage_endpoint, api_key_usage_calls, api_key_usage_last_at, api_key_usage_last_ip; primary key the first three). Nightly deletes api_key_usage older than config_log_retention in place of auth_logs.
    • New functions/sign_in.php: SIGN_IN_WINDOW_MINUTES (10), SIGN_IN_LOCKOUT (15), SIGN_IN_TYPES (Login, Client Login), SIGN_IN_FAILED (Failed, MFA Failed); signInAlertOptions(), signInAlertLimit(), signInFailureReason($email) (user_id, client_id, name, type, reason; an agent's account before a portal one), signInLog() (as the account, without signing in), signInLogFailure(), signInWatch($user_id) (on reaching the limit from the IP address or against the account), signInAccountsTried(), signInAdmins(), signInAlertAdmins($title, $text, $summary, $url) (a Failed Sign-ins notification and an email to each active admin, and Login Alert in the log), signInMailAlert() (in Mail > Previews as admin_sign_in_alert), signInNewIpIds($rows) and signInNewIpBadge().
    • login.php: the IP lockout counts both sign-in types' failures, with the constants; a password that matches no account is logged with signInFailureReason(), as Login or Client Login on that account; an agent's wrong MFA code, a contact's wrong two-step code, the portal turned off and a contact's account that can't sign in with a password go through signInLogFailure(). client/login_microsoft.php logs a failed Entra sign in. Blocked reads "Sign in turned away from IP - 15 failed sign-ins in 10 minutes". userLoginLineHtml() shows why a sign in failed, and userRecentLogins() includes the failures against the account.
    • auditLogWho() marks a failed sign in Their account; Security also takes Login Alert and API Failed; Alert is a danger action. Admin > Audit Logs, the client's Audit Log, Your account > Activity and the quick look badge signInNewIpIds().
    • New admin/sign_ins.php (view - '' People or ips - q, the date filter, sort - People user_name, sign_ins, failures, ips or last_seen, IP addresses log_ip, sign_ins, failures, accounts, first_seen or last_seen - order, page), in the admin nav under Access. Settings > Security takes config_login_alert_failures (one of signInAlertOptions(), else 10).
    • API keys, in functions/api_keys.php: apiKeyUsageEndpoint($uri), apiKeyUsageRecord($id, $endpoint, $ip) (an upsert per call; nothing before the update), apiKeyUsageSummary($ids, $days) (calls, endpoints, last_at, last_ip), apiKeyUsageDaily(), apiKeyUsageEndpoints(), apiKeyUsageIps(), apiKeyTurnedAway() and apiKeyWatch(). api/v1/validate_api_key.php records each call, and logs a call turned away as "Expired API key NAME used - it ended DATE" with log_entity_id the key, or "Unknown API key used". New admin/modals/api/api_key_usage.php (id); deleting a key deletes its use.
    • Database 3.3.3: settings.config_app_log_retention (int, 90; copied from config_log_retention when it is added), config_log_forward_enabled (0), config_log_forward_host (varchar 200, NULL), config_log_forward_port (514), config_log_forward_protocol (udp, tcp or tls), config_log_forward_format (json, cef or plain), config_log_forward_audit (1), config_log_forward_app_level (warning; error, info, debug or '' for none) and config_log_forward_tls_verify (1), each added only when missing; an app_log_created_at key on app_logs. Nightly deletes logs and api_key_usage older than config_log_retention days and app_logs older than config_app_log_retention, each only when it is above 0. Settings > Security no longer takes config_log_retention, and nightly no longer reads it with the other settings.
    • logApp() takes plain text, as before, and now checks the level: lowercased, critical, fatal, alert and emergency to error, warn to warning, anything else info, as the column takes only info, warning, error and debug. The category and details are cut by character (mb_substr()).
    • New functions/app_log.php: appLogLevels() (label, one, icon, tone, title), appLogLevel(), appLogLevelPill(), appLogCategoryIcon() (by name, or the part before a dash), appLogViews(), appLogFiltersSql($src, &$summary) (q, category, view - a level - dtf, dtt; a half-open range on app_log_created_at), appLogFiltersText($summary), appLogDate(), appLogCategories() (name => count), appLogRetention() (audit, app; read apart from the page settings, falling back before the update), appLogRetentionText() and appLogSize($table) (count, oldest).
    • Admin: admin/app_logs.php is rebuilt (q, category, view - error, warning, info or debug - the date filter, sort - app_log_created_at, app_log_type (worst first), app_log_category or app_log_id - order, page and open, an entry's id to open its quick look). New admin/modals/app_log/app_log.php (id), app_log_export.php and app_log_clear.php, sharing app_log_filters.php, and admin/post/app_log.php with export_app_logs (the same filters, columns[] from the new app_logs export columns) and clear_app_logs (scope - shown, with the filters, or all), audited as App Log Export and Delete. auditLogKinds() has App Log, the Security view takes App Log Delete, and Stripe's icon is one the free icons have.
    • Forwarding, in the new functions/log_forward.php: LOG_FORWARD_PROTOCOLS, LOG_FORWARD_FORMATS, LOG_FORWARD_TIMEOUT (2 seconds), LOG_FORWARD_BACKOFF (60), LOG_FORWARD_BATCH (50), LOG_FORWARD_FACILITY (16, local0) and LOG_FORWARD_CATEGORY (Log Forwarding); logForwardSettings($fresh) (read apart from the page settings - off before the update), logForwardTidy(), logForwardOn(), logForwardTarget(), logForwardAppLevels() and logForwardAppLevelSent(). logAudit() and logApp() call logForwardQueue($log, $id, $level, $category) with the new row's id, as do the three places that insert into logs themselves (api/v1/validate_api_key.php, client/login_reset.php and guest/guest_pay_invoice_stripe.php) - nothing during an email preview, or for Log Forwarding's own entries. logForwardFlush() runs on shutdown and at every 50 queued: it reads the entries back by id, so one written in a transaction that was rolled back isn't sent, and sends them in the order they happened over one connection for the request (logForwardSend(), logForwardConnect() - TLS 1.2 or later, the peer and its name checked unless tls_verify is off - and logForwardWrite()). TCP and TLS end each message with a newline (RFC 6587 non-transparent framing); UDP sends one datagram each. On a failure, logForwardFailed() writes an app log error under Log Forwarding, with the reason in words (logForwardReason()), and a file in the temp directory (logForwardBackoffFile()) stops sending for 60 seconds; the entries in between are not sent.
    • Each message is RFC 5424 - <PRI>1 TIMESTAMP HOST itflow - MSGID - MSG, the time with ITFlow's timezone offset, the host from $config_base_url (else the server's name), MSGID audit, app or test. Severity: an audit entry that failed, was turned away, denied or alerted on is warning (4), a deletion notice (5) and the rest info (6); an app log entry error 3, warning 4, info 6 and debug 7. As JSON, an audit entry has log, id, time, type, action, description, severity, who (agent, portal or system), user_id, user, user_email, client_id, client, record_id, ip, user_agent and host, and an app log entry log, id, time, level, category, details and host. As CEF: CEF:0|ITFlow|ITFlow|version|type:action|type action|severity| with rt, externalId, cat, act, msg, suid, suser, src, requestClientApplication, cs1 (client), cn1 (client id), cn2 (record id), cs2 (who) and dvchost, or for an app log entry app:category with cat, msg and cs1 (level). logForwardAuditMessage(), logForwardAppMessage() and logForwardTest($settings, $by) (ok, error; over UDP a port that answers it is closed fails).
    • New admin/settings_logs.php, in the Settings menu after Security, and admin/post/settings_logs.php: edit_log_settings and test_log_forward (saves, then sends a test) take config_log_retention and config_app_log_retention (1 to 36500), audit_forever and app_forever (0), and config_log_forward_enabled, _host (a name or address - one typed with its port, logs.example.com:1514, is taken as the two; anything else is dropped and sending stays off), _port, _protocol, _format, _audit, _app_level and _tls_verify (kept as it was unless TLS). Settings Edit in the audit log says what changed.
    • Report: agent/reports/audit_activity.php (client_id, agent and the period; export=csv by person, table=clients, kinds, or for an admin security), audit_activity in reportCatalog() in a new Activity group, needing module_client; entries scoped by auditLogRoleSql() and clientScopeSql(). Out of hours uses the SLA business days and hours (getSlaSettings()), else 9 to 5 on weekdays.
  • Vendor templates:

    • Database 3.3.4: vendor_templates.vendor_template_created_by and vendor_template_updated_by (0), taken from the first Vendor Template Create and the latest Edit in the audit log with log_entity_id the template, where they are 0, without touching vendor_template_updated_at.
    • New functions/vendor_template.php: vendorTemplateFieldLabels() (key => label, icon), vendorTemplateColumns() (key => [template column, vendor column] pairs - the support line is three), vendorTemplateInfo() (archived or not, with created_by_name and updated_by_name; vendorTemplateRow() in functions/vendor.php stays the current-only one used to make vendors), vendorTemplateUsage($ids) (clients, vendors, ours, last - current vendors, current clients), vendorTemplateFields($post, &$error) (as vendorFormSet() reads a vendor's, and the support email checked), vendorTemplateRowFields(), vendorTemplateSave(), vendorTemplateChanged(), vendorTemplateCopy(), vendorTemplateDelete() (vendors and license templates unlinked, their updated dates kept), vendorTemplateVendors() (with each vendor's differences and raw row), vendorTemplateLicenseTemplates(), vendorTemplateTile() and vendorTemplatePhoneHtml().
    • Admin: admin/vendor_templates.php is rebuilt (q - a support line searched by its digits - sort - vendor_template_name, used_count, last_used or changed_at - order, page and show=archived, archived=1 still taken). New admin/vendor_template.php (vendor_template_id). admin/modals/vendor_template/vendor_template_form.php is shared by vendor_template_add.php (from_vendor) and vendor_template_edit.php (id); both post to /admin/post.php. admin/post/vendor_template.php: add_vendor_template (from_vendor links that vendor; redirects to its page) and edit_vendor_template (follow - replacing the global_update_vendor_* and update_base_vendors fields), GET copy_vendor_template, archive_vendor_template, restore_vendor_template and delete_vendor_template (from_page). Audit entries are Vendor Template Create (with where a copy or one made from a vendor came from), Edit (with what changed), Archive, Restore and Delete, on the template, and Vendor Edit on each vendor that followed it. It no longer includes agent/post/vendor.php, so the agent vendor handlers don't run on admin/post.php.
    • auditLogKinds() links Vendor Template entries to the template's page. agent/vendor.php links its template for an admin, and has Make a template from it for a vendor with none.
    • Database 3.3.5: vendor_templates.vendor_template_required (tinyint, 0 no one, 1 every client, 2 tagged), vendor_template_required_tags (vendor_template_id, tag_id, cascading from both) and a vendor_template_id (vendor_template_id, vendor_client_id) key on vendors.
    • In step, in functions/vendor_template.php: VENDOR_TEMPLATE_SHARED (the keys kept in step - not account_number, code or contact_name), vendorTemplateCompareValue($key, $row, $side) (template or vendor columns), vendorTemplateHasValue(), vendorTemplateDifferences($template, $vendor) (key => label), vendorTemplateLabelsText(), vendorTemplateValueHtml(), vendorTemplateFieldsRow(), vendorTemplateApply(), vendorTemplateFollow($id, $before, $after) and vendorTemplateBringInLine($id, $vendor_ids, $keys) (both vendor id => name, client_id, fields), vendorTemplateOutOfStep($ids) (current vendors, current clients), vendorTemplateHost(), vendorTemplateMatches($id), vendorTemplateMatchFor($vendor) and vendorTemplateLink(). Who should have one: VENDOR_TEMPLATE_REQUIRED_NONE, _EVERY and _TAGGED, vendorTemplateRequiredTags($ids), vendorTemplateSetRequired() (client tags only), vendorTemplateScopeSql(), vendorTemplateScopeText(), vendorTemplateCoverage() (clients, covered, missing) and vendorTemplatesMissingFor($client_id), as the license and document templates have.
    • admin/post/vendor_template.php: save_vendor_template_required (vendor_template_id, required, tags[]), logged as Vendor Template Edit. New admin/modals/vendor_template/vendor_template_required.php (id). admin/vendor_template.php takes view (out or account) and loads js/bulk_actions.js (vendor_ids[]); admin/vendor_templates.php takes show=required and show=out.
    • agent/post/vendor.php: update_vendors_from_template (vendor_template_id, vendor_ids[], fields[] - keys of VENDOR_TEMPLATE_SHARED), link_vendors_to_template (vendor_template_id, vendor_ids[], bring; not to an archived template) and GET unlink_vendor_template, each only for vendors the agent may edit (vendorsForRequest()), logged as Vendor Edit on each vendor with its client. add_vendor_template_to_clients counts only a current vendor from the template as having it. New agent/modals/vendor/vendor_template_update.php (vendor_id, or template_id and vendor_ids[]); vendor_template_clients.php takes client_ids[] and missing=1 to start with clients picked.
    • agent/vendors.php with client_id shows vendorTemplatesMissingFor() (.itflow-vendors-missing), and clientOverviewAttention() adds Vendors missing for agents with module_client.
    • Database 3.3.6: vendors.vendor_status_page and vendor_portal, and vendor_templates.vendor_template_status_page and vendor_template_portal (after the website), and vendor_template_contact_phone_country_code, vendor_template_contact_phone and vendor_template_contact_email (after the contact name), each checked first.
    • vendorTemplateFieldLabels() and vendorTemplateColumns() have status_page, portal, contact_phone (country code and number) and contact_email; VENDOR_TEMPLATE_SHARED adds status_page and portal. vendorTemplateFields() reads them (a bad contact_email is refused), vendorTemplateRowFields() is built from the columns and takes a missing row as blank, and vendorTemplateValueHtml() links the sites. vendorTemplateFormFields() and vendorFormSet() (status_page, portal) carry them to vendors; the website, status page and portal are saved without their scheme or a trailing slash. vendorWebsiteUrl() returns '' for a scheme other than http or https.
    • New functions/vendor_template_transfer.php: VENDOR_TEMPLATE_FILE_FORMAT (itflow-vendor-templates), VENDOR_TEMPLATE_FILE_VERSION (1), VENDOR_TEMPLATE_FILE_FIELDS and VENDOR_TEMPLATE_FILE_MANAGER_FIELDS, vendorTemplatesExport($ids, $archived, $manager), vendorTemplatesExportFileName(), vendorTemplateFileValue(), vendorTemplatesImportRead($contents, &$error) (up to 1000), vendorTemplatesImport($templates, $existing, $only_existing, $only_fields) (added, replaced, skipped, ids, followed), vendorTemplatesImportLogFollowed(), vendorTemplatesImportFollowedCount() and vendorTemplatesImportSummary(). admin/post/vendor_template.php: export_vendor_templates (vendor_template_ids[], archived, manager) and GET export_vendor_template, and import_vendor_templates (file, existing - skip, copy or replace), logged as Vendor Template Export and Import, and Vendor Edit on each vendor that followed. New admin/modals/vendor_template/vendor_template_export.php and vendor_template_import.php.
    • SOFTWARE_TEMPLATE_FILE_VENDOR_FIELDS adds status_page and portal. The starter vendor templates (starterContentVendorTemplates()) are in the file's shape and load through the import; update_starter_vendor_templates (starterUpdateVendorTemplates()) replaces only description, website, status_page, portal and notes, logged as Starter Content Edit.
    • Report: agent/reports/vendor_templates.php (client_id and the period; export=csv by template, table=clients by client), vendor_templates in reportCatalog() under Clients & Documentation, needing module_client. Licenses and assets need module_support with IT documentation on, cases module_support with ticketing on, and the spend module_financial with accounting on and no client picked.
    • API: vendors/create.php and update.php take vendor_status_page and vendor_portal. The vendors export has Status Page and Support Portal columns (off by default), and the client export's vendors have them.
  • 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
    • agent/modals/asset/asset_documents.php, document_bulk_move.php, document_add_file_relation.php and project_link_closed_ticket.php
    • agent/js/file_delete_modal.js, certificate_fetch_ssl.js, credential_show_password_via_id.js and credential_show_otp_via_id.js
  • title attributes were added to about 300 icon-only buttons, and aria-label to the show-password buttons and the portal's mobile menu button.

  • API: api/v1/services/read.php is new, with services mapped to module_support in enforce_api_rbac.php. Ticket create and update use ticketClientOwnedId() and ticketAgentId(), and certificate create uses certificateFormSet().

  • Setup: setup/index.php keeps the handlers and draws the frame; each step's page is setup/steps/<step>.php (welcome, checks, database, user, company, localization, telemetry, restore, restored).

    • setup/setup_checks.php: setupChecks($root) returns required and recommended lists of setupCheck($label, $passed, $value, $fix), with setupChecksFailed(). SETUP_PHP_MINIMUM is 8.2.0; setupRequiredExtensions() adds openssl and fileinfo to the six there were; the git check needs both exec and shell_exec, which the updater uses.
    • setup/setup_database.php: setupConnect() catches mysqli_sql_exception and says why; setupTables(); setupImportSchema() runs db.sql a statement at a time and drops what it made if one fails; setupConfigContents() writes the values with var_export(); setupClose() appends $config_enable_setup = 0 and setupConfigChanged() drops opcache's copy of config.php.
    • config.php is included inside a try/catch, its error in $config_error. Setup has its own session (itflow_setup) and setupFlash($message, $type).
    • New POST create_tables. add_user needs $database_ready, add_company_settings $install_is_live, add_localization_settings $company_exists and add_telemetry $localization_done - each otherwise goes to ?$resume_step.
    • setup/setup_forms.php: setupUserForm(), setupCompanyForm() and setupRegionForm() each return the values and the errors by field, drawn with setupFieldInvalid() and setupFieldError() (data-field-error); setupImageProblem() checks a picture with getimagesize(); Region starts on setupSuggestCurrency() (intl's currency for the country), setupSuggestLocale() (the country and Accept-Language) and setupSuggestTimezone(). The avatar is saved with userSaveAvatar(), and the company's company_phone_country_code is set.
    • js/setup.js: form[data-setup-form] checked before it is sent, data-setup-same-as, data-setup-image with its data-setup-preview, data-setup-browser-timezone, the Region sample, and each setup form sent once. The company step loads intl-tel-input and js/phone_inputs.js.
    • setup/setup_install.php: what setup writes - setupCreateFirstUser(), setupCreateCompany(), setupSaveRegion(), setupTelemetryDetails() and setupShareTelemetry() - shared by the wizard and scripts/setup_cli.php, which now also uses setupChecks($root, false) (no HTTPS, domain name or upload size from the command line), setupHostProblem(), setupConnect(), setupImportSchema(), setupConfigContents() (which takes $repo_branch) and the form checks. It exits 1 with the reason on stderr; --password may be empty, and --locale, --currency and --timezone default to the country's when there is one.
    • Restore: the page sends the upload itself with X-Setup-Upload: 1, and setupGo() answers it in JSON with where to go. A POST to ?restore that PHP emptied (over post_max_size) is answered too. ?restored is shown once, from $_SESSION['setup_restored'], even though setup is closed. Finish makes the backup key with backupEncryptionKey().
    • .itflow-setup* styles in css/itflow_custom.css.
  • Settings: functions/settings.php - settingsPages() (the menu, the overview and each page's header), settingsFields() (every setting: its page, section, label, kind and what else it is searched by - Find a setting and the audit log read it), and the frame: settingsPageStart(), settingsSectionStart()/settingsSectionEnd(), settingsInput(), settingsSelect(), settingsSwitch(), settingsTextarea(), settingsField() and settingsSaveBar(). Handlers read with settingsCurrent(), refuse with settingsRefuse($page, $errors) (the form comes back once, from $_SESSION['settings_kept']), and save with settingsSave(), or settingsWrite() per table and settingsSaid(), which write only what changed and log it. New admin/settings.php and js/settings.js (loaded on admin pages: form[data-settings-form], #setting=<name>, Find a setting, picture previews, the theme preview and the Localization sample). The Settings menu is built from settingsPages(). Form fields renamed to their columns on Company details (company_*), Localization (company_locale, company_currency, config_timezone) and Theme (config_theme, favicon). The Preferences light/dark swatch is .itflow-mode-swatch.

  • Settings: Tickets, Projects, Quotes, Invoices and billing and Defaults use the frame. New in functions/settings.php: settingsNumbering($kind), settingsNumberInUse(), settingsNumberingCheck($kind, $post, $saved) (prefix and next number checked against the highest in use with that prefix - skipped when both are as saved, so a clash already there doesn't block the rest of the page) and settingsNumberingFields() (with [data-settings-next] in js/settings.js, and [data-settings-depends-on=name] to fade fields while a switch is off); settingsEmailProblem(); settingsStartPages(), settingsStartPageProblem() and settingsNetTerms(). settingsSectionStart() takes the section's element id, so #reminders, #billing-time, #billable-expenses and #mileage still work; settingsSelect() takes options_html, append and search => false; settingsSwitch() takes id. settingsSame() treats NULL as 0 or empty and ignores Windows line ends; the log names accounts, calendars and categories. Values kept from a refused save are only used for five minutes. These handlers keep a setting that isn't sent ($_POST + saved), a switch not sent being off. Defaults' fields are renamed to their columns (config_start_page, config_default_*). The start page is escaped where it is printed.

  • Settings: Notifications, Security, Client portal, Single sign-on (admin/identity_providers.php), Mail and Logs use the frame. settingsSearch($query, $limit) scores settings as Find a setting does, leaving out pages for a module that is off; agent/ajax.php?global_search returns them as settings for admins and js/global_search.js shows them under Settings. Single sign-on's fields are renamed to their columns (config_azure_client_id, config_azure_client_secret). A field marked quiet in settingsFields() is logged as changed without its value (the login key). The Logs handler is unchanged.

  • Settings: Mail's Sending, Receiving, OAuth and From addresses tabs use the frame, and every Mail column is in settingsFields(). Its handlers save through settingsSave() and settingsWrite(), logging from -> to; edit_mail_smtp_settings and edit_mail_imap_settings share one check, and both come back to their tab when refused. A password, secret or refresh token posted empty keeps what is saved; an empty OAuth client ID forgets its secret and refresh token; a changed app or refresh token clears config_mail_oauth_access_token and its expiry, so it is fetched again. The access token is no longer a field. oauth_connect_microsoft_mail saves the same way before going to Microsoft, and admin/oauth_microsoft_mail_callback.php now stores only the tokens. New in functions/settings.php: settingsMailProviders(), settingsMailEncryption() and settingsEnterSaves($button) - a hidden Save placed first in a form whose first button would otherwise be another (Mail's OAuth tab, Logs). settingsRefuse() takes the page to go back to; settingsInput(), settingsSelect() and settingsTextarea() take id; settingsWrite() writes a secret field exactly as given, rather than through escapeSql(), which trims and strips tags.

  • Payment Providers: new functions/payment_provider.php - paymentProviderKinds() (the providers ITFlow takes payments through and what their keys look like), paymentProviderMode(), paymentProviderRow(), paymentProviderTies() and paymentProviderTiesText(), paymentProviderLimitText(), paymentProviderCheck($post, $before), paymentProviderKeysChanged(), paymentProviderAsk($name, $secret_key) (Stripe's account, from the secret key), paymentProviderChangeLines() and paymentProviderWrite(), which saves the keys exactly as given. check_payment_provider answers in JSON - ok, errors by field and the Stripe account - and the window asks it before saving; a key it has had taken isn't asked about again on the save, for ten minutes. New handlers turn_payment_provider_off and turn_payment_provider_on (GET with the CSRF token). payment_provider_add.php opens payment_provider_edit.php with no id. Field record_fees replaces enable_expense; active and description are new. admin/post/payment_provider_model.php is gone.

  • Payment Providers: the nightly Stripe fee reconciliation is paymentProviderRecordFees($provider) now, which Record fees now (record_payment_provider_fees, GET with the CSRF token) runs too - recorded, waiting and failed counts, and the fees by currency. Also paymentProviderUnrecordedSql(), paymentProviderActivity($provider) (card payments and fees in 30 days, and the payments still to record), paymentProviderMoneyText(), paymentProviderProblems() (the Settings overview's card payment items) and paymentProviderSummaryHtml() (Paid online). check_payment_provider_connection (GET with the CSRF token) runs paymentProviderAsk() on the saved key. The overview's items take .itflow-settings-attention-text, so the button stays beside the text where there is room.

  • Saved Cards (admin/saved_payment_methods.php): paymentCardExpirySql() (a saved card's expiry as yyyymm, from its description), paymentCardAutopay($saved_ids) and paymentCardRemove($saved_payment_id, $by), which detaches it at Stripe (a card Stripe no longer has counts as done), writes history for each recurring invoice that loses its autopay, and deletes it - the autopay cascades. Filters provider, client and show (autopay, expired). The audit log entry is type Payment Provider, with the client.

  • Removed, as nothing reached them or they couldn't run: admin/settings_custom_fields.php (it required a modal that no longer existed) with admin/post/custom_field.php, admin/post/custom_field_model.php and admin/modals/custom_field/; admin/post/settings_ai.php and admin/post/settings_online_payment.php, which wrote the config_ai_* and config_stripe_* columns dropped in 2.3.2; admin/post/settings_online_payment_clients.php, and agent/client_autopay.php, which read the dropped Stripe columns and was linked from nowhere. The custom_fields and custom_values tables stay as they are, and the client export still includes asset custom field values.

  • Mail Queue: new functions/mail_queue.php - MAIL_QUEUE_TRIES (4), MAIL_QUEUE_RETRY_MINUTES (30) and MAIL_QUEUE_KEEP_DAYS (90), which cron/mail_queue.php's retry pass and the nightly clean-up now use too; mailQueueTabs(), mailQueueState($row), mailQueueCan($row, $action), mailQueueAct($action, $ids) and mailQueueHealth(). email_status 4 is Cancelled - never selected by the worker. Retry now sets status 0, no attempts and no failure time. Handlers: retry_mail, cancel_mail, delete_mail (GET with the CSRF token) and bulk_retry_emails, bulk_cancel_emails, bulk_delete_emails; send_failed_mail still works. The worker sets email_failed_at on the bad sender, bad recipient and no-MX paths.

  • Mail Queue: database 3.3.7 adds email_queue.email_error varchar(500) - why it last failed, or "Cancelled by ..."; cleared when sent or retried. The worker writes it on every failure path, through mailQueueErrorSql(), which leaves it out until the update has run, so an install between updating its files and its database still sends; mailQueueErrorColumn() does the same for reads. The retry pass's bad-recipient path now sets email_failed_at, so its backoff counts from that try. An alert (appNotify) when the retry pass gives up. The email's window shows the queued HTML in an iframe srcdoc with sandbox="allow-popups allow-popups-to-escape-sandbox" (no scripts, no same origin) and a script-src 'none' policy, instead of HTMLPurifier in the page.

  • Mail Queue: mailQueueSummary() - waiting, retrying, failed in the last day and week, and the last sent - read once a request, for the Settings overview (attention items and the Mail tile), Mail's Tests tab and the admin menu's count (admin/includes/side_nav.php). globalSearchPages() has Mail Queue for admins.

  • AI: database 3.3.8 adds ai_providers.ai_provider_kind varchar(50) (guessed from the address for those already there, else custom) and ai_provider_redact tinyint(1) default 1, widens ai_provider_api_url and ai_provider_api_key to varchar(500), adds the ai_usage table (feature, use case, provider and model ids and names, user, client, tokens in and out, ms, redacted, ok, error, created at) and the module_ai module, given level 1 on every non-admin role. setup/seed_data.php and db.sql match.

  • AI: functions/ai.php - aiProviderKinds() (address, key, how it is sent - bearer or Azure's api-key - how its models are listed, local or not, where keys are made, an example model), aiProviderKind(), aiUseCases(), aiFeatures(), aiCan() (the module_ai permission), aiReady($use_case), aiClientConfig(). getAiModel() joins the provider (an INNER JOIN now), returns its id, kind and redact with the model's id, and is cached per request. callAiApi($model, $messages, $context) takes feature and client_id, sends the key as the kind takes it, takes secrets out with aiRedact() and puts them back with aiRestore(), puts the model's prompt after the feature's system message as the company's instructions, and records every request with aiUsageRecord() (until 3.3.8 has run, aiUsageTableExists() says so and nothing is recorded or read from the new columns). aiCleanHtml() takes code fences off and runs documentHtmlPurify(). Also aiUsageTotals(), aiTokensText(), aiTemperatureText(), aiModelsUrl(), aiProviderTest($kind, $url, $key) (the models it lists; Anthropic's own /v1/models with x-api-key), aiProviderUrl() (a base address gets /chat/completions), aiProviderRow(), aiModelRow(), aiProviderCheck(), aiProviderConnectionChanged(), aiProviderWrite() and aiModelWrite() (raw values, escaped with mysqli_real_escape_string, not escapeSql()), aiProviderChangeLines(), aiModelCheck(), aiModelChangeLines(), aiUseCaseMap($without), aiUseCasesStopping(), aiProviderIcon(), aiUseCaseStripHtml() and aiProblems().

  • AI: handlers - check_ai_provider (JSON: ok, errors by field, soft when only not answering, models, listed, url as it will be saved; test to ask even when unchanged) before add_ai_provider / edit_ai_provider, which take kind, name, url, api_key, clear_key, redact, save_anyway and, adding the first, first_model; ai_provider_models (JSON, cached ten minutes); test_ai_provider (GET with the CSRF token); check_ai_model (JSON, try to send a one-line request as feature test) before add_ai_model / edit_ai_model. admin/modals/ai/ai_provider_add.php and ai_model_add.php are gone - the edit windows add with no id, and ai_model_edit.php?provider= picks the provider. New page admin/ai_usage.php.

  • AI: agent/ajax.php - ai_reword, ai_ticket_summary and ai_create_document_template need module_ai (a JSON 403 otherwise), pass their feature and client to callAiApi(), and answer purified HTML. ai_reword takes client_id in its JSON and has its own instructions, by use case. The nightly run deletes ai_usage rows older than the audit log's retention.

  • AI on tickets: database 3.3.9 adds settings.config_ticket_ai_triage tinyint(1) (0 off, 1 suggest, 2 fill in) and the ai_ticket_triage table (one row a ticket - suggested category id, priority, summary, reason, state suggested, applied, dismissed or failed, who applied or dismissed it; cascades with its ticket). The setting isn't in load_global_settings.php: aiTriageMode() reads it, and aiTriageReady() says whether 3.3.9 has run (from the database version, or the column in cron), so the ticket page and Settings > Tickets work before the update.

  • AI on tickets: new functions/ai_ticket.php - aiHtmlToText(), aiTicketContext($ticket_id, $internal) (the ticket as the prompts read it, internal notes only when asked, long conversations cut to the first message and the latest replies), aiTicketSummary(), aiTicketDraft($ticket_id, $notes, $type), aiTicketDocument(), aiJsonFromText(), aiTriageModes(), AI_TRIAGE_SOURCES, aiTriageRow(), aiTicketTriage() (checks the answer against the categories and TICKET_PRIORITIES), aiTriageChanges(), aiTriageApply($ticket_id, $who, $only, $state, $by) (history, audit, applyTicketSla(), ticket_update custom action) and aiTriageNewTickets($limit). callAiApi() takes 'secrets' => 'remove' to leave them out as [removed] rather than put them back. New features ticket_draft, ticket_triage and ticket_document; aiClientConfig() has draft.

  • AI on tickets: agent/ajax.php - ai_ticket_summary answers JSON (html or error) now, rather than HTML; new ai_ticket_draft (JSON body ticket_id, notes, type - internal, public or email) and ai_ticket_document (title, description, html, removed). New handlers in agent/post/ticket_ai.php: add_ticket_ai_note, triage_ticket_ai (GET with the CSRF token), apply_ticket_ai_triage (apply[] of category, priority) and dismiss_ticket_ai_triage. add_document takes from_ticket_id, logs it in the ticket's history and opens the document. New window agent/modals/ticket/ticket_document.php (the document form, document_form.php). New cron job ai_triage (cron/ai_triage.php, every minute). The reply textarea on the ticket page carries data-ai-ticket-id, which adds aidraft (itflowAiDraft() in js/app.js) to the editor.

  • AI for clients and documents: ai_reword takes tone (aiRewordTones() - reword, shorter, formal, friendly, plain, structure, proofread, translate) and language (one of aiLanguages()); the instructions are aiRewordInstructions($use_case, $tone, $language), and a translation is recorded as feature translate. aiClientConfig() has tones and languages, and reword is a split button (itflowAiReword() in js/app.js), Translate asking for the language in a TinyMCE dialog and remembering it in localStorage (itflowAiLanguage).

  • AI for clients and documents: new functions/ai_client.php - aiBriefingPeriods(), aiClientBriefingContext($client_id, $days) (from functions/client_overview.php, gated by clientOverviewAccess()) and aiClientBriefing(); new functions/ai_document.php - aiDocumentSplit() (the title and summary off the top, which aiTicketDocument() uses now too), aiClientProfile(), aiDocumentDraft($client_id, $prompt, $use_client) and aiDocumentReview($document_id) (verdict, summary, findings as [severity, text], secrets counted by aiRedact(); always redacted). New endpoints ai_client_briefing (client_id, days), ai_document_draft (client_id, prompt, use_client) and ai_document_review (document_id), all JSON; new features client_briefing, document_draft and document_review. New windows agent/modals/client/client_briefing.php (saves through add_document) and agent/modals/document/document_review.php (Use as its summary through rename_document).

  • AI for quotes and invoices: a new use case, Billing (aiUseCases(); ai_model_use_case is a varchar, so no database change). New functions/ai_billing.php, loaded by functions.php - aiBillingCatalog($limit) and aiBillingCatalogText(), aiTicketWorkText($ticket_id) (each reply and note with the time it logged), aiBillingPoints(), aiTicketInvoiceDescription($ticket_id) (billingTimeDescription() then the points; always redacted, secrets removed), aiBillingTicket($type, $doc_id) (a quote's ticket_quote_id ticket), aiBillingFrequency(), aiBillingLine($type, $line, $catalog, $rate) (kind catalog, labor or other; a catalog line takes the product's name, price and frequency, a name matching a product is that product, labor is rounded up to the quarter hour at billingTimeRate(), other has price null) and aiBillingDraft($type, $row, $prompt, $use_ticket, $use_client) (scope, lines, note, checks, rate, ticket; the ticket and aiClientProfile() only with module_support). New features billing_draft and invoice_description. New endpoints ai_ticket_invoice_description (ticket_id; Support and Sales 2) and ai_billing_draft (type quote or invoice, doc_id, prompt, use_ticket, use_client; Sales 2, lines that can change), both JSON. New handler billing_ai_lines in agent/post/billing.php (type, doc_id, lines[n][include, kind, product_id, name, description, qty, frequency, price], scope with use_scope, note with use_note - added after the note already there) - tax by kind through taxDefaultFor(), each line through billingItemSave(), the ones that can't go on listed. New window agent/modals/billing/billing_ai_writer.php; includes/billing_paper.php takes ai for its button, which agent/quote.php and agent/invoice.php set. callAiApi() with 'secrets' => 'remove' also takes out any [secret-n] the model made up.

  • js/app.js: the four tinymce.init() calls go through itflowTinyMce, which does nothing where TinyMCE isn't loaded. itflowAiOn(ticket) and itflowAiReword(editor, ticket) add Reword, first in the toolbar, only when window.itflowAi (printed by includes/footer.php from aiClientConfig()) says so, posting to its endpoint, /agent/ajax.php. A textarea's data-ai-client-id goes with the request.

  • Security sweep: accountLedgerVisible($row) (functions/accounts.php) is a ledger line as the agent may see it - for a client they can't open, hidden set and the client, what, description and reference taken off - used by agent/transactions.php, agent/reconcile.php and the export_transactions handler; accountLedgerFiltersSql() puts clientScopeSql() on a search and matches nothing for a client filter the agent can't open. agent/reports/tax_summary.php folds the exempt clients the agent can't open into one row (key -1).

  • Security sweep: clientScopeSql() on the client lists of the asset, certificate, contact, credential, domain, location, software and ticket export windows and asset_bulk_transfer_client.php, and on the expense and recurring expense lists of agent/vendor.php. agent/user/user_activity.php doesn't name, link or look up a record of a client the agent can't open. client_duplicate_check (agent/ajax.php) names only a client the agent can open. product_stock.php lists expenses only with module_financial 1 and clientScopeSql('expense_client_id'), and product_stock_save links only an expense the agent could pick.

  • Security sweep: admin/modals/cron/cron_edit.php loads admin/includes/modal_header.php (the admin check) like the other admin windows. change_records_per_page (post/misc.php) validates the CSRF token, which includes/filter_footer.php now posts, and takes 5, 10, 20, 50, 100 or 500; includes/load_user_session.php treats a stored 0 as 1.

  • Security sweep: guest/guest_pay_invoice_stripe.php (the payment form), guest/guest_pay_setup_stripe_customer.php and stripe_create_pi in guest/guest_ajax.php need payment_provider_active; the return from Stripe doesn't, and skips a payment intent already booked as Stripe - pi_.... uploads/canned_responses/.htaccess denies the folder - the files are served by agent/canned_response_file.php - and .gitignore keeps it. On nginx, deny /uploads/canned_responses/ the way you deny /uploads/expenses/.

  • Security sweep: apiDecryptPasswordOpens($api_key_decrypt_hash, $api_key_decrypt_password) (functions/security.php); api/v1/credentials/credential_model.php refuses a wrong one before encrypting, checks the contact, asset, vendor and license with assetClientOwnedId() (the license against software_client_id), and create.php and update.php add vendor_credentials and software_credentials. api/v1/contacts/read.php puts the phone or mobile match in brackets before apiClientScopeSql(). api/v1/assets/update.php reads the primary interface with the asset. api/v1/tickets/resolve.php counts the rows straight after its UPDATE. api/v1/clients/client_model.php starts $client_row as null.

  • Security sweep: includes/footer.php falls back to window.itflowAi = false when aiClientConfig() throws (admin/debug.php closes the database before its footer). reportCsv() escapes only the report's name for the audit log, as $session_name is SQL-safe already. bulk_edit_asset_interface_network refuses a network id that no longer exists.

  • Guest ticket page: guest/guest_view_ticket.php is built on the portal's ticket helpers in client/functions.php (portalTicketState(), portalAvatarHtml(), portalDateTime(), portalTicketEventText()) and sends Referrer-Policy: no-referrer (the key is in the address), X-Frame-Options: DENY and frame-ancestors 'none'. ticketByGuestKey($ticket_id, $url_key) (functions/ticket.php) returns ticketRow() only when the key matches exactly (hash_equals()); the page, the handlers and the new guest/guest_ticket_attachment.php use it, and ticketGuestPath($row) is the page's address from guest/. The attachment endpoint follows client/ticket_attachment.php's rules (never an internal or archived reply's file, inline only for getInlineViewableMimeTypes()), never serves Original-parsed-email.eml, and logs each download.

  • Guest ticket page: in guest/guest_post.php, close_ticket, reopen_ticket and add_ticket_feedback are POST only, with ticket_id and url_key; the GET forms are gone. Each acts once, on the state it expects, so a second press logs nothing. Reopen calls resetTicketResolutionSla() and syncTicketSlaClock() and appNotify(); close calls syncTicketSlaClock(). add_ticket_feedback takes Good or Bad only. ticket_calendar (GET) sends the booking's calendar file. ticketScheduleRow($ticket_id) and ticketScheduleIcs($row, $schedule) are what ticketSetSchedule() used to build inline, so both give the same UID and event. The CSS class .itflow-portal-todo-wrap lets a portal to-do row's title wrap instead of being cut off.

  • Ticket emails: emailHtml() takes a history block (a heading and items of who, organisation, action, when and plain text, or a gap count) drawn after the button. emailPlainText($html, $limit) (functions/email.php) is HTML as plain text, keeping line breaks, dropping pictures and the reply line, and cut at a word. ticketEmailHistory($row, $opts) (functions/ticket.php) builds the history: public and client replies newest first, never internal, archived or the portal's "Ticket ... by X." lines, then the request. skip leaves out the reply the email shows and anything newer, request and replies choose the parts, and limit (4) caps the replies. ticketEmailExcerpt() takes off the parser's "Email from" line. ticketEmailContent() and ticketAgentEmailContent() add it per kind and set the preview line through ticketEmailPreview(). ticketMailColleaguesNew() shows the details unless ticket_source is Email. cron/ticket_email_parser.php also cuts a reply at a plain-text reply line, quoted or not.

  • Custom links: new functions/custom_link.php holds the link helpers. customLinkLocations() lists the menus (1 agent menu, 2 top bar, 3 client portal, 4 admin menu, 5 reports menu). customLinkUriProblem() and customLinkHref() check addresses: escapeUrl()'s schemes plus mailto and tel, relative paths allowed, anything else drawn as #. customLinksFor($location) is a menu's current links, read once a page, with href and icon added; customLinkNavItemHtml() draws a side menu line. Also customLinkFromInput(), customLinkProblem(), customLinkCreate(), customLinkUpdate() (returns the from-to changes for the audit log), customLinkCopy(), customLinkReorder(), customLinkMenuIds() and customLinkIconChoices(). admin/includes/side_nav.php, agent/includes/side_nav.php, agent/reports/includes/reports_side_nav.php, includes/top_nav.php and portalNav() read customLinksFor(); the portal's nav items take a title. admin/post/custom_link.php adds copy_custom_link, archive_custom_link, restore_custom_link, move_custom_link and reorder_custom_links (POST, JSON, like reorder_categories). The add and edit windows share admin/modals/custom_link/custom_link_form.php with js/custom_link_form.js. custom_link_description is now used; custom_link_order is set by position. No database change.

  • Debug: new functions/system_health.php. healthChecks() returns the sections in order (key, title, icon, items), each item from healthItem($label, $level, $value, $fix, $url, $action, $private) - level ok, info, warning or danger, fix as HTML, private leaving the value out of the report. healthSummary($sections) counts them by level and lists the warnings and problems, problems first. healthDatabaseTables($exact) reads sizes and InnoDB's row estimates from information_schema.TABLES in one query, or counts each table with $exact (debug.php?exact=1); healthStructureDifferences() compares information_schema.COLUMNS with db.sql, ignoring integer display widths (healthColumnType()); healthUploads($seconds) counts uploads by top-level folder and stops at the time given, marked partial. healthReport() is the plain-text report. Every read goes through healthQuery() / healthRow(), which answer null instead of throwing, so a column the database doesn't have yet costs a line, not the page. The certificate is read from $config_base_url's host (healthBaseHost(), healthCertificate()), never HTTP_HOST; HTTPS counts X-Forwarded-Proto; the required extensions come from setupRequiredExtensions() in setup/setup_checks.php so setup and Debug agree; mail uses mailQueueHealth() and mailQueueSummary(); updates use shellCommandsAvailable() and commandPath('git'). admin/debug.php no longer closes the database before the footer, uses reportStats() for the figures along the top, and the copy button is the shared .clipboardjs one, targeting the report's textarea. New CSS: .itflow-debug-section-icon, .itflow-debug-item, .itflow-debug-fix, .itflow-debug-attention, .itflow-debug-bar, .itflow-debug-stats. No database change.

  • Ticket statuses: database 3.4.0 adds ticket_statuses.ticket_status_description varchar(255) and ticket_status_client_reply tinyint(1) (1, the default, sends the ticket back to Open when the client replies; 0 leaves it), gives the five built-in statuses their descriptions where they have none, and numbers ticket_status_order 1, 2, 3... in the order they already showed in. New functions/ticket_status.php: ticketStatusBuiltIns() (ids 1-5 with icon, meaning and what sets them), ticketStatusIsBuiltIn(), ticketStatusRow(), ticketStatusesAll() (once a page, in order), ticketStatusAbout(), ticketStatusColor(), ticketStatusBadgeHtml() (text colour from tagTextColor(), the description as its title), ticketStatusOptions($keep), ticketStatusTicketCounts(), ticketStatusMoveTargets(), ticketStatusFromInput() (a built-in keeps its name, stays on and takes getTicketStatusSlaLock() and client reply 1 whatever is posted), ticketStatusProblem() (a name, not taken), ticketStatusCreate(), ticketStatusUpdate() (returns the from-to changes, syncs its tickets' SLA clocks when pausing changes), ticketStatusMoveTickets(), ticketStatusSetActive(), ticketStatusDelete(), ticketStatusReorder() and ticketStatusAfterClientReply($status_id), which cron/ticket_email_parser.php and client/post.php now use instead of setting status 2. ticketStatusColumnsReady() keeps all of it working before 3.4.0 has run. ticketStatusHtml() draws through ticketStatusBadgeHtml(), and the dashboard, client overview, ticket page, Ticket Aging and Client Review reports use it instead of their own badges. admin/post/ticket_status.php adds move_ticket_status, reorder_ticket_statuses (POST, JSON), switch_on_ticket_status, switch_off_ticket_status (POST, move_to) and delete_ticket_status as POST with move_to - the GET delete is gone. The add and edit windows share admin/modals/ticket_status/ticket_status_form.php with js/ticket_status_form.js; ticket_status_remove.php is the switch-off and delete window. Quick picker options take a title (js/quick_pick.js), which the status picker sets to the description. New CSS: .itflow-ticket-status.

  • Payment methods: database 3.4.1 adds payment_methods.payment_method_use tinyint(1) (0 money in and out - every method so far, 1 money in, 2 money out) and payment_method_archived_at. New functions/payment_method.php: PAYMENT_METHOD_USES, PAYMENT_METHOD_ONLINE ('Stripe', what online card payments are recorded as - its name can't be changed), paymentMethodUsageColumns() (every column a method is kept in by name: payment_method, revenue_payment_method, expense_payment_method, recurring_expense_payment_method, reimbursement_payment_method, transfer_method, recurring_payment_method), paymentMethodRow(), paymentMethodsAll(), paymentMethodUsage() (one UNION ALL, counts and the last date per name), paymentMethodUsageText(), paymentMethodsNotSetUp(), paymentMethodDefaults(), paymentMethodDefaultFor(), paymentMethodSetDefault(), paymentMethodFromInput(), paymentMethodProblem() (a name, not taken), paymentMethodCreate(), paymentMethodRename() (every column above and both defaults, case-insensitively), paymentMethodUpdate() (returns the from-to changes and how many records were renamed; drops a default the new use rules out), paymentMethodSetArchived(), paymentMethodDelete() (refuses a used method without another to merge into) and paymentMethodAddNames(). paymentMethods($way = '', $with_archived = false) moves here from functions/billing.php - 'in' for payments and income, 'out' for expenses, '' for transfers; archived ones only for filters - and every picker and check passes its way: the payment, income, expense, recurring expense, reimbursement, mileage claim, bulk edit and import windows, incomeImportMethod(), the expense and income save functions, and Settings > Defaults. paymentMethodColumnsReady() keeps it working before 3.4.1 has run. The payment window keeps a payment's own method among the choices. admin/post/payment_method.php adds default_payment_method (GET, way in or out), archive_payment_method, restore_payment_method, delete_payment_method as POST with move_to (the GET delete is gone) and add_payment_method_names. The add and edit windows share admin/modals/payment_method/payment_method_form.php; payment_method_delete.php is the delete and merge window.

  • Contracts: database 3.4.2 adds to contract_templates and contracts {prefix}_term_months int (12), _auto_renew tinyint(1) (1), _notice_days int (30) and _sla_low_id, _sla_medium_id, _sla_high_id, _sla_urgent_id int (0, the default), and to contracts contract_template_id, contract_contact_id, contract_owner_id, contract_created_by int, contract_notes text, contract_renewed_at, contract_reminded_at, contract_cancelled_at datetime and contract_cancel_reason varchar(255). It turns the _sla_{low,medium,high}_{response,resolution}_time hours into SLA ids - an unarchived slas row with the same minutes, or a new one - and _renewal_frequency into the term and auto-renew, then drops those seven columns from both tables. It makes _net_terms int (the digits of the old text, or NULL), sets _type to a key of CONTRACT_TYPES and contract_status to Draft, Active, Ended or Cancelled, and re-adds contracts_ibfk_1 with ON DELETE CASCADE. New functions/contract.php: CONTRACT_TYPES (key => label, icon, about), CONTRACT_TERMS, CONTRACT_REMINDER_DAYS (30), CONTRACT_SLA_PRIORITIES, contractsReady() (keeps every page and the nightly job working before 3.4.2 has run), contractRow() (with the client, contact, owner and template names), contractTemplateRow(), contractTemplates(), contractTypeLabel(), contractTermLabel(), contractTermEnd($start, $months) (a term less a day - termEnd() in js/contract_form.js matches it), contractNextEnd(), contractNoticeDate(), contractReminderDate(), contractState() and contractStateHtml(), contractViews(), contractSlaChoices($keep), contractSlaTargets(), contractFromInput($in, 'contract'|'template') (returns the fields and an error; checks the client in scope, the contact at that client, an active agent as owner and listed SLAs, and purifies the terms), contractTermsSetSql($f, $prefix) (shared by contracts and templates), contractSetSql(), contractCreate(), contractUpdate() (returns the changes; a new end date clears contract_reminded_at), contractActivate(), contractRenew(), contractCancel(), contractsNightly($today) (returns renewed, ended and reminded counts; cron/nightly_tasks.php runs it and logs them), contractRemind(), contractClientCount() and contractClientActive(). New agent/contracts.php, agent/contract.php, agent/post/contract.php (add_contract, edit_contract, activate_contract, renew_contract, cancel_contract POST with Sales Modify, and delete_contract GET with Sales Delete), and agent/modals/contract/ (the add, copy and edit windows share contract_form.php with js/contract_form.js, plus contract_activate.php, contract_renew.php and contract_cancel.php). admin/post/contract_template.php is rewritten on contractFromInput() and adds copy_contract_template; deleting a template sets its contracts' contract_template_id to 0. The template add and edit windows share admin/modals/contract_template/contract_template_form.php. auditLogKinds() has a Contract kind, the client overview has Contracts ending and Contracts renewing attention items and a contract in the Financial row, and agent/post/client.php deletes a client's contracts with it.

  • Contract signing: database 3.4.3 adds contract_signatures, a row for each time a contract is sent to sign - contract_signature_contract_id (ON DELETE CASCADE), _url_key (48 hex characters, '' for one recorded), _document (mediumtext, the document as sent) and _document_hash (SHA-256), _contact_id, _sent_to_name, _sent_to_email, _sent_at, _sent_by, _viewed_at, the client's _client_name, _client_title, _client_image (a PNG data URI, NULL when typed), _client_signed_at, _client_via (link, portal or recorded), _client_contact_id, _client_ip, _client_user_agent, the countersignature's _agent_user_id, _agent_name, _agent_title, _agent_image, _agent_signed_at, _agent_ip, and _withdrawn_at with _withdrawn_reason. A signature found in contracts.contract_contact_signature or contract_agent_signature is moved to a recorded row first; then those and contract_client_name, _client_address, _client_email, _client_phone, contract_contact_name, _contact_signature_date, contract_agent_name and _agent_signature_date are dropped. New functions/contract_signature.php: CONTRACT_PLACEHOLDERS, contractSignaturesReady(), contractPlaceholderValues($row, $as_of), contractFillTerms() (through ticketPlaceholderFill(), with [SLA] on its own line as contractSlaTableHtml()), contractUnfilled(), contractSlaRows() (the contract's SLA or the client's, by getTicketSlaId()), contractDocumentHtml($row, $as_of) (classes only, no styles - the portal's CSP allows none), contractDocumentHash(), contractSignatureRow(), contractSignatureByKey() (hash_equals), contractSignatureRounds(), contractSignatureSigned(), contractSignatureState() (sent, viewed, countersign, signed, withdrawn, incomplete), contractSignaturePending(), contractSignaturePendingFor(), contractSignatureUrl(), contractSignatureImageFromInput() (a PNG data URI of at most 400 KB, redrawn with GD and cut to the ink - blank pads and other formats refused), contractSignatureFromInput(), contractSignatureSend() (withdraws a request still out), contractSignatureRequestEmail(), contractEmailFrom() (the quotes mailbox, else ITFlow's), contractSignatureWithdraw(), contractSignatureViewed(), contractSignatureClientSign() and contractSignatureAgentSign() (each one UPDATE guarded by the state it needs, so a second press or a race does nothing), contractSignatureSignedEmail(), contractSignatureBlockHtml(), contractSignatureTrail(), contractSignatureCheckChanged() (the edit handler withdraws a request whose document, made as of the day it was sent, now hashes differently), contractSignatureOutdated() and contractPdf(), with functions/contract_pdf.php (ContractPdf, its Error() throwing so terms TCPDF can't draw fall back to text). agent/post/contract.php adds send_contract_signature and countersign_contract (POST), resend_contract_signature and withdraw_contract_signature (GET with the token), all with Sales Modify, and export_contract_pdf (Sales Read, &round= for a round's copy). New agent/contract_document.php, agent/modals/contract/contract_send.php and contract_countersign.php, guest/guest_view_contract.php with sign_contract (POST, the id and key, and the document hash the page showed) and export_contract_pdf in guest/guest_post.php, and client/contracts.php and client/contract.php (its ?signature= serves a signature's picture, as the portal's CSP allows no data: images) with portal_sign_contract in client/post.php, ?contract= in client/pdf.php, portalContractsGate(), portalContractScopeSql(), portalContractWaitingSql(), portalContractRound() and portalContractState() in client/functions.php, a Contracts entry in portalNav() and the home page's list. Shared: includes/contract_paper.php (the document on a page), includes/contract_signature_pad.php with js/signature_pad.js (pointer events, the strokes redrawn on resize, the PNG put in the form as it is sent), and includes/contract_placeholders.php with js/contract_placeholders.js. contractViews() adds Signing; contractRow() adds client_currency_code and contact_title. Audit log actions Sign and Withdraw. emailPreviewCatalog() adds the Contracts group (emailPreviewContractCatalog(), emailPreviewContract()). New CSS: .itflow-contract-doc-*, .itflow-contract-signature*, .itflow-signature-*, .itflow-contract-trail.

  • Contract prepaid hours: database 3.4.4 adds to contract_templates and contracts {prefix}_monthly_hours and {prefix}_hours_alert decimal(8,2) (0.00), and contracts.contract_hours_alerted_at datetime, plus two tables. contract_blocks: contract_block_contract_id (ON DELETE CASCADE), _hours, _price (NULL for none), _bought, _expires (NULL for never), _month (char(7), for a month's included hours - UNIQUE with the contract, so a month is made once), _invoice_id, _note, _created_by, _created_at. contract_block_uses: contract_block_use_block_id (ON DELETE CASCADE), _ticket_id, _hours, _full (on the use that covered the rest of a ticket), _created_by, _created_at; a use of 0 hours keeps a ticket that was put back out of the draw. New functions/contract_hours.php: contractHoursReady(), contractHoursText(), contractHoursWords(), contractBlocks(), contractHoursSummary(), contractHoursLeftFor(), contractHasHours(), contractClientHours(), contractHoursEnsureMonthly(), contractHoursSync() (after a save, Make active, Renew or a countersignature that makes a draft active), contractHoursTicketUses(), contractHoursUses(), contractHoursDraw() (one at a time per client, under GET_LOCK), contractHoursDoneOn(), contractHoursRecheck(), contractHoursPutBack(), contractHoursAlertReset(), contractHoursAlertCheck(), contractHoursAlertSend(), contractHoursFromInput(), contractHoursAdd() (the invoice through billingInvoiceCreate() and billingItemSave()), contractHoursBlockDelete(), contractBlockRow() and contractHoursNightly(). billingUnbilledSql() leaves out a ticket with a full use; billingUnbilledTickets() adds worked (the hours the time logged makes) and prepaid, and hours is now what is left after them; billingUnbilledByClient() adds prepaid. contractFromInput() takes monthly_hours and hours_alert, contractTermsSetSql() writes them once 3.4.4 has run, and contractUpdate() logs them and clears contract_hours_alerted_at when the level changes. contractsNightly() returns drawn too, logged by cron/nightly_tasks.php. ticketDeleteForGood() deletes the ticket's uses. CONTRACT_PLACEHOLDERS adds Monthly Hours, and contractDocumentHtml() adds an Included hours row only when there are some, so documents without them hash as before. agent/post/contract.php adds add_contract_hours and draw_contract_hours (POST, with a contract_id, a client_id or a ticket_id) and delete_contract_block and put_back_contract_hours (GET with the token), all with Sales Modify. New agent/modals/contract/contract_hours_add.php. emailPreviewContractCatalog() adds contract_hours_low. New CSS: .itflow-contract-hours-left and .itflow-hours-bar (a <progress>, as the portal's CSP allows no inline styles).

  • Contract billing and SLAs: database 3.4.5 adds contracts.contract_recurring_invoice_id int (0, keyed) and contract_recurring_stopped_at datetime, set when the contract pauses its recurring invoice so it does so once, and sets a rate of 0.00 on contracts and templates to NULL (contractFromInput() saves 0 as NULL too). New functions/contract_billing.php: contractBillingReady(), contractTermsContracts() (a client's contracts that can be in force, kept for the request), contractInForceOn(), contractTermsOn() (the one started last that sets a rate, net terms or a priority's SLA), contractsInForce(), contractSlaFor(), contractAfterHours() (from getSlaSettings() and getBusinessHolidays()), contractTicketRate() (source after_hours, contract, default_after_hours or default, and a note), contractClientRate(), contractNetTerms(), contractNetTermsToday(), contractRecurringRow(), contractForRecurring(), contractRecurringChoices(), contractRecurringShouldRun(), contractRecurringAmountText(), contractRecurringStart() (skips missed runs with recurringInvoiceCatchUp()), contractRecurringStop(), contractRecurringFollow(), contractRecurringLink(), contractRecurringUnlink(), contractRecurringFromInput(), contractRecurringFirstDate(), contractRecurringCreate() and contractRecurringNightly(). getTicketSlaId() asks contractSlaFor() first, and takes $with_contract (false for the default - contractSlaRows() uses it). billingUnbilledTickets() prices each ticket with contractTicketRate() and adds rate_source and rate_note; billingUnbilledByClient() adds rates, rate_note and rate_source; billingTimeInvoice() with no price bills each ticket at its own rate, and combine makes one line per rate. billingInvoiceCreate(), quoteConvert(), recurringInvoiceGenerate(), New Invoice and Copy in agent/post/invoice.php take contractNetTerms(). contractsNightly() returns paused too, logged by cron/nightly_tasks.php. agent/post/contract.php adds make_contract_recurring and link_contract_recurring (POST) and unlink_contract_recurring (GET with the token), all Sales Modify, and Make active, Renew, Cancel and a countersignature that makes a draft active call contractRecurringFollow(); deleting a recurring invoice unlinks its contract. New agent/modals/contract/contract_recurring.php. The prepaid hours handlers pass the agent's name unescaped, as the history and logs escape it.

  • Client portal invites and sign-ups: database 3.4.6 adds portal_invites - invite_kind (invite or signup), invite_email, invite_name, invite_billing, invite_technical, invite_token_hash (the SHA-256 of the link's token), invite_expires_at, invite_sent_at, invite_sent_count, invite_created_by (an agent), invite_created_by_contact_id (a primary contact), invite_created_at, invite_accepted_at, invite_contact_id, invite_cancelled_at and invite_client_id (ON DELETE CASCADE), keyed on the client and address and on the contact - plus clients.client_portal_signup and settings.config_client_portal_signup tinyint(1) (0). New functions/portal_invite.php: PORTAL_SIGNUP_LINK_HOURS (24), PORTAL_INVITE_MAX (50), PORTAL_SIGNUP_IP_LIMIT (20), portalInvitesReady() (keeps every page and the nightly job as they were before 3.4.6 has run), portalInviteParse(), portalInviteWho() (new, contact, member or elsewhere), portalInviteRow(), portalInviteBy(), portalInviteState(), portalInviteUrl(), portalInviteNewToken(), portalInviteByLink() (hash_equals), portalInviteMail(), portalInviteSend(), portalInviteResend(), portalInviteCancel(), portalInvitesWaiting(), portalInviteStateLabel(), portalContactJoined(), portalInviteAccept() (spends the link with one guarded UPDATE first, so a second press or a race does nothing), portalMailSignupJoined(), portalMailInviteAccepted(), portalSignupOn(), portalSignupDomains(), portalSignupClients(), portalSignupOpen(), portalSignupClientFor(), portalSignupRequest() (logged as Client Login Sign Up Requested, Sign Up Sent and Sign Up Refused), portalMailSignupExists(), portalSignupSet() and portalInvitesNightly(), which cron/nightly_tasks.php runs and logs. portalContactAccess() and portalSignInMethods() move from client/functions.php to functions/portal.php, as cron and the agent side use them now. agent/post/contact.php adds send_portal_invites (POST) and resend_portal_invite and cancel_portal_invite (GET with the token), and agent/post/client.php set_client_portal_signup (POST), all Client Modify. agent/modals/contact/contact_invite.php, an unused stub (Welcome Letter, Big Wig) with no handler, is replaced by the Invite people window. agent/contacts.php adds role=invites. client/post.php adds portal_invites_send, portal_invite_resend and portal_invite_cancel (the primary contact's). New client/invite.php, client/join.php (?invite=&token=, Referrer-Policy no-referrer) and client/signup.php (a form token in the session and a hidden field robots fill in). edit_client_portal_settings saves config_client_portal_signup once 3.4.6 has run. emailPreviewInviteCatalog() adds portal_join_invite, portal_join_invite_colleague, portal_join_accepted, portal_signup, portal_signup_exists and portal_signup_joined. auditLogActionStyle() adds Invite and the Sign Up actions. New CSS: .itflow-join-box.

  • Get started: database 3.4.7 adds settings.config_getting_started tinyint(1) (1 in db.sql, so a new install has it; the update sets it to 0 for one already running), config_getting_started_skipped varchar(255) (the steps skipped, comma separated) and config_getting_started_opened_at datetime (when it was first opened - it opens by itself on signing in only until then). New functions/getting_started.php: GETTING_STARTED_STEPS, GETTING_STARTED_SLAS (priority => business minutes to respond; the SLAs gettingStartedApplySlas() makes have sla_resolution_minutes NULL, and it reuses one only with the same response time and none), gettingStartedReady() (nothing shows before 3.4.7 has run), gettingStartedState(), gettingStartedUrl(), gettingStartedCronCommand(), gettingStartedBackupJob(), gettingStartedSteps() (each step's done, skipped and status, from the settings), gettingStartedProgress(), gettingStartedAfter(), gettingStartedSkip(), gettingStartedSkipAll() (every step's key into config_getting_started_skipped, done ones too, so one that comes undone later stays skipped and off the dashboard; returns the titles that were still to do, for the audit log), gettingStartedOpened(), gettingStartedDismiss() (also what scripts/setup_cli.php --skip-get-started calls), gettingStartedFirstVisit() (login.php takes the first admin there, after MFA enforcement and not instead of a last_visited page), gettingStartedCard() (the dashboard's card), gettingStartedBackupSave() (the backup job's row in cron_jobs, seeded from the registry when the dispatcher hasn't yet), gettingStartedApplySlas() and gettingStartedDaysText(). New admin/getting_started.php (a page of its own, in the setup wizard's frame; ?step=, and ?cron_status=1 answers in JSON), its steps in admin/getting_started/ and admin/post/getting_started.php: getting_started_cron_on, getting_started_mail (continue or test), getting_started_backups, getting_started_billing, getting_started_support, getting_started_team, getting_started_content, getting_started_skip, getting_started_skip_all (to the dashboard) and getting_started_dismiss (the dashboard card's ×; the page's footer no longer offers it - Do it later and Skip all, data-gs-later and data-gs-skip-all, are at the top, with Skip all asking through js/confirm_modal.js, which the page now loads with SweetAlert2). The first client posts add_client to agent/post.php and the demo data load_demo_data, both of which return to the step. Admin > Mail's check of a sending or receiving server moves from a closure in admin/post/settings_mail.php to settingsMailServerCheck($server, $before, $post) in functions/settings.php, which both use. In admin/post/starter_content_model.php, starterContentLoad() and each starterLoad*() take $only, the items to load by starterItemKey() (null for the whole pack, as Maintenance > Starter Content loads it), and new starterContentRows(), starterItemKey() (a category's or tag's type with its name), starterItemName(), starterItemGroup(), starterOnly() and starterContentItems() (what a pack would add, judged here as each loader judges it); getting_started_content and load_starter_content (for one pack) take picked[pack] and items[pack][]; starterContentItems() gives each item's library row. New admin/includes/starter_content_pick.php (both pages): starterItemAbout(), starterItemLook() (through tagPill(), categoryChip() and productBilledText()), starterPreviewPacks(), starterItemPreview(), starterPickHtml($pack, $items, $form) (the list, tied by form= to a table row's Add) and starterPreviewModalHtml(); new admin/starter_content_preview.php answers Preview in JSON (?pack=&item=, admins only, 404 for anything else); new js/starter_content.js (data-starter-pick, data-starter-item, data-starter-pack-check, data-starter-count, data-starter-add, data-starter-preview) takes over from the picking in js/getting_started.js; new CSS .itflow-starter-*. New js/getting_started.js (data-gs-reveal, data-gs-show, data-gs-cron-poll), and js/app.js only starts DataTables where it is loaded, so setup and Get started, which load app.js on its own, no longer log an error to the console. New CSS: .itflow-gs-*.

  • Tags' look: .itflow-tag draws --itflow-tag-color as a color-mix() wash and edge with the icon in it and the name in --bs-body-color (--itflow-tag-ink, 75% or 55% under [data-bs-theme="dark"], is how much of the color the icon keeps), with neutral fallbacks before each color-mix(); --itflow-tag-text is no longer used by pills. .itflow-search-tag-icon and the new .itflow-select-badge.is-soft follow it. New tagOptionLook($color, $icon) in functions/tags.php (selectOptionColor() plus data-soft="1") for a tag's <option> - tagOptionHtml(), the Tags filters on Clients, Contacts, Assets, Credentials, Domains, Vendors and Locations, and the merge window; js/itflow_select.js puts an icon-and-color lead on the wash when its option says data-soft="1", and js/tag_picker.js always does. Categories and ticket statuses keep their filled badges.

  • Reply emails: database 3.4.8 adds ticket_replies.ticket_reply_emailed tinyint(1) (1 emailed, 0 not, NULL not known - a client's reply, or one from before the update the mail queue no longer had), ticket_replies.ticket_reply_emailed_to text (a JSON list of {email, name}, kept after the mail queue clears the emails) and email_queue.email_ticket_reply_id int(11), indexed. The update links the mail queue's Ticket update and Ticket resolved emails to the public reply on that ticket made in the 15 seconds before each was queued. emailQueue() takes a ticket_reply_id option, kept on each email, and so does ticketMailSend(); ticketMailReply() and ticketMailResolved() take $reply_id and mark the reply (both still return how many it went to). New in functions/ticket.php: ticketReplyEmailReady() (nothing is kept before 3.4.8 has run), ticketReplyEmailed($reply_id, $emailed = true) (who it went to, read back from the mail queue; false for a note), ticketReplyEmailStates($rows) (each recipient's mailQueueState() while the queue has the email) and ticketReplyEmailHtml(). add_ticket_reply, bulk_ticket_reply, bulk_resolve_tickets and the API's ticket_replies/create.php mark each reply they add, and ticket_replies/read.php returns the two new columns. New CSS .ticket-badge-note, .ticket-reply-emailed and .ticket-reply-recipients.

  • Popups and editors: new itflowDismissPopups(from) in js/itflow_select.js closes what a click outside would - Bootstrap dropdowns (not those with data-bs-auto-close false or inside, nor one holding from), an open flatpickr calendar - and fires itflow:dismiss on document, on which js/itflow_select.js, js/quick_pick.js and js/tag_picker.js close their panel. It runs on mousedown/touchstart in every TinyMCE editor (through tinymce.on('AddEditor'), so the client portal's too) and when the focus goes into any other iframe (on window blur), since neither reaches the page. .itflow-select-sheet-head shows on a desktop too, as a slim heading: the field's label with Cancel on a single select (close(false, true), nothing typed is added) or Done on a multi select; the tag picker keeps the Done in its footer there. The canned response picker has [data-canned-close], which also focuses the reply.

  • Security sweep 2: api/v1/enforce_api_rbac.php counts every call but read.php and time.php as a write ($is_write), so archive.php, unarchive.php, tickets/resolve.php and tickets/close.php refuse a client_id the key's user can't reach (apiUserCanAccessClient()) as create, update and delete do - a list of the reads, so a new kind of write is checked by default. api/v1/assets/asset_model.php, contacts/contact_model.php, networks/network_model.php and documents/document_model.php pass the contact, location, vendor, network and folder through assetClientOwnedId() / folderOwnedId() against $client_id, as their forms do. link_contact_to_asset (agent/post/contact.php) refuses a contact assetClientOwnedId('contact', ...) doesn't own, and unlink_asset_from_contact only clears asset_contact_id when it is still that contact. Existing cross-client asset contacts: SELECT asset_id FROM assets JOIN contacts ON contact_id = asset_contact_id WHERE contact_client_id <> asset_client_id.

  • Security sweep 2: new in functions/export.php - exportClientName($client_id) and exportRecordName($table, $id, $name_column, $client_column) (the name for an export's filter line only when the agent can reach its client; a record of no client is named) and guardExportHasRows($num_rows, $what) ("No $what match - nothing to export." and back, called before the $num_rows > 0 block of every export but projects, services and the two logs, which already did). download_assets_csv_template needs module_support and names the file after the client only when hasClientAccess(). The flash messages of delete_ticket_additional_asset, bulk_add_ticket_project and bulk_edit_asset_interface_network name the asset, project or network only once it was on the ticket or taken; the unlink handlers in agent/post/asset.php and contact.php name the other record only when assetClientOwnedId() owns it; bulk_move_files refuses a folder whose client the agent can't reach before naming it.

  • Security sweep 2: the guest quote's Accept and Decline post quote_id, url_key and accept_quote or decline_quote to guest/guest_post.php, which compares the key with hash_equals(); the GET form of accept_quote / decline_quote no longer answers. contractSignatureByKey(), portalInviteByLink() and client/join.php take a key or token that isn't a string as ''. $_SERVER['HTTP_USER_AGENT'] ?? '' in api/v1/validate_api_key.php, includes/load_user_session.php, client/login_microsoft.php, guest/includes/inc_all_guest.php and isMobile().

  • Security sweep 2: the handlers that fetch their record and go on when it is gone return "That ... no longer exists." - archive_asset, restore_asset (both failed on asset_history's foreign key), delete_asset, edit_asset, the asset and contact note handlers, add_asset_note, add_contact_note, anonymize_contact, archive_contact, restore_contact, delete_contact, unlink_credential_from_contact, unlink_credential_from_asset, edit_invoice, add_invoice_copy, mark_invoice_sent, mark_invoice_non-billable, cancel_invoice, delete_invoice, delete_payment, refund_payment_stripe, add_quote_copy, edit_quote, mark_quote_sent, delete_quote, accept_quote, decline_quote, mark_quote_invoiced, add_invoice_recurring, edit_recurring_invoice, set_recurring_payment, delete_recurring_invoice and recurring_invoice_email_notify. restore_contact_note read its id from unarchive_contact_note. The ticket prefix's pattern is [A-Za-z\-]+ - an unescaped - in a class is an error under the v flag browsers now compile pattern with.

  • Asset types: new functions/asset_type.php, loaded by functions.php. ASSET_TYPES (name => icon, tone, group, os - os or firmware - fields from ASSET_FIELDS (contact, os, network, nat, links, serial, purchase, warranty), specs and example, the form's placeholders), ASSET_SPECS (key => label, input - text, number, select, date or asset - icon, and as they apply unit, min, max, step, options, suggest, asset_types, placeholder, ports (copper or sfp), flag, mono, unit_only and alone - a value that says what it is, shown without its label in a short line), ASSET_RACK_TYPES and ASSET_TYPE_GROUPS. assetTypeInfo() (case-blind, Other's entry for a type it doesn't know), assetTypeName(), assetTypeIcons() (what $asset_types_array and productAssetTypes() return now), assetTypesInGroup(...$groups), assetTypeGroupSql($group, $column), assetTypeTabs($column) (workstation, server, virtual, network, power, printer and other, each with label, new, groups, icon and query - the list's and the export's type), assetTypeHasField(), assetTypeOsLabel() and assetTypeSpecs(). assetTypeTone(), getAssetIcon(), rackItemStyle(), portalAssetGroups() (with power), the client overview's counts, the client export's families and the document template and AI asset lists read the registry.

  • Asset details: database 3.4.9 adds asset_specs - asset_spec_asset_id (ON DELETE CASCADE), asset_spec_key varchar(50) and asset_spec_value varchar(255), unique on the asset and key, keyed on the key and value. assetSpecsReady() (false until the update has run: nothing is read, kept or flagged), assetSpecsFor($asset_ids), assetSpecs($asset_id), assetSpecsFromInput($type, $input, $client_id, $asset_id, &$problems) (each checked by its input; an asset pick by id or name, the same client's, of its asset_types, not itself), assetSpecsSave($asset_id, $type, $specs) (keys not given or not the type's are deleted), assetSpecsFromPost($asset_id, $type, $input, $client_id) (returns the message's ", with ... added" and the problems; ports are made when a count is new or changed), assetPortsFromSpecs(), assetPortsMadeText(), assetSpecText(), assetSpecRows($type, $specs, $client_id) (key => label, icon, text, html, flag from assetWarranty(), alone and brief - the short line the list and the quick look use), assetSpecAssetOptions(), assetGuests($asset_id, $client_id), assetSpecsExportAttach($rows) (spec_<key>, a host by name) and assetSpecsApiAttach($rows).

  • Asset handlers: add_asset and edit_asset take spec[key]; edit_asset without spec keeps the asset's details, less those its type doesn't take. import_assets_csv takes twelve columns or more, matching each after the twelfth to a detail by its label, label and unit, or key, and keeps them once every row is in. bulk_transfer_client_asset copies interface_type and the details but vm_host. export_assets takes spec_<key> in columns[] and the assetTypeTabs() keys in type. agent/assets.php takes type=power and show_column[]=Details.

  • Interfaces: INTERFACE_SFP_TYPES, INTERFACE_NOT_PORT_PATTERN, INTERFACE_MANAGEMENT_PATTERN, interfaceIsSfp(), interfacePortKind($type, $name) (copper, sfp, or '' for a radio, VLAN, coax or console, or a management port such as mgmt0), interfaceSeriesNext($asset_id, $asset_type, $sfp) ([prefix, start, pad] of the next run, which New interfaces now starts from) and interfacePortsEnsure($asset_id, $asset_type, $copper, $sfp) (makes the missing ones, counting live ports, never taking an archived one's name). INTERFACE_PORT_MAP_TYPES adds Modem/ONT, NAS and NVR/DVR.

  • API: assets/create and assets/update take asset_specs ({key: value}; on update laid over what the asset has, '' clearing one, and a new asset_type keeping only its own) and answer asset_specs_not_kept (an array of strings) when some weren't kept; asset_type is saved as the list spells it. assets/read gives every asset asset_specs ({} for none), through $api_asset_specs in api/v1/read_output.php.

  • Nightly: "UPS Battery Due" notifications from a UPS's battery_replace, on the warranty alert days (1, 7, 45), linking to the asset - a Support type in notificationTypeModules(), drawn with car-battery. CSS: .itflow-asset-tone-storage and .itflow-asset-tone-power, light and dark, and .itflow-port-break before a port map's SFP cages.

  • Client terms: database 3.5.0 adds settings.config_default_after_hours_rate decimal(15,2) (NULL - none, after-hours time bills at the hourly rate) and gives each client whose client_rate, client_net_terms or sla_assignments rows differ from the defaults a contract named Standard terms - type other, Active, a 12-month term renewing itself, 0 notice days, with only the differing contract_rate_standard, contract_net_terms and contract_sla_{part}_id set. It starts on DATE(client_created_at), or the day before the client's earliest contract_start_date when that is on or before it, its end carried on a year at a time to the term running today, and is archived with an archived client. A client row pointing at SLA 0 or an archived SLA isn't carried. The update then deletes the sla_assignments rows with sla_assignment_client_id > 0 and drops clients.client_rate and client_net_terms; run again, it does nothing more.

    • functions/billing.php: billingAfterHoursReady() (3.5.0 has run), billingDefaults() (rate, after_hours - 0 for none - and net_terms, read once a request), billingClientLegacyTerms($client_id) (a client's own rate, net terms and SLA rows while 3.5.0 hasn't run, so they apply where no contract sets them, as before; null after), and billingClientLegacyFields() and billingClientLegacySql(), which give a client added before 3.5.0 runs the default client_net_terms (NOT NULL, no default) - every client INSERT uses them. billingTimeRate() takes no argument and returns the default rate. billingUnbilledTickets() and billingRow() no longer select the client's rate or terms.
    • functions/contract_billing.php: contractTicketRate() adds the source default_after_hours, and gives client only before 3.5.0 runs; contractNetTerms($client_id, $date) loses $client_terms and falls back to billingDefaults(); new contractClientTerms($client_id) returns rate (contractClientRate()), after_hours, net_terms and slas (by priority), each with the name of the contract it comes from or ''; new contractNetTermsWhose($client_id, $date) is contractNetTerms() with whose terms they are in words, for New Invoice, Copy and a quote's Make Invoice; new contractUsualTerms($row, $date) is what applies for what a contract leaves empty on a day - another contract in force for the client, else the default, each with where it comes from. The contract's page and its document use it (contractPlaceholderValues(), and contractSlaRows($row, $date) and contractSlaTableHtml($row, $date), which take the day the document is as of; contractSlaRows() adds from), so a document that left the rate, net terms or an SLA to the client's own says the same after the update, from Standard terms, and nothing signed goes out of date. getTicketSlaId() no longer reads a client's own sla_assignments rows once 3.5.0 has run - the contract's, else client 0's.
    • functions/contract.php: new contractDefaultHints() (the placeholders and SLA option text of contract_form.php and contract_template_form.php) and contractStandardTermsAdd($client_id, $rate, $net_terms, $slas = [], $start = null, $notes = '') (the contract id, or 0 when nothing differs from the defaults), used by import_clients_csv and api/v1/clients/create.php, which returns contract_id. api/v1/clients/update.php returns not_kept when client_rate or client_net_terms is sent.
    • Clients: client_add.php and client_edit.php lose the rate, net_terms and sla_* fields, and add_client and edit_client no longer save them or SLA rows; new agent/modals/client/client_terms_note.php ($terms_note_client_id, 0 for a new client) is their Billing tab's note. Removed: agent/modals/client/client_bulk_edit_hourly_rate.php and client_bulk_edit_net_terms.php, with the bulk_edit_client_hourly_rate and bulk_edit_client_net_terms handlers. clientExportRows(): the client dataset's hourly_rate, after_hours_rate, net_terms and new terms_from (with the billing section), and the slas dataset's rows (each priority's SLA with a new from), are what the client is billed on. Demo data gives each demo client a contract (demoBuildContract()) instead of a rate, terms and SLA rows of its own, and its removal keeps an SLA a remaining contract or template sets.
    • Contracts from the client: contractClientPickReady() (contracts there, accounting on, Sales at Modify), contractFromTemplateAdd($client_id, $template_id, $start, $owner_id, $notes, $status) ([contract id, error] - active from $start, its term carried on to today when it renews itself, the client's primary contact; with $status Draft it waits to be signed and isn't carried on) and contractFromClientForm($client_id, $post) (contract_template_id and contract_start, logged as Contract Create; [id, name, error]), in functions/contract.php. New agent/modals/client/client_contract_pick.php ($contract_pick_edit) on the Billing tab of client_add.php and client_edit.php; add_client and edit_client call contractFromClientForm() after saving, and say when the contract couldn't be made. New agent/modals/client/client_bulk_add_contract.php and bulk_add_client_contract (POST client_ids[], contract_template_id, contract_start; Client and Sales at Modify; logged as Contract Bulk Create). New contractInForceSql($alias, $date) in functions/contract_billing.php. agent/clients.php takes contract (any, none or a template id) and sort=contract (the name of the contract in force that started last).
  • Agent invites: database 3.5.1 adds user_invites - invite_email, invite_name, invite_role_id, invite_force_mfa, invite_client_access (text, JSON {"allow": [client_id], "deny": [...]}, NULL for none), invite_avatar (a file in uploads/users/invites/<invite_id>/), invite_token_hash (the SHA-256 of the link's 48-hex token), invite_vault (the vault's master key sealed under the token as setupFirstUserSpecificKey() seals it under a password - wiped once accepted, cancelled or run out), invite_expires_at, invite_sent_at, invite_sent_count, invite_created_by, invite_created_at, invite_accepted_at, invite_user_id and invite_cancelled_at, keyed on the address and the user.

    • New functions/user_invite.php, loaded by functions.php: USER_INVITE_LINK_DAYS (7), userInvitesReady() (3.5.1 has run - until then New User sets a password and nothing else changes), userInviteMailReady(), userInviteRow(), userInviteFor($email) (the one waiting), userInviteState(), userInviteStateLabel(), userInviteClientAccess(), userInviteBy(), userInviteUrl(), userInvitesHoldingSeats($except_invite_id), userInvitesWithRole(), userInviteSessionVaultKey() (the master key from the admin's session, '' when it can't open it), userInviteNewToken() (a new token, hash and seal - '' and nothing changed without the vault), userInviteByLink() (hash_equals), userInviteSaveAvatar(), userInviteRemoveAvatar(), userInviteAvatar(), userInviteSave() (one waiting for the address is updated, keeping its picture), userInviteSend($invite_id, $mail, &$error) (a new link, emailed or only made), userInviteShowLink() and userInviteTakeLink() (the link to pass on, kept in the session to show once), userInviteMail(), userInviteCancel(), userInviteAccept() (spends the link with one guarded UPDATE first; makes the user, user_settings, client rules and picture; logs User Create in the new agent's name; notifies whoever sent it), userInviteJoined(), userInvitesWaiting() and userInvitesNightly(), which cron/nightly_tasks.php runs and logs.
    • New /join.php (?invite=&token=, posts agent_join, name, new_password, confirm_password; Referrer-Policy no-referrer, refused over plain HTTP when $config_https_only is set), then $_SESSION['login_message'] and on to /login.php, with ?key= when the login key is required.
    • add_user takes sign_in (invite or password): invite keeps the invite and emails it, or with mail not set up shows the link once, and goes to users.php?show=invited; without sign_in, a password sent sets it, as before. Setting a password for an address with an invite waiting cancels the invite. New GET handlers with the token: resend_user_invite, link_user_invite and cancel_user_invite, logged as User Invite and Cancel. getting_started_team invites when userInviteMailReady().
    • userMailNewAccount() is removed, and with it the user_new preview and send_email on New User. emailPreviewUserInviteCatalog() adds user_invite. New userAvatarStore($file, $dir, &$error) in functions/user.php, which userSaveAvatar() now uses. roleArchiveBlocker() refuses a role with invites waiting. gettingStartedSteps() counts the team step done with an invite waiting.
    • admin/users.php adds show=invited, its own list from user_invites sorted by the same headers (user_name, user_email, role_name, user_status - when it runs out - and user_created_at). user_form.php adds the sign_in choice for a new user (data-user-sign-in, data-user-password-field), and user_add.php's button data-user-submit takes its label from it.
  • Client onboarding: database 3.5.2 adds client_onboardings - onboarding_client_id (unique, deleted with the client), onboarding_saved and onboarding_skipped (comma lists of step keys), onboarding_contract_id, onboarding_recurring_invoice_id and onboarding_project_id (what its steps made), onboarding_started_by, onboarding_started_at, onboarding_finished_at and onboarding_dismissed_at.

    • New functions/client_onboarding.php, loaded by functions.php: CLIENT_ONBOARDING_STEPS (company, locations, people, agreement, billing, it, kickoff), clientOnboardingReady() (3.5.2 has run - until then nothing is shown), clientOnboardingCan() (and Clients at Modify), clientOnboardingUrl($client_id, $step), clientOnboardingRow(), clientOnboardingStart() (once, logged as Client Onboard), clientOnboardingStepKeys() (the agent's steps, by module and role), clientOnboardingContract(), clientOnboardingSteps() (each with done - saved, or the client has what it is about - skipped, saved and a status line), clientOnboardingProgress(), clientOnboardingAfter(), clientOnboardingMark($client_id, $key, $kind, $on), clientOnboardingMade(), clientOnboardingFinish(), clientOnboardingDismiss(), clientOnboardingCard() (the overview's card, or null) and clientOnboardingState() (none, open or finished).
    • New agent/client_onboarding.php (?client_id=&step=, step=done for the summary; it starts the onboarding of a client it is opened for), its steps in agent/client_onboarding/ (FROM_CLIENT_ONBOARDING), and agent/post/client_onboarding.php: client_onboarding_company (client_id 0 makes the client), client_onboarding_location, client_onboarding_contact, client_onboarding_agreement, client_onboarding_billing, client_onboarding_it, client_onboarding_kickoff, client_onboarding_skip (the step's key), client_onboarding_finish (logged as Client Finish) and client_onboarding_dismiss. A refused step comes back through settingsRefuse() under client_onboarding_<step>.
    • The audit log has an Onboard action. Data attributes: data-onboard-new and data-onboard on clients.php, data-client-onboarding (its state) in client_actions_menu.php, data-co-card on client_overview.php, data-onboard-lead in client_edit.php and data-gs-onboard on Get started's client step.
  • Mentions in internal notes: database 3.5.3 adds ticket_reply_mentions - mention_reply_id (the note, deleted with it), mention_user_id, mention_by and mention_created_at, keyed on the note and the agent. A ticket's mentions are its notes', so a merge that moves replies moves them.

    • A mention is <span class="itflow-mention itflow-mention-u{user_id}" data-mention="{user_id}" contenteditable="false">@Name</span> in the note's HTML. The page's HTML Purifier keeps the classes and drops the rest.
    • New functions/ticket_mention.php, loaded by functions.php: ticketMentionsReady() (3.5.3 has run - until then a mention is left as posted and nobody is told), ticketMentionAgents($client_id) (active agents with Support at Read or an admin role, let at the client by user_client_permissions), ticketMentionOffer() and ticketMentionAgentsAttr() (the picker's list, without you, as data-mention-agents JSON), ticketMentionSpan(), ticketMentionsFromHtml($html, $client_id, $internal) ([html, user ids] - each mention rewritten with the agent's current name, or unwrapped to its text when they can't open the ticket or the reply isn't internal), ticketMentionsSync($reply_id, $user_ids) (returns the ones added), ticketMentionsForTicket(), ticketMentionedSql($user_id), ticketMentionUrl() (#reply-{id}), ticketMentionsTell() (a Ticket notification and ticketMailMentioned(), not to the author; returns user_id => name) and ticketMentionNames().
    • add_ticket_reply, edit_ticket_reply and redact_ticket_reply run the reply through ticketMentionsFromHtml() before saving and ticketMentionsSync() after; add and edit tell the agents added and write ticket history, and redact tells nobody. bulk_ticket_reply leaves mentions as text. add_ticket_reply no longer sends the "updated Ticket" notification to an assignee or creator the note mentions.
    • ticketAgentEmailContent() has the kind mentioned (by, message, reply_id), and emailPreviewCatalog() adds agent_mentioned. agent/ticket.php gives each reply card id="reply-{id}" and data-reply-mentions-me, agent/tickets.php adds the show=mentioned view, and js/app.js adds itflowMentionAutocompleter() to the .tinymce and .tinymceTicket editors, with the [data-mention-hint] and [data-mention-public] lines beside them.
  • Contracts, dates and what stops once one is over:

    • functions/contract.php: new contractDateValid($date) (a real day written Y-m-d), used by contractFromInput(), contractFromTemplateAdd(), contractStandardTermsAdd() and the activate_contract, renew_contract and cancel_contract handlers, where 2026-02-30 passed strtotime() and failed on saving. New contractAddMonths($date, $months, $day = 0) and contractAnchorDay($row) (its start's day, while its term ends on the day before it - 0 when its end date was set to another day); contractTermEnd($start, $months, $day = 0) and contractNextEnd($end, $months, $day = 0) keep to day $day of the month (the start's own by default), or the last day of a shorter month, in place of PHP's +N months overflow. contractsNightly(), contract_renew.php, contractFromTemplateAdd() and contractStandardTermsAdd() pass the contract's start day, and termEnd() in js/contract_form.js works it out the same.
    • New contractDueSoonSql($today = null): active, within CONTRACT_REMINDER_DAYS of its notice date, and ending (contract_auto_renew 0) or renewing with contract_notice_days > 0 and its notice date after the day its term started. contractViews()'s soon (the Due soon tab, the side menu count and the client overview's tiles) and contractsNightly()'s reminder use it, and contractState() gives renewing on the same rule, through the new contractNoticeInTerm($row). New contractClientLiveSql($column = 'contract_client_id') (the client isn't archived), used by contractsNightly()'s renewals, endings and reminders, agent/contracts.php across every client (rows, tabs and figures) and get_side_nav_counts.php.
    • functions/contract_signature.php: new contractSignatureWithdrawStale($client_id = 0, $contract_id = 0) withdraws the rounds waiting on the client or a countersignature on contracts Ended or Cancelled, or of archived clients - contract_signature_withdrawn_reason The contract was cancelled, The contract ended or The client was archived - and returns how many. cancel_contract (audit Withdraw, and the flash), archive_client, bulk_archive_clients and contractsNightly() (withdrawn in what it returns) run it. New contractSignatureOpenSql(): contractSignatureClientSign() and contractSignatureAgentSign() sign only while the contract is a draft or active and its client isn't archived. contractSignatureSelectSql() adds client_archived_at, and contractSignatureState() gives withdrawn for a round still waiting on such a contract. portalContractSentSql(false) (To sign) keeps to drafts and active contracts, and send_contract_signature refuses an archived client.
    • functions/contract_hours.php: new contractHoursLive($row, $today = null) and contractHoursLiveSql($today = null) - active, or cancelled with its end date not passed - used by contractClientHours(), contractHoursEnsureMonthly(), contractHoursNightly(), add_contract_hours, contract_hours_add.php and the contract page's Prepaid hours card (data-contract-hours-until while a cancelled one is drawn from).
    • New contractDeleteBlocker($row), contractDeleteBlockers($rows) (id => why, for rows with contract_id and contract_status) and contractDeleteWarning($row). delete_contract refuses a contract with prepaid hours drawn (contract_block_use_hours > 0) or a round signed by both, and contract.php and contracts.php show why (data-delete-blocked) in place of Delete.

Library Updates

  • Removed Tom Select, replaced by ITFlow's own js/itflow_select.js.
  • ClipboardJS is no longer loaded on agent pages (still shipped for the MFA enforcement page).

[26.10]

Upgrading to 26.10

Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's one database change (2.7.8 to 2.7.9) in the same pass. Nothing else is required.

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

  • API: reading locations, documents, vendors, domains, networks and software no longer returns archived records, including when you ask for one by its ID. There is no way to list archived records through the API yet, so keep the ID of anything you archive if you may want to unarchive it later. Thanks to @Wrongecho.
  • API: a location must be archived before the delete endpoint will remove it, the same rule contacts have followed since 26.09.1.
  • Recurring Invoices: every existing recurring invoice is set to Send Automatically after the update, so nothing changes until you switch one to the new draft option below. A draft is not emailed, does not appear in the client portal, and is not charged automatically until you send it. Automatic payments only run on an invoice's due date, so if a recurring invoice also takes payment automatically, send its draft before the due date or it will not be charged.
  • Notifications: each night ITFlow posts a notification when draft invoices are waiting for review. It counts every draft invoice, including ones created by hand, so if you keep drafts around you will see it daily.
  • Stripe: payments affected by the one-cent problem under Bug Fixes are not repaired by the update. A guest invoice payment that went through in Stripe but never appeared in ITFlow still has to be added by hand with Add Payment, putting the pi_ reference from Stripe in the reference field.
  • The two shipped files in agent/custom/includes/ (custom_side_nav.php and inc_all_custom.php) are now marked do not modify, because updates replace them. If you have edited either one, keep a copy of your version under a different name before you update.

New Features & Updates

  • Recurring Invoices: each recurring invoice now has an Action setting — Send Automatically, as before, or Generate Draft for Review. A draft is created on schedule as usual but waits for you to check it, change it if needed, and send it yourself. The Email Notify toggle is hidden while a recurring invoice is set to draft. Thanks to @Wrongecho.
  • Invoices: tickets can be taken back off an invoice. Each linked ticket has a remove button that unlinks it and returns it to unbilled, and the Tickets card shows each ticket's number as a link to the ticket. Billable tickets that are Resolved can now be added, not just Closed ones. Thanks to @Wrongecho.
  • Tickets: the assignee shows as a small pill with the agent's photo, or their initials if they have not uploaded one. This applies on the ticket list, the board view, the ticket itself, recurring tickets, and the ticket lists on assets, contacts and projects. An open unassigned ticket gets a red Unassigned pill that turns grey once the ticket is resolved or closed. The Mine and Unassigned buttons on the tickets page have new icons to match.
  • Networks: an archived network now shows (Archived) next to its name at the top of its page.
  • Navigation: your company name at the top of the side navigation shrinks to fit instead of overflowing when it is long. Thanks to @BoredManCodes.
  • API: new endpoints to update, archive, unarchive and delete locations; archive and unarchive documents and software; and create, update, archive and unarchive vendors, domains and networks. For domains, the DNS and WHOIS details are looked up from the domain itself rather than taken from the request, and so is the expiry date when you do not send one. Thanks to @Wrongecho.

Bug Fixes

  • Stripe: about one card payment in fifteen was charged a cent less than the invoice balance. When paid from a guest invoice link, the payment then failed to record in ITFlow even though Stripe had taken the money. When paid automatically, from the client portal or by an agent, it was recorded a cent short and the invoice was marked Paid. Charges now match the balance exactly. See the note above about payments already affected.
  • Invoices: adding an unbilled ticket from the invoice page did not work. The Tickets card that holds the add button also only appeared once a ticket was already linked, so the first ticket could never be added there. Both are fixed. Thanks to @Wrongecho.
  • Licenses: archived licenses could not be restored, because the menu on an archived license still offered Edit and Archive. It now offers Restore. Restoring does not bring back the asset and contact assignments that archiving removed.
  • Assets: a login entered while creating an asset was only saved if it had a username, so a password on its own was lost. A username or a password on its own is now enough.
  • Copy buttons inside pop-up windows, such as the ones for credentials and a new API key, did nothing. They copy again.
  • Editor: inside pop-up windows, the editor's "..." button for more tools closed as soon as it opened, and the link dialog would not take typing, so links could not be added or edited. The editor's other drop-down menus and dialogs were affected the same way. All of them work again.
  • Editor: closing a pop-up that contains an editor and opening it again, New Ticket for example, brought the editor back as a plain text box until the page was refreshed. It now loads every time.
  • Documents: images in documents, document versions and queued emails opened in a pop-up spilled past the edge of the window, and tables showed without their styling. The same happened in document previews on the Files page. Both now display properly.
  • Tickets: in Change Client, picking the new client did not load that client's contacts, so no contact could be chosen.
  • Client Portal: the header with your photo, the welcome line and the company logo no longer breaks onto separate rows on smaller screens, and the menu button on phones shows a proper menu icon. Thanks to @BoredManCodes.
  • Restore the ability to type in a manual entry in select boxes with data-tags=true inclusing adding custom ticket watchers.

Developer Updates

  • Database 2.7.9 adds recurring_invoices.recurring_invoice_auto_send (tinyint(1) NOT NULL DEFAULT 1). It is read by the nightly run in cron/nightly_tasks.php and by force_recurring in agent/post/recurring_invoice.php. Invoices generated in draft mode are inserted as Draft and everything else as Sent, as before. The cron email still also requires config_recurring_auto_send_invoice and the recurring invoice's email notify to be on.
  • The nightly draft notice is a single appNotify('Draft Invoices', ...) linking to invoices.php?status=Draft. It is raised whenever any Draft invoice exists.
  • Stripe: all four PaymentIntent sites (agent/post/payment.php, client/post.php, cron/nightly_tasks.php, guest/guest_ajax.php) now send intval(round($balance_to_pay * 100)).
    • intval() alone truncates, and about 6.6% of two-decimal amounts land just under the whole cent in floating point (2.01 × 100 = 200.999…).
    • The guest return path compares whole cents with round() on both sides, so the short charge failed that check and nothing was booked.
    • The other three paths compare whole dollars, so the short charge passed and they booked the short amount. Those dollar-level checks are unchanged.
  • formatAssignee($user_id, $user_name, $user_avatar, $highlight_unassigned = true) in functions/format.php replaces the assignee rendering at nine sites. Callers need user_avatar in their query. It was added to the related-ticket queries on contacts and projects and to $ticket_select_columns in agent/tickets.php.
  • TinyMCE in modals: Bootstrap 5's focus trap pulled focus back out of TinyMCE's popups, which render in .tox-tinymce-aux on <body>.
    • js/app.js binds a focusin listener on .tox-tinymce-aux once at page load, and it calls stopImmediatePropagation(). Bootstrap adds its own listener each time a modal opens, so this one always runs first.
    • js/ajax_modal.js now removes a modal's TinyMCE editors on hidden.bs.modal. TinyMCE kept the dead editor in its registry, and tinymce.init skipped the next textarea with the same id.
  • Scripts loaded inside ajax modals cannot rely on DOMContentLoaded, which has already fired by then.
    • js/pretty_content.js now exposes prettyContent() and runs it immediately when the document is already parsed. agent/files.php calls it again after loading a preview.
    • agent/js/ticket_change_client.js moved to itflowReady().
  • Clipboard: the ClipboardJS container is now chosen per click — the trigger's own modal, otherwise body — so Bootstrap's focus trap no longer swallows the copy.
  • Sidebar brand: agent/css/sidebar_brand_fix.css and agent/js/sidebar_brand_fix.js are loaded from agent/includes/side_nav.php. They scale #sidebar-brand-text with a CSS transform, driven by a ResizeObserver on the brand link. The custom side nav does not load them.
  • Invoices: new remove_ticket_from_invoice GET handler in agent/post/invoice.php (CSRF token in the URL, module_sales 2, enforceClientAccess). add_ticket_to_invoice now redirects to the invoice instead of post.php, and the billable list is ticket_status IN (4, 5).
  • Software: new restore_software handler in agent/post/software.php (module_support 2).
  • API: new endpoint files under api/v1/:
    • locations/{update,archive,unarchive,delete}, documents/{archive,unarchive}, vendors/{create,update,archive,unarchive}, domains/{create,update,archive,unarchive}, networks/{create,update,archive,unarchive} and software/{archive,unarchive}.
    • New vendor_model.php, domain_model.php and network_model.php.
    • Writes are POST and scoped by the posted client_id. Every read.php for those modules now filters on *_archived_at IS NULL.
    • Domain create and update call getDnsRecords() and getDomainExpirationDate(), so each request does live DNS and WHOIS lookups. Update writes domain_history rows the way the web form does.
    • Archiving software through the API only sets software_archived_at and leaves software_assets and software_contacts alone. The web archive deletes them.
  • add_asset in agent/post/asset.php creates the credential when either a username or a password is posted. It previously required a username.

Library Updates

  • Bump TinyMCE from 8.8.2 to 8.9.2.
  • Bump stripe-php from 21.0.0 to 21.3.2. The pinned API version moves from 2026-06-24.dahlia to 2026-08-26.dahlia. TelemetryId is unchanged, so includes/stripe_init.php is still needed.
  • Bump FullCalendar from 7.0.2 to 7.1.0.
  • Bump DataTables from 3.0.1 to 3.1.2.

[26.09.3] Maint Release

Upgrading to 26.09.3

Update from Maintenance > Update — Queue Update hands the job to cron and it applies on its own. There is no database change in this release, so nothing else is required.

Breaking Changes and Notes

  • New tickets are now assigned to whoever is creating them. The assignee on the ticket form and on the client bulk-add form starts on your own name instead of Unassigned, and you can still pick anyone else or Unassigned before saving.
  • API: a ticket created with an assignee now comes in as Open rather than New, matching what happens when an agent assigns a ticket by hand. Anything of yours that watches for New tickets to pick up work will no longer see assigned ones.
  • Tax Summary and the dashboard Income by Category chart will show different numbers than they did before. Both were wrong wherever an invoice had been partly paid, and both usually read high. The corrected figures are described under Bug Fixes.

New Features & Updates

  • Invoices: a partly paid invoice shows what is still owed in red underneath the invoice total in the list, so you can see the outstanding balance without opening the invoice.

Bug Fixes

  • Invoices: the Unpaid figure at the top of the invoice list counted the whole of every partly paid invoice rather than what was left on it, and counted that invoice again for each payment against it. An invoice for $1,000 with three payments of $100 added $3,000 to Unpaid; it now adds the $700 that is actually outstanding.
  • Reports: Tax Summary counted an invoice's full tax once per payment recorded against it, so a partly paid invoice with three payments reported three times its tax. Tax is now booked to the month the money came in, in proportion to how much of the invoice that payment covered, and each payment is counted once. Yearly totals also no longer disagree with the months they are made of.
  • Dashboard: the Income by Category chart only counted invoices marked Paid, so partly paid invoices contributed nothing and revenues entered outside an invoice never appeared at all. It now counts payments and revenues as they land, matching the Cash Flow chart above it and the Income Summary report.
  • Tickets: ticket-created emails told the client the status was Open no matter what the ticket was actually set to. They now carry the real status, on tickets an agent creates and on scheduled tickets from recurring tickets.
  • Tickets: the assignee list on the client bulk-add form left out agents on the Accountant role and did not match the list on the normal ticket form. Both lists are now the same.
  • Quotes and Recurring Invoices: picking a product from the item autocomplete put the word "undefined" in the item name, while the description and price filled in correctly. Invoices were not affected. The product list behind the box was also missing information on those two pages, so every entry read "No tax", services showed a stock badge, and searching by product code did not match. Reported by @cthompson.

Developer Updates

  • getMonthlyTax() and getQuarterlyTax() in functions/app.php are rewritten. They previously joined invoice_items to invoices to payments, which multiplied the line-item rows by the payment rows — the double counting was row multiplication, not a rounding problem. Both now drive off payments, join a pre-aggregated per-invoice tax subquery, and scale by payment_amount / invoice_amount, with invoice_amount > 0 guarding the division. agent/reports/tax_summary.php also dropped a second loop that recalculated each row total by calling getMonthlyTax() another twelve times; the total accumulates in the first loop instead, cutting the queries behind the monthly view in half.
  • agent/invoices.php: the Partial total no longer selects SUM(invoice_amount) across a payments join, and payments against partial invoices are subtracted from the unpaid figure. The list query gained a derived LEFT JOIN (SELECT payment_invoice_id, SUM(payment_amount) ... GROUP BY payment_invoice_id) for the per-row balance, which keeps it to one query rather than one per row.
  • agent/dashboard.php: the TopCategories temporary table is now built from a UNION ALL of payments (carrying their invoice's category) and revenues, keyed on payment and revenue dates rather than invoice_status = 'Paid' and invoice_date. The Other bucket is built from the same union.
  • api/v1/tickets/create.php sets ticket_status = 2 when assigned_to > 0, rather than always inserting status 1.
  • agent/modals/client/client_bulk_add_ticket.php filtered the assignee list on user_role_id > 1 where every other assignee list uses user_type = 1. Role 1 is the built-in Accountant role, so accountant-role agents were missing from it.
  • Product autocomplete is consolidated. The three pages each carried their own product SELECT and their own copy of the autocomplete JavaScript; the queries drifted, and quote and recurring invoice were still on a four-column version that had no product_name or prod_id, so the shared onSelect wrote undefined into #name. Its last line also assigned to #product_id, which only the invoice form has, so onSelect threw a TypeError on those two pages and the input handler under it threw on every keystroke. The query now lives in getProductsForAutocomplete($mysqli) in functions/app.php and the JavaScript in js/product_autocomplete.js, with the hidden #product_id added to the quote and recurring item forms and treated as optional in the JavaScript. Net 218 lines removed for 134 added. Note that item_product_id is still only written by add_invoice_item and the API, so the hidden field on those two forms is inert until the handlers are wired up.

[26.09.2] Maint Release

  • Updates the App Version to a proper version number.

[26.09.1] Maint Release

Upgrading to 26.09.1

Update from Maintenance > Update — Queue Update hands the job to cron and it applies on its own. There is no database change in this release, so nothing else is required.

Breaking Changes and Notes

  • API: a contact must be archived before the delete endpoint will remove it. Deleting an active contact is refused and reports nothing deleted, so archive it first and then delete. Thanks to @Wrongecho.

Bug Fixes

  • Assets: IP address fields demanded all three digits of every octet, so 10.0.0.1 had to be entered as 010.000.000.001. They take natural input again, and a field holding DHCP is now left alone rather than being emptied the moment the modal opens.
  • Networks: in the IP list, an empty hostname or description shows a dash rather than a blank cell, the column headings match the rest of the app, and the table is tighter so more addresses fit on screen.

Developer Updates

  • The IPv4 mask in js/app.js is a regex mask rather than four IMask.MaskedRange blocks. A pattern mask will not advance past a separator until the current block reaches its maxLength, which is what forced the three-digit octets. A regex mask has no per-block completeness rule and tests the whole value on each keystroke, so a partial 10.0. is valid on its own. Octets are still bounded to 0-255 and leading zeros are still accepted, matching what the old jquery.inputmask ip alias allowed. Any value not made purely of digits and dots is skipped, because interface_ip and asset_ip are varchar(200) and also carry the literal DHCP written by the checkbox on those same modals.
  • Dead display variables removed from the asset, expense and product listings. $asset_description_display, $client_name_display and $product_description_display are folded into ?: '-' at the point of assignment.

[26.09]

Upgrading to 26.09

The database update has to be run from the command line one more time. The web interface no longer applies database updates, and the new queued-update path needs a schema change that this release itself adds — so on this one upgrade there is nothing in the browser that can finish the job. After this, it goes back to being a single button.

  1. Back everything up. Maintenance > Backup, or a full VM snapshot.
  2. Update the files from Maintenance > Update as normal. The old page pulls the new files and reports success, then drops you into 26.09 running against the 26.08 database. Errors at that point are expected and stop as soon as step 3 completes.
  3. Run the database update from the command line. Run it as the user that owns the ITFlow files — the script tells you which user if you get it wrong:
sudo -u www-data php /var/www/itflow.example.com/scripts/update_cli.php

It applies every pending version from 2.6.8 to 2.7.8 in order and reports each one as it goes. If a step fails it stops there without advancing the recorded version, so you can fix the cause and run it again. 4. Check it took. Maintenance > Update should show the database up to date and the Queue Update button available.

From here on the command line is optional. Maintenance > Update hands the work to cron, which updates the files and the database in one pass.

Breaking Changes and Notes

  • Updates are no longer applied by your browser. Maintenance > Update either hands the job to cron, which runs it in the background, or you run it from the shell. As a side effect ITFlow now updates cleanly on hardened hosts that disable PHP's shell functions, which it could not do before.
  • Database updates have been removed from the web interface entirely. Cron applies them as part of a queued update, or you run php scripts/update_cli.php yourself.
  • Running php scripts/update_cli.php with no arguments updates the files and then the database in one go, and the file update is forced — any local edits you have made to shipped files are discarded. Use --update_db if you only want the database half; it never touches your files.
  • If you have added your own CSS or JavaScript under agent/custom/, expect it to need updating. The interface has moved to a new major version of the framework it is built on and most class names have changed. Details are in Developer Updates below.
  • Deleting a payment now requires Full access to both Sales and Financial, up from Modify. Refunding requires the same. Deleting a payment removes money from the books, so it is treated as a delete rather than an edit — agents who could remove payments before may no longer be able to.
  • The five built-in ticket statuses now have fixed SLA clock behaviour and can no longer be configured. New and Open always run; On Hold, Resolved and Closed always pause the resolution clock. Only custom statuses keep the SLA Clock dropdown, and the built-ins are marked "(fixed)" in the list.
  • On Hold pauses the resolution clock, not the response clock, and only once the ticket has had its first reply. A ticket parked on hold before anyone replies still breaches its response target — reply first, then hold.
  • Breaches already recorded against on-hold tickets under the old behaviour are left as they are. Change a ticket's priority or SLA to re-stamp it if you want it recalculated.
  • Holidays and closure days are new and start empty. Until you add them, SLA clocks keep running through your closures exactly as they did before.
  • Tickets default to Medium priority when none is given, and ticket replies default to Public.
  • Closed tickets can no longer be deleted. A closed ticket is treated as a permanent record.
  • Send Email on invoices and quotes now opens a contact picker rather than sending straight away. Quick Send, described below, keeps the old one-click behaviour, but any bookmarked direct links to the send action no longer work.

New Features & Updates

  • The interface has had a full visual overhaul, with dark mode carried properly through the calendar, the editor, confirmation dialogs, tables and form controls.
  • Your theme colour and any custom CSS now apply everywhere, including the client portal, the guest pages, login and setup. Those pages previously ignored both and rendered in the default styling.
  • Ticket: canned responses. Add them under Admin > Templates > Canned Responses, scoped to a ticket category or to all categories, and pick one from the reply form. It inserts at the cursor, so picking one into a half-written reply adds to it rather than replacing it.
  • Ticket: SLA response targets now appear on ticket-created emails, rolled up to business days, with a note to call in on High and Urgent.
  • Ticket: the reply card stays out of the way until you click Reply.
  • SLA: holidays and closure days. Define the days you are closed and the SLA clock pauses through them, with a one-click importer for US federal holidays.
  • Networks: a full IP address section under each subnet — address, hostname and description, with search, sort, bulk delete, CSV import and export. Addresses are checked against the subnet they are being added to and duplicates within a subnet are refused, so the same guard applies whether you type one in or import a thousand. Both IPv4 and IPv6, sorted numerically so .9 comes before .10.
  • Designate one client record as your own organization, under Company Details. Clicking your company name at the top of the side navigation now takes you straight to it.
  • Invoices and Quotes: Quick Send is a one-click send to the default contacts, the way Send Email used to work — primary and billing for invoices, primary for quotes. It sits at the top of the actions menu with a lightning bolt and asks for confirmation, and never opens the picker.
  • Invoices and Quotes: Send Email, below it, opens a modal listing every contact on the client with their email addresses and the same defaults pre-checked, for when the invoice needs to go somewhere other than the usual place. Who it went to is recorded in the document history either way.
  • Invoices and Quotes: Mark Sent asks how it was sent — snail mail, an email client, in person and so on — and records the reason in the history.
  • Stripe: refund a card payment from the invoice itself. The refund goes through Stripe, is written to the invoice history, and the payment is removed, replacing the old routine of deleting the payment in ITFlow and refunding it by hand in the Stripe dashboard.
  • Stripe: adding or editing the Stripe provider now checks the secret key against Stripe and tells you if it is wrong, instead of the key looking fine until the first client tries to pay.
  • Stripe: a client paying from a guest invoice link no longer needs a Stripe customer record to exist first — Pay Now sets one up as part of the payment.
  • Client account statements. Send one from the invoice list with a date range and an option to leave out paid invoices, view it on the guest invoice page, and download it as a PDF from the client portal.
  • Files: documents and files are now told apart, with a filter for all documents or just files. Thumbnail view shows every file, previews work for documents, PDFs and text, and your choice of list or thumbnail view survives navigating between folders.
  • Client Portal: the profile page has been rebuilt — department, location, title and phone with inline editing, PIN changes, recent sign-ins and recent activity, with a separate full activity page.
  • Client Portal: empty tables now say what is missing instead of showing a bare header row, and the saved payment method wording is clearer about what saving a card actually does.
  • Guest: the ticket task approval page has been rebuilt. The request being approved leads the page, the ticket body sits underneath as context, the approve action is a proper button rather than a link in a paragraph, and approving or declining lands on a real confirmation screen. Internal routing jargon that meant nothing to a client has been dropped.
  • Guest and client portal pages now have proper footers, and the agent footer stays at the bottom of short pages instead of floating mid-screen.
  • Phone numbers show their country code, and the country is kept when you edit the number. The international phone input is now used on every phone field.
  • Maintenance > Update: Queue Update hands the update to cron, which runs it in its own process and updates the files and the database in one pass. Check now looks for new commits without needing shell access, listing the pending commits with their dates and descriptions.
  • Mail Parser: you are now notified when the parser skips an autogenerated email rather than it disappearing silently.
  • Demo data. Twenty fictional clients with two years of history — a mix of managed and break-fix, tickets, invoices, expenses, assets and contracts, including FOSS products — loaded from Maintenance > Starter Content and tagged so it can be cleared out again.
  • API: reads accept an optional client_id filter on top of the key's own client scoping, so a key that can see everything can ask for one client's records. It can only narrow — a key with no access to that client still gets nothing.
  • API: new endpoints to close a ticket and to delete a client. Closing resolves the ticket first if it has not been resolved already, so the SLA figures come out right. Thanks to @BoredManCodes for the client delete endpoint.
  • Client pages load noticeably faster, running roughly a third fewer queries than before, and several pages that were slow on large installs have been sped up.
  • Lists now say whether nothing matched your filters or there are no records at all, instead of showing an empty table either way.
  • Assets and Contacts: an "Add primary" link where a client has no primary location or contact set.
  • The client header at the top of client pages collapses, and stays collapsed as you move between pages.
  • Page changes fade in rather than flashing, the calendar reserves its height so the page no longer jumps as it loads, and table listings line up consistently across the app.

Security

  • A restricted agent could open an asset, contact or location belonging to a client they have no access to by entering its id in the address bar. Those pages are now gated on the module permission that owns them and checked against the agent's client access rules, like every other record page.
  • Client Portal: changing a password or a PIN now requires the current password. Contacts signing in through SSO are exempt — there is no local password to check and the identity provider has already done it.
  • A user name containing HTML could inject markup into the page through the ticket task approver list. It no longer can.
  • Deleting and refunding payments now require Full access to both Sales and Financial, as described under Breaking Changes.

Bug Fixes

  • Tickets could not be opened for clients marked as a lead.
  • Client Portal: raising a ticket sent no new-ticket notification — the notification errored out instead.
  • The readable password generator called a function that no longer existed, so generating one did nothing.
  • Client pages flashed blank on every load.
  • Invoice emails went to archived contacts, and every copy carried the primary contact's name in the greeting rather than the name of the contact receiving it.
  • Guest: the confirmation dialog on ticket task approvals and on quote accept and decline rendered as plain text at the foot of the page instead of as a dialog, because the guest pages never loaded its stylesheet.
  • Filtering the audit log by date was slow on large installs.
  • API: deleting an asset reported how many interfaces it had removed rather than confirming the asset itself was deleted.
  • API: updating a client failed outright if the request did not include the lead field.
  • API: a request that authenticated correctly but then failed on the query wrote nothing to the app log, leaving nothing to debug from.

Developer Updates

Front-end framework migration:

  • AdminLTE 3.2.0 to 4.9.1 and Bootstrap 4.6.2 to 5.3.8. The layout skeleton is renamed throughout — content-wrapper, main-sidebar and main-header become app-main, app-content, app-sidebar and app-header.
  • AdminLTE 4 dropped a number of v3 classes ITFlow relies on. text-bold, text-sm, btn-default, img-circle, the .alert .icon pairing, the sidebar badge positioning, the small-box watermark icon and all sixteen theme colours are reproduced in css/itflow_custom.css at v3's computed values, driven by a single --itflow-accent variable per theme.
  • Bootstrap 5 split .bg-* from .text-bg-*, so every bg-dark card and modal header needed its text colour restored explicitly.
  • input-group-append and input-group-prepend wrappers are deleted rather than renamed, selects moved from form-control to form-select, data-toggle="buttons" groups became .btn-check, and custom-control, custom-select and custom-file are gone.
  • .input-group > .form-control at specificity (0,2,0) outranks .form-control-color's width:3rem at (0,1,0), which collapsed every colour swatch. Fixed with a matching-specificity .input-group > .form-control-color rule rather than a w-auto override, which only hands the width to the UA default.
  • Stylesheets and scripts were repeatedly found loaded by includes/header.php only. css/itflow_custom.css and libs/sweetalert2/css/sweetalert2.min.css are now loaded by the client portal, guest, login, setup and MFA headers as well — the SweetAlert2 gap is why guest dialogs rendered in normal flow, since all of its positioning lives in the stylesheet while includes/footer.php was loading the JS fine.

jQuery removal:

  • jQuery, jQuery UI, select2, Inputmask, daterangepicker, Moment, Tempus Dominus, toastr, pdfmake, Dropzone and Popper are all gone. Replacements are Tom Select, Flatpickr, IMask, SweetAlert2 and Bootstrap's own toasts, plus js/autocomplete.js for the product and OS autocompletes.
  • New helpers in js/app.js: itflowPostForm() reproduces jQuery's bracketed array encoding that ajax.php parses, itflowBindOnce() replaces the namespaced .off().on() pattern, and initTomSelect / refreshTomSelect / clearTomSelect / setTomSelectValue wrap Tom Select.
  • includes/modal_footer.php re-executes js/app.js on every ajax modal open, so every initialiser needs a re-entry guard or it double-initialises.
  • New itflowReady() restores jQuery's .ready() semantics. Scripts injected into an ajax modal run after DOMContentLoaded has already fired, so a bare listener never runs — this is what broke notification pagination, asset OS autocomplete, the contact authentication toggle, AI ticket summaries and AI document template generation mid-cycle.
  • js/ajax_modal.js re-injects <script> tags explicitly, because innerHTML does not execute them and .append() did.

Schema and queries:

  • history_description widens from varchar(200) to text (2.7.7). A send to several recipients overflows 200 characters and strict mode errors rather than truncating.
  • Indexes added on the client-scoped columns (2.7.5), the per-parent child fetches and the mail queue loop (2.7.6), and logs(log_user_id, log_client_id) for the portal profile and activity pages (2.7.8). No new migrations after 2.7.8 — the Stripe work reuses client_payment_provider.
  • agent/includes/inc_all_client.php rewritten so the sidebar badge counts are one query per table rather than one per number: 43 queries to 27 per client page load, with invoices scanned once instead of eight times.
  • admin/audit_logs.php's date filter was DATE(log_created_at) BETWEEN, which is not sargable and made KEY log_created_at unusable. It is now a half-open range.

API:

  • apiClientScopeSql() now takes an optional caller-supplied client_id and appends it after the scope fragment, so the filter can only narrow what the key is already allowed to see. Reads only — writes take client_id as the target they act on and validate it separately.
  • api/v1/tickets/close.php sets status 4 with ticket_resolved_at first where the ticket has not been resolved, calls syncTicketSlaClock() and setTicketResolutionSlaMet(), then sets status 5 and stamps ticket_closed_by. Every query is scoped by ticket_client_id and ticket_closed_at IS NULL, so a second call is a no-op rather than a re-close.
  • The four *_output.php helpers now logApp() on query failure, which previously returned an error to the caller and left no trace server-side.

Other:

  • The client portal PIN handler checked length before escapeSql(), whose strip_tags() then emptied the value, and stored the blank while flashing success. Length is now checked after sanitising.
  • The client delete modal's script was blocking the parser mid-body, which is why every client page flashed blank; it is deferred now.
  • The gating fix under Security covers agent/asset.php, agent/contact.php, agent/contacts.php and agent/locations.php.
  • The ticket task approver picker built <option> markup by template literal into innerHTML; it uses new Option() now, which assigns text.
  • Stripe refunds live in agent/post/payment.php behind refund_payment_stripe, pulling the payment intent out of the stored reference and calling refunds->create() for the full amount. Partial refunds are not supported.
  • Payment provider post variables moved into a model to stop the add and edit paths drifting apart.
  • New functions/network.php (subnet containment and IP normalisation, v4 and v6) and functions/files.php; functions/sla.php extended for holidays and closure days.
  • Phone input handling moved out of js/app.js into js/phone_inputs.js so the client portal can load it without pulling in everything else.
  • Update path: admin/post/update.php no longer shells out at all. cron/update_check.php does the fetch and stores the result, cron/app_update.php runs scripts/update_cli.php as a child process, and the database phase re-execs against the newly updated code so a migration calling a brand new helper does not hit an undefined function.
  • Removed 29 dead or duplicate files, including 348KB of unused FullCalendar themes, and fixed two broken script paths.
  • The debug page now recommends 512M for PHP's memory limit.
  • README, CONTRIBUTING and SECURITY.md updated.

Library Updates

  • Bump AdminLTE from 3.2.0 to 4.9.1.
  • Bump Bootstrap from 4.6.2 to 5.3.8.
  • Bump intl-tel-input from 25.3.0 to 29.2.3.
  • Bump ImapEngine from 1.25.4 to 1.25.6, along with its dependencies.
  • Added Tom Select, Flatpickr, IMask and SweetAlert2, none of which need jQuery.
  • DataTables now uses its Bootstrap 5 styling build.
  • Removed jQuery, jQuery UI, select2, select2-bootstrap4-theme, Inputmask, daterangepicker, Moment, Tempus Dominus, toastr, pdfmake, Dropzone, Popper and Show-Hide-Passwords-Bootstrap-4.

[26.08.2] Maint Release

Upgrading to 26.08.2

Update the files from Settings > Update as normal. There is no database change in this release, so nothing else is required.

Bug Fixes

  • Calendar: fixed the agent calendar showing no events.
  • Calendar: shared calendar feeds set to publish busy blocks only were publishing full event titles, locations and descriptions to anyone holding the subscription link.
  • Cron: fixed Maintenance > Cron failing to load. Scheduled jobs themselves were unaffected and kept running.
  • Exports: restored the missing columns on the ticket, quote, recurring invoice, software and user exports.
  • API: restored the full record on the credentials list endpoint.
  • Mail: switching an existing install from Standard SMTP/IMAP to Microsoft 365 or Google OAuth no longer leaves the old mail server behind, which stopped sending and ticket email fetching from working. The connection settings for OAuth providers are now fixed by the provider and cleared on save.

[26.08.1] Maint Release

Upgrading to 26.08.1

Update the files from Settings > Update as normal. This release moves the database to 2.6.7 and the web updater completes it for you — the command line step that 26.08 required is not needed again.

Breaking Changes and Notes

  • Client access: agents with restricted client access now see records that have no client assigned. Previously this varied by page — unassigned tickets and projects were visible, unassigned expenses and credentials were not. It is now consistent everywhere.

Bug Fixes

  • Setup: fixed the wizard closing itself after the first user, which left new 26.08 installs stuck in a redirect loop between /setup and /login.php.
  • API: tightened client scoping on the expense read and record update endpoints.
  • Income: revenue rows now respect restricted client access.
  • Client PDF Export: fixed the export producing a CSV file, and each section is now gated on the module that owns it.
  • AI: fixed model creation, per-use-case model selection, configurable temperature, and error reporting.
  • Ticket: system-generated replies no longer record time worked that was never worked.
  • Ticket: fixed an error when scheduling a ticket.
  • Ticket: cancelling a schedule now cancels the calendar event on the recipient's calendar.
  • Ticket: history no longer records a status change when the status did not change.
  • Recurring Ticket: bulk priority changes no longer deny access to agents who are not administrators.
  • Contact: deleting a contact now removes the linked portal user, and anonymizing now redacts the phone number.
  • Calendar: fixed event deletion.

New Features & Updates

  • Performance: queries now select only the columns they use instead of SELECT *, cutting memory use and query time across the app and especially in the crons.
  • Performance: removed client joins that were only there for scoping — side nav badge counts are significantly faster.
  • Client scoping: added a clientScopeSql() helper so list queries scope on the owning column instead of a joined clients.client_id.
  • Contributing: documented the column-selection and client-scoping conventions.

[26.08]

Upgrading to 26.08

Read this before you start. Done out of order this update will break your instance. The database structure changes, every API key is deleted, and the whole cron setup is replaced.

  1. Back everything up. Take a full VM backup or snapshot before you start.
  2. Remove every ITFlow line from your crontab (or delete /etc/cron.d/itflow). The old per-minute jobs must not keep firing against a half-updated install. You put the new one in at step 5.
  3. Update the files with the normal web updater, from Settings > Update. It will pull the new files, report the update as successful, and then error out as it drops you back into the app. That is normal — the new code is now running against the old database.
  4. Run the database update from the command line. Just this once it cannot be done from the web interface. Run it as the user that owns the ITFlow files — the script tells you which user if you get it wrong:
sudo -u www-data php /path/to/itflow/scripts/update_cli.php --update_db

It applies every pending version in order and reports each one as it goes. On an install with a lot of ticket history it can take a minute or more, so let it finish. If a step fails it stops there without advancing the recorded version, so you can fix the problem and run it again. The 500s stop as soon as it completes.

  1. Add the new cron entry. One line runs everything now, and the schedules are managed in ITFlow under Settings > Cron:
* * * * * www-data php /path/to/itflow/cron/cron.php >/dev/null

Drop the www-data column if this goes in a user crontab rather than /etc/cron.d.

  1. Recreate your API keys. Every existing key is deleted by this update. Issue new ones and update anything that talks to the ITFlow API.
  2. Check it took. Open Settings > Cron — the green "Cron last checked in" banner should appear within a couple of minutes and every job should pick up a schedule. Only this release needs the command line for the database update. Normal updates go back to running from Settings > Update as usual.

Breaking Changes and Notes

  • Cron: the crontab collapses to a single entry. cron/cron.php is now a dispatcher that runs every minute and works out which jobs in cron/ are due, and the old nightly work has moved to cron/nightly_tasks.php which it runs at 03:00.
  • Cron: an existing crontab keeps working — the per-minute scripts still run and still lock correctly, and a single daily cron.php entry still runs the daily jobs — but any job added in this or a future release only runs once the dispatcher is scheduled.
  • Cron: ticket SLAs need no entry of their own. cron/ticket_sla.php is in the dispatcher's job list and runs every minute once the new entry is in place. Without it SLA targets are still worked out and displayed, but warnings and breaches never fire.
  • Backups: write down your backup encryption key. It is generated on first use and stored in config.php, never in the database, and without it a backup cannot be restored.
  • API: every existing key is deleted by this update and must be recreated. Keys are now owned by a user and inherit that user's role, module and client permissions instead of carrying their own client scope, and existing keys cannot be safely mapped to a user.
  • API: credential decrypt passwords are now read from the request body instead of the query string. Any caller passing that value in the URL needs updating.
  • Client access permissions now support deny rules as well as allow, and the permissions UI will not load until the database update has run.
  • Business hours are new and default to Monday to Friday, 09:00 to 17:00 in your configured timezone. SLA targets are measured against them, so set them before assigning SLAs.
  • The plugins directory is now libs. Anything pointing at plugins/ directly — custom scripts, reverse proxy rules, web server config — needs updating.
  • Several pages dropped the _details suffix and moved to consistent singular and plural filenames, so old bookmarks and external links will 404.
  • Credential passwords moved from varbinary to varchar(500) and now have a length guard. Existing credentials are migrated by the database update.

New Features & Updates

  • Backups are now encrypted, catalogued, schedulable and restorable from the command line. Three types — Full (database and uploads), Database Only, and Master Key — and every archive is an AES-256 encrypted zip. Open them with 7-Zip, WinZip, PeaZip or Keka; unzip, Windows Explorer and the macOS Archive Utility do not support AES.
  • Backups: one encryption key per install, generated on first use and stored in config.php, never in the database and never in the file name. It is shown in Maintenance > Backup.
  • Backups are built by cron rather than by your browser. The button queues the work and the dispatcher picks it up within the minute, then notifies you — a dump of a real install takes longer than a web request is allowed to live, which is why the old Download Backup button timed out on large instances.
  • Backups: scheduled backups are a new backup cron job, off by default, turned on in Maintenance > Cron. Retention by age and by count runs in the nightly job and never deletes the newest backup.
  • Backups are stored outside the web-served path under uploads/backups/ with a deny-all rule and downloaded through an admin-only handler. Set $config_backup_path in config.php to keep them off the web root entirely.
  • Backups: restore from the command line with php scripts/restore_cli.php --file=/path/to/backup.zip. This is the only restore path with no size limit — the setup wizard's restore is capped by PHP's upload limits and a full backup is usually larger. --inspect checks an archive without changing anything.
  • Backups: restores validate before they destroy. The key is checked and the archive unpacked before any table is dropped, and the current database is dumped first and put back automatically if the import fails.
  • Cron: one entry instead of five, and a page to manage it. Jobs are tracked in a new cron_jobs table, so a job whose slot was missed runs at the next opportunity rather than waiting a day, and each job locks for its own run so a slow mailbox or a long nightly pass no longer holds anything else up.
  • Cron: new Settings > Cron page listing every job with its schedule, last run, duration, outcome and next due time. Jobs can be disabled, rescheduled, or run on demand — Run Now hands the job to the next dispatch so it starts within a minute and still runs on the command line. The last error is kept until dismissed rather than vanishing behind the next success, and the page says plainly when the crontab entry itself is missing.
  • Cron: the nightly run is safe to repeat. Late fees, overdue invoice reminders and autopay retries now apply at most once per invoice per day, so a Run Now after the scheduled pass no longer stacks fees or re-emails clients. Nightly Tasks only accepts the daily schedule.
  • Cron: the master enable switch moved out of Notifications and into the Cron settings where it belongs, and the unused overdue invoice setting has been removed.
  • Ticket: major UI overhaul of the ticket list, the kanban board and the ticket detail page.
  • Ticket: redesigned the task bar on ticket details and removed the redundant task count.
  • Ticket SLAs, optional throughout. An SLA sets a response target and an optional resolution target, assigned per client and priority with a global default and an explicit "no SLA" override. Targets are measured against your business hours. Tickets show time remaining and turn yellow at a configurable warning threshold and red on breach, on both the ticket list and the kanban board, and can be filtered by SLA state. Nominated statuses pause the resolution clock for "waiting on customer", preserving the remaining budget. Two new reports, SLA Summary and SLA by Client. With no assignments defined nothing behaves any differently.
  • Ticket: added an Urgent priority.
  • Ticket: agents can attach files to tickets from inside the app, both when raising a ticket and on a reply, and attachments are emailed to the contact through the mail queue. A 10 MB ceiling applies per message; anything that does not fit stays on the ticket to download.
  • Ticket: tasks can be added and edited inline in the add ticket and add recurring ticket modals.
  • Ticket: the older add ticket modal has been retired, there is one add ticket modal now.
  • Ticket: watchers and attachments have moved into the assignment section of the add ticket modal.
  • Ticket: recurring tickets can be assigned a ticket template. Picking one fills in the subject and details and stamps the template's task list onto every ticket the schedule raises, from the nightly run and a forced run alike. The recurring ticket list shows which schedules carry a template and how many tasks it adds.
  • Ticket: recurring tickets now own their task list. The template fills it in when picked but it can then be edited per schedule, and it is those edits the run reads. Existing schedules are backfilled from their template by the database update.
  • API: added ticket reply endpoints for creating and reading replies.
  • API: added an invoice_items endpoint for adding line items to an invoice.
  • Calendar: calendars can be published as a read-only iCalendar (ICS) subscription feed and read by Google Calendar, Nextcloud, Apple Calendar, Thunderbird or anything else that takes a feed URL. The link carries a secret key and needs no login, can be regenerated or revoked at any time, and a busy only option publishes time blocks without titles, descriptions or locations. Refresh timing belongs to the subscribing client — Google refreshes on its own schedule and cannot be forced, and Nextcloud defaults to weekly and refuses feed URLs resolving to private IPs.
  • Calendar: events can be marked all day, and the date and time are now separate fields. Previously all day was inferred from a midnight start, which made a genuine midnight appointment indistinguishable from an all-day event. Existing events are backfilled by the database update using the old rule, so nothing changes appearance.
  • Calendar: repeating events now work. The Repeat field was present but disabled and the stored value was never drawn. It is now selectable daily, weekly, monthly or yearly, and monthly and yearly series skip dates that do not exist in a period rather than sliding into the next month. Recurrence is series-wide — editing any occurrence edits the whole series, and individual occurrences cannot yet be moved or cancelled. Repeating events are marked with an icon and a hover note, and the delete action reads Delete series and asks for confirmation.
  • Calendar: clicking empty space creates an event there. Clicking a day or a time slot, or dragging across several, opens the New Event modal with the start and end already filled in and the All day switch set to match. A range dragged out or lengthened by hand is no longer overwritten by the end-time-follows-start behaviour.
  • Exports: every export modal now has a Filter tab and a Selectable Columns tab with sensible defaults, and can export to PDF as well as CSV.
  • Combined Payments and Revenues into a single Income page with CSV export. Revenue not tied to an invoice is still added there and payments are still added from invoices. The standalone Payments and Revenues pages are gone.
  • Income: added a Category column and filter, carried through to the exports.
  • Income: added bulk actions for account, payment method and category.
  • New Transactions page — a per-account ledger of transfers, revenues, payments and expenses with filtering by type, category, client, payment method, amount range and date, a running balance, summary cards, account balances in the account picker, and CSV export.
  • Products: added a basic product import via CSV.
  • Added user based RBAC for API keys, so a key runs as a user and inherits that user's permissions.
  • Added deny rules to client access permissions, so access can be granted broadly and revoked for specific clients.
  • New secure file download handler for files and ticket attachments, with client and contact permission isolation on the client portal.
  • Invoices: clicking the Paid or Partial status badge opens a read-only breakdown of the payments recorded against that invoice.
  • Assets: multiple notes per asset, same as contact notes, with categorized note types (Maintenance, Repair, Configuration, Upgrade, Inspection, Note).
  • Reworked the Maintenance > Update page, and fixed the branch handling on it.
  • Categories and tag types moved from a top button nav to a left side nav.
  • Dashboard: added expiring asset warranties and licenses, along with an "Expiring in" filter for assets, licenses, domains and certificates.
  • Added bulk and single refresh actions for domains and certificates.
  • Stripe gateway fees now come from the actual Stripe balance transaction rather than a static percentage and flat fee configured in ITFlow, with a nightly pass to backfill fees that were not available at payment time. The static fee fields are gone from payment provider settings.
  • Database updates are now split into per-version files under admin/database_updates/, the latest version is derived from the directory listing, and one run applies everything pending. Migration history before 2.0.0 has been pruned.
  • Reorganized the main, client, admin and reports side navigation menus.
  • Mail settings tabs are now URL addressable and stay on the active tab after saving.
  • Removed the legacy vendor contacts feature.
  • dig and whois are no longer required, domain lookups use native DNS and RDAP.
  • Tightened the .htaccess rules, and added one for uploads/tmp.
  • Bumped the minimum supported PHP version.

Security

  • Rate limited 2FA code attempts and narrowed the TOTP acceptance window.
  • Rotate the session ID on login to prevent session fixation.
  • Stopped parallel login attempts from bypassing the login rate limits.
  • Tightened validation on the recurring invoice frequency used by the billing run.
  • Admin UI modals are now gated to admins. Previously any logged-in user could open them directly and read stored payment provider and AI provider API keys — rotate those keys when you update, as there is no record of who may have viewed them.
  • Global search returned credentials to users without credential module access. It is now gated like every other credential surface.
  • Credential password reveals are now written to the audit log, on both the reveal endpoint and the TOTP code.
  • Swept module and client permission enforcement across modals and ajax endpoints to match the post handlers, closing a number of cases where a user restricted to certain clients could read another client's records by ID.
  • Products: the CSV export now requires sales module read access.
  • Neutralized CSV formula injection in generated exports.
  • Fixed weak random number generation in TOTP secret generation.
  • Shared item views are now claimed atomically so the view limit cannot be exceeded by simultaneous requests, and guest audit IPs are logged.
  • Hardened CSRF handling and session cookies, and set SameSite=Lax on the session cookie.
  • Hardened file upload handling to use random storage names.
  • Client Portal: contacts can no longer edit their own contact record.
  • The setup wizard's restore step is now closed on any install that has users, whatever config.php says. Previously, if $config_enable_setup was missing from config.php — a state an install can be left in when setup does not reach its final step — the restore step stayed reachable. Restoring over a live install is now done from the command line.
  • A restore no longer takes ITFlow's uploads/.htaccess from the archive. The guards are rewritten afterwards regardless of what the backup contained, so restoring a backup taken before those guards existed no longer removes them.
  • Tightened the directory guards under uploads.

Bug Fixes

  • Deleting a payment now correctly recalculates and sets the invoice status.
  • Fixed contact notes, and several broken modal links in contacts, assets and file linking.
  • Client Portal: fixed adding saved payment methods and cards following a Stripe API change.
  • Fixed sending invoices and quotes over OAUTH2, which was reading the SMTP host instead of the SMTP provider — the host is not filled in when OAUTH2 is selected.
  • Mail Parser: correctly work out whether the ITFlow folder belongs under the INBOX namespace or the root directory, fixing folder creation on cPanel Dovecot Maildir++ setups.
  • Fixed possible duplicate emails caused by a race condition in the mail queue.
  • Added a shared lock guard across every cron entry point, scoped per script and per install, replacing the mail queue's non-atomic lock file. Rows left in a sending state by a run that died are now recovered.
  • Prevented duplicate Stripe payment bookings and overlapping cron runs.
  • Mail bodies are cleared after successful delivery.
  • Reworked getFieldById to stop escaping its return value, and reworked every caller to escape at the point of use — it was causing double escaping in a lot of places.
  • Added missing maxlength attributes to forms backed by length-limited columns, so an overlong value no longer throws a 500.
  • Fixed undefined variables in the audit log and flash messages for expenses, assets, contacts and several other handlers, which were logging blanks in place of the record name.
  • Fixed the spelling of the expense description in audit logging.
  • Fixed autofill on invoices, quotes and recurring invoices, where the tax field was not updating and a dash was being placed in front of the product.
  • Fixed gaps in ticket history.
  • Recurring Expense: fixed editing not keeping the client.
  • Fixed client name truncation in the side navigation being applied after escaping.
  • Side navigation counts are only shown to users with permission to see them.
  • Invoice statistics now only reflect clients the user has permission to see.
  • The agent category handler no longer drops the category description.
  • Expenses: allowed negative amounts, and the current date is now prefilled.
  • Certificates can now be searched by description.
  • Fixed cents calculation rounding.
  • Fixed guest view credential TOTP display, and removed the legacy OTP code path.
  • Gated the SLA option in ticket details, which was gated everywhere else.
  • Deleting a ticket template task or a payment provider recorded the wrong name in the audit log and the confirmation message, reading an unrelated record's id in place of the name.
  • Bulk-creating tickets from a template against multiple assets only added the template's tasks to the first ticket, and dropped each task's completion estimate.
  • Deleting a ticket template now unlinks it from any recurring ticket that referenced it, instead of leaving the schedule pointing at a template that no longer exists.
  • Fixed the asset section in the recurring ticket modal when opened outside a client, and project selection when raising a ticket.
  • Tickets raised by the nightly recurring schedule were created without a guest URL key, so the "View ticket" link in reply and task approval emails could not be opened. Cron now generates a key like every other path that raises a ticket, and existing tickets missing one are backfilled by the database update.
  • Calendar: fixed the last day of a multi-day all-day event not being drawn or published to subscribed feeds. event_end holds the last day the event covers, which is what the event modal asks for, but FullCalendar and iCalendar both treat an all-day end as exclusive.
  • Fixed confirm-link doing nothing inside an ajax modal, where the handler was only bound to links present at page load.

Developer Updates

  • Line endings normalized to LF across the codebase, with .gitattributes and .editorconfig added. Vendored code under libs/ is marked so it stays byte identical to upstream.
  • Converted to the short echo tag <?= throughout.
  • functions.php is now a loader, with helpers split into topical files under functions/. Unused legacy functions removed, including an unused database wrapper layer.
  • PHP functions renamed to camelCase throughout, including nullable_htmlentities to escapeHtml, sanitizeInput to escapeSql, logAction to logAudit, and key32gen to generateTotpSecret.
  • Seed data is now shared between the setup wizard and setup_cli.php from one file, so a headless install gets the same starter content as a browser install.
  • CONTRIBUTING.md added and expanded, covering the security rules, style conventions, database column prefix convention and migration pairing.

Library Updates

  • Bump TinyMCE from 8.6.0 to 8.8.2.
  • Bump DataTables from 2.3.7 to 3.0.1.
  • Bump FullCalendar from 7.0.0 to 7.0.2.
  • Bump ImapEngine from 1.25.0 to 1.25.4, along with its dependencies — notably zbateson/mail-mime-parser 3.0.6 to 4.0.3 and guzzlehttp/psr7 2.12.3 to 3.0.0.

[26.07.1]

Bug fixes

  • Fixed broken M365 and Google Workspaces OAUTH2 in Mail Settings.
  • Security Fix.

[26.07]

Major Changes

  • Migrated from Webklex php Library to IMAPEngine.
  • Major Rewrite of the mail settings page to better support Microsoft 365 and Google OAUTH2.

Bug fixes

  • Many Security Fixes.
  • Microsoft 365 and Google can now specify Licensed User.
  • Clients - Only show 3 Tags per line instead of streching all the way across.
  • Login: Make Email field email type instead of text.
  • Fixed Invoice Late Overdue notices now shows correct balance when late fees are attached and if partial invoice was paid.
  • User Preferences Avatar: Fix creating user upload directory if doesn't exist, and remove Avatar now properly deletes the old avatar image.
  • Do not send an in-app alert on successful cron execution, keep it in logging only.
  • Fix Issues with Clients Signing in via Entra in the client Portal, items were broken because CSRF token was not being generated.

New Features & Updates

  • Clients: Removed Entity Stat Counter was slow and unused.
  • Added Monospace text in areas where it deserves it like, IPs, Amounts Costs in Tabular data forms etc.
  • Cicking into the client section no longer turns the main nav and text gray, it keeps the configured theme across the app. It was implmented long ago to differentiate between the client section and the main section of ITFLow but didn't work very well.
  • Bump Sortablejs from 1.15.6 to 1.15.7.
  • Bump TinyMCE from 8.5.0 to 8.6.0.
  • Bump Fullcalendar from 6.1.20 to 7.0.0 amd convert existing code to comply with 7.0, also make calendar more printable.
  • Bump PHPMailer from 7.0.2 to 7.1.1.

[26.05.1] Stable Release

  • Security Fixes.

[26.05] Stable Release

Bug Fixes

  • Stripe Payment: Fix adding saved cards on client portal.
  • Various client and module enforments fixes.
  • Projects: Fix slow load by using an optimized query to count tickets and tasks.
  • Show correct currency for the account balance when adding payment to invoice.
  • Expire all Password reset tokens nightly with cron.
  • Shared Items via secure link: Do not delete shared items that have not been viewed before cron runs.
  • Client: Fix Client Abbreviation being converted to an int on edit.

New Features & Updates

  • Bump TinyMCE from 8.4.0 to 8.5.0.
  • Bump TCPDF from 6.11.2 to 6.11.3.
  • DeBump stripe-php from 20.0.0 to 19.4.1.

[26.04] Stable Release

Bug Fixes

  • Racks: Fix Device Removal.
  • Table Lists: replace class table-responsive-sm with just table-reponsive was causing ui issues with certain screen sizes.
  • Client: Fix Edit erroring on certain characters.
  • Category: Fix Add/Edit due to missing CSRF fields.
  • Category: Fix Restore function and Icon and text color.
  • Invoice: Do not apply late fee on first overdue reminder (1 day).
  • Ticket: Fix issue with contact not being added with Add contact modal v1.
  • Quote: Fix Copy was missing client.
  • API: Don't set client ID from POST - this is properly done via require_post_method instead only if it's an all-clients key.
  • API: Prevent error 500s when existing data can't be cleanly re-inserted to database.
  • API: Add more helpful errors.
  • API: Fix asset read uri_2 field.
  • API: Various other field fixes.

New Features & Updates

  • Categories: Add Description Field.
  • Categories: Add DB Field for order.
  • Categories: Move Asset Status and Network Interface Type to categories so custom ones can be created and edited.
  • Categories: Moved note type, software type, rack type to be creatable/editable Categories with common defaults and descriptions
  • Files: Allow .swb file for MikroTik Backup Files.
  • Software: Added additonal License Types including Perpetual, Site, etc.
  • API: Invoice Items: Add read endpoint.
  • Networks: Added Import.
  • Bump TinyMCE from 8.3.2 to 8.4.0.
  • Bump stripe-php from 19.4.1 to 20.0.0.

[26.03] Stable Release

Bug Fixes

  • Ticket Templates: Fix Task Sortinhahahg.
  • Ticket: Lower autoclose setting minimum value from 48 to 24 Hours.
  • Ticket: Fix Task Approval.
  • Recurring Ticket: add empty value placeholder for Ticket Frequency.
  • Documents/Files: Fix redirect after File Upload to redirect to files instead of the non existent documents.
  • Setup: Fix base url tacking on /setup when not installing via script.

New Features & Updates

  • Clients: Net Terms: Added common 45 and 15 Days, removed 14 Days not as common.
  • Clients: Bulk Action Set Net Terms Added.
  • Clients: Swapped location and contact column, add PopOver with Details such as created, abbreviation, DB ID instead of taking up space underneath client, rounded tag pills and increased padding, removed info badges and added one info badge that displays a popover with details.
  • Clients: Added New Ticket to Client Top Header Menu.
  • Clients: Client Overview: UI Sprucing.
  • Invoice: Send reminder 1 day after due date.
  • Invoices/Quotes/Recurring Invoices: Split Items tables into their own POST logic and Modal UIs and tables (quote_items, invoice_items, recurring_items).
  • Tickets: New Ticket Parsing - Anyone CC'ed onto the original email that created the ticket is added as a ticket watcher.
  • Ticket/Quotes: Quotes can now be associated with a ticket.
  • Networks: Removed Subnet Mask Field, Use CIDR instead.
  • Networks: Rearranged fields, Updated placeholders, Add/Edit/list for better flow.
  • Networks: Renamed DHCP to IP Range to allow for you use of both DHCP and or Usable IPs.
  • Assets: Rearranged fields, Updated placeholders, Add/Edit/list for better flow.
  • Assets: Added IPv6 if available under IP, Make and Model are now one line with Serial Underneath. Added OS under Type. use pill for status.
  • Calendar: Event thats are cut off can now be viewed as a tooltip on hover.
  • Calendar: Renamed System Calendars to built-in calendars and added the names and color dot for reference.
  • Calendar: You can now delete a custom calendar.
  • Report: Client Ticket Time Detail Audit: Selectable Billing Time Increment, will later be avauilable globally.
  • Roles/Permissions: Now complete and is out of beta all permission roles are strictly enforced, except for in Trips and Calendar, new enforce modules will be added for these at a later date.
  • Project Templates: Ticket Template order can now be dragged and dropped.
  • Global: Introduced new checkbox class to all Checkbox select columns to keep consistency and reduce space and enhance ui.
  • Global: CSRF Checks everywhere instead of just deletion calls.
  • Global: Renamed the rest of the unarchive post and label calls to restore.
  • Files: Allow upload of .unifi extension.
  • Bump Libraries:
    • stripe-php from 19.0.0 to 19.4.1.
    • fullcalendar from 6.1.19 to 6.1.20
    • TCPDF from 6.10.1 to 6.11.2

[26.02.1] Maint Release

Bug Fixes

  • Credentials: Fix Password Generator.
  • Calendar: Restrict Events for client restricted agents.
  • Ticket Merge: Fix.
  • Asset Transfer: Fix.
  • Ticket Listing: Restrict Tickets presented in ticket list view from client restricted agents.
  • Ticket Details: Deny access to client restricted agents to view tickets without client_id in uri.
  • Tickets: Allow agents with restricted client access to view and edit tickets without a client.
  • Ticket Change client: Limit selection for agents with restricted client access.
  • Ticket Details: Don't display updated at when null.

New Features & Updates

  • Report: Added Client Detail Auditing.
  • API: Added Endpoint to retrieve time worked by agent.
  • ajax-modal: Revert to previous JS implementation before 26.02 release.
  • Ticket: Move Subject from Ticket main ticket header to ticket details card header.

[26.02] Stable Release

Bug Fixes

  • Mail Parser - Do not automatically send new ticket notifications to noreply/donotreply addresses.
  • Ticket: removed newline \n on Parsed emails.
  • Show Trips for everyone if accounting module is enabled.
  • Fix Invoice Exporting.
  • Fix Billable Column not sorting correctly in tickets.
  • Fix Login flow where user agent and client user exists and agent has MFA but will not let them continue.
  • Fix passing missing user_id var in client portal.
  • Fix Ticket Templates not auto filling when selected.
  • Fix Invoices not being sent to all billings contacts when manaully sent.
  • Fix Documents and Files not able to be bulk deleted.
  • Fix Role Archiving, can be archived as long as no users are assigned to the role.
  • Fix showing Powered By ITFlow visibility on the login screen when Whitelabel is enabled.
  • Missing username in audit log on successful login due to missing passed user_id to logging.
  • API: Fix updating all documents instead of the intended document.
  • Documents: Fix Document created at not showing the correct creation date of the master document.
  • Ticket: Fixed Using edit ticket modal agent was not able to be set.
  • Always check if a user is archived and or disabled instead of just during login.
  • Report: Fix Collected tax report not totalling all tax categories.

New Features & Updates

  • Task Approval System for ticket tasks: Once an approval is requested, the task cannot be marked as complete until approved. Internal Approvals Any other technician, or Specific technician, Client Approvals Anyone (usually the requestor) Tech contacts Billing contacts.
  • Printable Invoice Packing Slips now available.
  • Drastic Performance Bump: Up to 50% faster queries accross the board and reduced server memory usage by 40% by switching Database Query method from mysqli_fetch_array to mysqli_fetch_assoc.
  • Added Connect to Microsoft 365 Button to mail settings.
  • OAUTH2 support for Microsoft 365 and Google Workspaces is now considered stable and working.
  • Favorites: Assets and Credentials now can be favorited singly or by Bulk action. Favorited items appear in the client overview now.
  • Files/Documents: Collapsable folders feature, collapsed by default with a button to expand all.
  • URL Keys and such are now set to a more manageable 32 Characters by default.
  • Various UI/UX Updates throughout the app, with focus oin ticket details, contact details modal etc.
  • Added Show Archived files and documents to the files section.
  • Added Bulk Archive and restore options to files and documents.
  • Rewrite of the Kanban Ticket view to match our procedural style of coding.
  • All options are available in TinyMCE now in Mobile mode.
  • Agent names appear now in Invoice History section.
  • Mail Parser: Support flowed text.
  • Assets: Keep Purchase reference when copying.
  • Assets: Add basic tracking history: Archiving, restoring, name changes, transferimg to new clients.
  • Mail Parser: NDR Parsing.
  • Allow SVG files in mail attachments.
  • Tickets: Use a more friendly time worked instead of 02:41:00 translates to 2h 41m.
  • Update wording on ticket to invoice item details.
  • Merge Tickets: Now wth a ticket merge dropdown list of tickets instead of a text field.
  • Role Permissions can now be set during role creation, update Permission UI to use radio buttons instead of select boxes.
  • Bump TinyMCE 8.2.2 to 8.3.2.
  • Bump PHPMailer from 7.0.1 to 7.0.2.
  • Bump Datatables from 2.3.4 to 2.3.7.

[25.12.1] Maint Release

Major Changes

  • Unified the Client/Agent Login and process (Note only Client Users can Reset passwords from the login page, does not apply to agent users).

Bug Fixes

  • Fix Payment Provider not adding an account.
  • Fix New ticket button in contact details in the related tickets section.

New Features & Updates

  • You can now Set Payment Provider income/expense account, expense vendor and expense category upond creation or editing.
  • Moved Saved Payment Provider Methods away from admin side nav to the count link within Payment Providers page.
  • Moved AI Models from the admin side nav to the model count link within AI Providers.
  • Add Favicon Reset.

[25.12] Stable Release

Breaking Changes

  • For Existing installs: php-xml extension needs to be installed for document creation and editing, new install script does this for you as of Dec 6th 2025. To install php-xml: sudo apt install php-xml

Major Changes

  • Consolidated "Files" and "Documents" into a single section called Files.

Bug Fixes

  • Resolved issue with updating asset notes in asset details.
  • Fixed problem with bulk ticket merging.
  • Corrected issue where decimal inputs (e.g., price, cost) weren’t displaying on iPhones in certain forms.
  • Added CSV escaping to the sample export data in areas where a sample CSV template is provided.
  • Fix a race condition where dupe tickets, invoices, recurring invoices, recurring tickets, quotes will be created using the same number if created in parallel espcecially when using the API.

New Features & Updates

  • Introduced automatic subject-based ticket merging/reply detection. Now, if an email comes from a known contact or domain and the subject matches 95% of a ticket opened in the last 7 days, it will be merged automatically.
  • Added cleanInput function to sanitize data before inserting it into the database when using MySQLi prepared statements.
  • Migrated client post functionality to use MySQLi prepared statements.
  • Updated payment method post functionality to use MySQLi prepared statements.
  • Implemented saveBase64Images() to convert base64-encoded <img> tags into actual image files stored under /uploads/<module>/<id>/ with secure filenames. Added wrapper functions, and updated document creation to use processed image paths.
  • For new documents and document templates, images are now stored in /uploads/documents/$document_id instead of being stored as base64 in the database, using the saveBase64Images() function.
  • UI/UX improvements made to the document details page.
  • Removed sidebar quick-add options.
  • Created new folders in the uploads directory: documents, document_templates, and recurring_tickets.
  • Reworked the bulk action function to pass the name arrays, instead of a generic selected_ids array. This allows multiple bulk name arrays to be passed at once, currently used for the new file-document merge.
  • Big task: Converted the remaining modals to use the new ajax-modal system, enabling more flexible flow expansion going forward.
  • Mail queue: Added a --no-mx-validation flag to bypass recipient domain MX validation.
  • Bump PHPMailer from 7.0.0 to 7.0.1.
  • Bump stripe-php from 18.1.0 to 19.0.0.
  • Bump TCPDF from 6.10.0 to 6.10.1.
  • Bump TinyMCE from 8.2.0 to 8.2.2.

[25.11.1] Maint Release

Fixes

  • Fix broken edit Payment Method.
  • Fix unable to delete Vendor Template.
  • Fix Mail Queue link in flash alert for testing email and sending a quote.
  • Add Show Category Type select if not defined.
  • Add Show Product Type select if not defined.
  • Fix add ticket watcher.
  • Fix if Client isn't assigned to a ticket dont show client view.
  • Fix missing session client id check when paying an invoice from client portal.
  • Update Composer Webklex-IMAP library dependency symfony/http-foundation from 7.3.3 to 7.3.7 to fix security related issues.
  • Add back delete Payment provider the database will handle cascade deletes to saved cards, recurring payments and client payment provider reference.
  • Don't show Client Tickets Breadcrumb if no client is assigned to a ticket.
  • Don't Show Contact or Assignment Tab in edit ticket if no Client is Assigned.
  • Don't Show add contact, asset, vendor, watcher if not client is assigned to a ticket.
  • Don't Show Public Comment & Email if contact email doesn't exist.
  • Fixed IMAP Test whicn now uses RAW TCP Connection instead of the depracated php-imap extension.
  • Fix Broken Link in Ticket Updates via Client Portal to agent.

Added / Changed

  • [Feature] Added Asset Tags.
  • [Feature] Added Quick Add Links to most side bar navs example quickly add a client from sidebar.
  • Migrate ticket template add to ajax modal.
  • Add TOTP secret to Client Export PDF in Credential section.
  • Add UserID on hover in users listing.
  • Merge ticket now redirects to the new ticket details page.
  • [Feature] Add Pay via saved card under invoice Listings.
  • Ticket Related Side Items UI Cleanup to use btn-tool class.

[25.11] Stable

Deprecation Notice:

  • Outdated CRON Scripts: The following scripts are removed.

    • /scripts/cron_mail_queue.php
    • /scripts/cron_ticket_email_parser.php
    • /scripts/cron.php
    • /scripts/cron_domain_refresher.php
    • /scripts/cron_certificate_refresher.php

    Action Required: Transition to the new versions:

    • /cron/mail_queue.php
    • /cron/ticket_email_parser.php
    • /cron/cron.php
    • /cron/domain_refresher.php
    • /cron/certificate_refresher.php
  • PHP Extensions php-imap and php-mime-mail-parser are no longer required.


Fixes

  • Ticket Listing: Resolved issue where the “Check All” checkbox was visible even when ticket status wasn’t set. Now hidden for closed tickets only.
  • Timer Auto-Start: Show H/M/S placeholders when timer auto-start is disabled.
  • Ticket Guest URL: Fixed email not including the ticket guest URL key.
  • EML Generation: Resolved issue with EML not being generated in the new ticket parser.
  • New Ticket Mail Notification: Included message when notifying the tech of a reply in the new ticket mail parser.
  • Advanced Filter Collapse: Added clause to prevent collapse of advanced filters when the “from” date is set to the default (1970-01-01).
  • Recurring Invoice: Fixed issue where email was marked as sent but not actually sent when forcing a recurring invoice to an invoice.
  • CSRF Token: Fixed issue with deleting recurring ticket from asset details page due to missing CSRF check token.
  • Vendor Website Link: Fixed missing https:// prefix in the vendor website link on the vendor details modal.
  • Agent Select Box: Resolved issue where agents sometimes didn’t appear in the agent select boxes.
  • TinyMCE: Fixed TinyMCE editor issue on Bulk Create Ticket in Assets.
  • Ticket Timer: Fixed ticket timer initialization after reload and when the tab is put to sleep (background tab).
  • Client Deletion: Fixed issue with client deletion.
  • Domain Records: Added flag for missing SOA record when adding a domain (prevents subdomain creation).
  • Domain Fetching: Quits domain record fetching if no SOA record exists (prevents subdomains).
  • Domain Expiry: Only show time to expiry when there’s an expiry date set; otherwise, display a dash.
  • Certificates: Improved handling of empty date in the agent UI.
  • Certificates API: Fixed bug with missing JS to fetch certificate details.
  • API Updates:
    • Clients API: Added support for archiving/un-archiving clients, updating client data, and abbreviation support.
    • Contacts API: Added archiving/un-archiving and restriction to only allow one primary contact per client.
    • Mail Queue: Added recipient domain MX validation before sending emails.

Added / Changed

  • Backup / Restore: Improved backup and restore by streaming data to disk (to prevent memory issues), setting unlimited timeouts, checking for bad backup contents, and using PHP for DB import instead of shell exec. Added .htaccess to prevent PHP execution in /uploads/ directory.
  • Ajax Modals: Migrated all Add and Bulk modals to the new Ajax Modal for improved performance.
  • Recurring Ticket Sorting: Default sorting of recurring tickets by RunDate instead of subject.
  • Recurring Ticket Enhancements:
    • Added Billable column.
    • Added bulk actions for setting priority, agent, billable status, and next run dates.
    • Added filters for category, assigned agent, and billable status.
    • Added new frequency options: 3-day and biweekly.
  • Asset Select: Updated asset select dropdown to separate asset types using opt groups (planned for wider use).
  • Expiring Domains & Certificates: Added "30 Day" warning for expiring domains and certificates in the dashboard.
  • Ticket Search: Allowed search using both ticket prefix and number.
  • Recurring Invoice: Cancel recurring invoices when the associated client is archived.
  • Credentials Import/Export: Now includes TOTP secrets when importing/exporting credentials.
  • Asset Notes Import: Allowed importing of asset notes.
  • Ticket View: Added a "View HTML Code" button in all ticket views for TinyMCE.
  • Date Range Picker: Updated all date filters to use the improved DateRangePicker JS.
  • Bulk Ticket Creation: Added bulk ticket creation for clients.
  • Sidebar Updates: Updated all sidebars to use absolute paths for easier integration with custom code.
  • Document Actions: Added Archive and Delete buttons to the Document Details view with improved redirect behavior.
  • Ticket Template Sorting: Allowed sorting by task count in ticket templates.
  • Contact Modal UI: Updated contact details modal to display contact information at the top.
  • API & Code Updates:
    • Separated out post files for recurring tickets, invoices, expenses, and payments.
    • Removed unused budget code.
  • Invoice Product Autocomplete: Now allows searching for product codes as well as names.
  • Client Duplicate Check: Flags duplicate clients or leads when using the client add modal.
  • Recurring Invoice Reference: Added a column to invoices indicating if they were created from a recurring invoice.
  • Global Search Enhancements:
    • Allowed ticket details to be searchable in global search.
    • Allowed searching for quotes in global search.
  • UI/UX Improvements:
    • Spruced up the ticket details page UI.
    • Added contact email validation to flag duplicates or invalid addresses.
  • API Debugging: Log API endpoint/URL path for authentication failures to aid in debugging.
  • Image Upload Optimization: Removed image optimization from uploads (this will be handled by a cron job in the future).
  • View Behavior Change: Updated ticket/invoice/quote views to always be in the Client section, showing client-side navigation and top info bar.

Library Updates:

  • DataTable: Bumped from 2.3.3 to 2.3.4.
  • TinyMCE: Bumped from 8.0.2 to 8.2.0.
  • Stripe-PHP: Bumped from 17.6.0 to 18.1.0.
  • PHPMailer: Bumped from 6.10.0 to 7.0.0.
  • Chart.js: Bumped from 4.5.0 to 4.5.1.

[25.10.1]

  • Deprecation Notice: /scripts/cron_mail_queue.php , /scripts/cron_ticket_email_parser.php , /scripts/cron.php /scripts/cron_domain_refresher.php, /scripts/cron_certificate_refresher.php are being phased out. Please transition to /cron/mail_queue.php , /cron/ticket_email_parser.php, /cron/cron.php, /cron/domain_refresher.php, /cron/certificate_refresher.php These older scripts will be removed in the November 25.11 release—update accordingly. 25.10.1 installs have the script already configured.

Fixes

  • Fix regression missing custom Favicon.
  • Update SMTP and IMAP provider to allow for empty strings, empty means disabled.
  • Fix Client portal Microsoft SSO Logins.
  • Fix regression in Vendor Templates.
  • Fix refression in some broken links from user to agent.
  • Fix Project edit.
  • Prevent open redirects upon agent login.
  • Fix regression on switching to Webklex IMAP to allow for no SSL/TLS in IMAP.
  • Fix Setup Redirect not behaving properly when setup hasnt been performed.
  • Added Server Document Root Var to several includes, headers, footers files to allow includes from deeper directory strutures such as the new custom directories.
  • Fix edit contact in contact details.
  • Add .htaccess to /cron/.

Added / Changed

  • Support for HTML Signatures.
  • Add Edit Project Functionality in a ticket.
  • Added more custom locations: /cron/custom/, /scripts/custom/, /api/v1/custom/, /setup/custom/.
  • Copied /scripts/cron.php /scripts/cron_domain_refresher.php, /scripts/cron_certificate_refresher.php to /cron/cron.php, /cron/domain_refresher.php, /cron/certificate_refresher.php. See Above!
  • Signatures is now handled in post ticket reply on Public Comments only.

[25.10]

Breaking Changes

  • Renamed /user/ directory to /agent/.
  • Deprecation Notice: /scripts/cron_mail_queue.php and /scripts/cron_ticket_email_parser.php are being phased out. Please transition to /cron/mail_queue.php and /cron/ticket_email_parser.php. These older scripts will be removed in the November release—update accordingly. New Installs via the script will have this already configured.
  • Custom is working now. Custom code should be placed in /admin/custom/ , /agent/custom/ , /client/custom/ /guest/custom/ We will provide example code with directory structure for each custom directory a week after this release.

Fixes

  • Resolved issue with "Restore from Setup" not functioning correctly.
  • Corrected asset name display in logs and flash messages when editing an asset in a ticket.
  • Fixed Payment Provider Threshold not being applied.
  • Fixed issue where Threshold setting was not saving properly.
  • Various minor fixes for Payment Provider issues.
  • Removed leads from the client selection list in the "New Ticket" modal.
  • Fixed issues with the MFA modal.
  • Resolved MFA enforcement bugs.
  • Fixed KeepAlive functionality to maintain user sessions longer.
  • Fixed multiple broken links caused by the /user/ to /agent/ path migration.
  • Fixed Custom code directories.

Added / Changed

  • Removed "ACH" as a payment method; added "Bank Transfer" instead.
  • Replaced relative paths with absolute paths for web assets.
  • Tickets can now be resolved via the API.
  • Added a filter for Archived Users and an option to restore them.
  • Introduced a modal when archiving users, allowing reassignment of open and recurring tickets to another agent.
  • Improved logic for determining the index/root page.
  • Added "Assigned Agent" column for recurring tickets.
  • Introduced "Additional Assets" option when editing assets in tickets; modal now uses the updated AJAX method.
  • Added Gibraltar to the list of supported countries.
  • Added Custom Link Option for the Admin Nav.
  • Added Custom Link Option for the Reports Nav.

Other notes

  • Major releases will happen on the first week of every Month.

[25.09.2]

Fixes

  • Fix Payment Method Select box in Revenue.
  • Remove Extra Feeback Wording When Invoice Sends.
  • Updated all CSV exports to use escape parameters.
  • Fix Missing First row on Asset interface export.
  • Fix Edit User not working due to incorrect modal footer path.
  • Fix Add Certificate breaking due spelling on function.
  • Update all CSV Exports to include company name or client name depending on when its being exported from.
  • Introduced new function sanitize_filename and implmented it in all exports.
  • Spruced up UI/UX Saved Paymented section in Client Portal.
  • Fix add Payment Link in client portal recurring invoice section.
  • Better Logic handling for default page redirect.

Features

  • Introduced new Beta mail parser cron using webklex imap library instead of php-imap as this is deprecated --Not Enabled on existing installs, only new installs.
  • Introduced Beta support for OAUTH2 Authentication for Microsoft 365 and Google Workspaces for both incoming ticket parsing and outgoing email but must use new mail parser and mail queue for this to work, and requires changing the cron jobs: scripts/cron_mail_queue.php to cron/mail_queue.php and scripts/cron_ticket_email_parser.php to cron/ticket_email_parser.php.

[25.09.1]

Fixes

  • Web Installer: Resolved issue with broken installer caused by incorrect database schema file name.
  • Hide the "Add Credit" button as the feature is not fully implemented yet.
  • Corrected long invoice/quote notes that were overlapping with the footer in PDF exports.
  • Fixed AI settings not appearing in the Admin Menu when the Billing module was disabled.
  • Enabled wrapping of client tags when they are too long.
  • Fixed an issue where AI was not functioning correctly.
  • Removed extra spacing between the contact name and icon in the Ticket Details contact card.

Features

  • Redesigned AI Ticket Summary, now divided into 3 sections: Main Issue, Actions Taken, and Resolution/Next Steps.
  • Updated the AI Ticket Summary prompt to include ticket status, reply author, source, category, and priority.

[25.09]

BACK UP before updating.


Breaking Changes and Notes

  • We strongly recommend updating from the command line, however if performed via the webui and after performed it will return a 404. thats normal as the directory structure has changed, just close your browser then log back in then go back to update to perform the many database updates.
  • This is a major release with significant changes. While the community has done a great job identifying bugs, some may still remain — continued testing is encouraged.
  • All AI settings will be reset and must be reconfigured using the new AI provider backend.
  • The xcustom directory has been renamed to custom. All custom libraries and post-processing scripts should now be placed here.

Added / Changed

  • Numerous UI improvements and refinements across the application.
  • Enhanced visual clarity by thickening the left border on ticket comments to help identify comment types.
  • Ticket details UI redesigned to use less space at the top of the screen.
  • Introduced tracking for the first response date/time on tickets.
  • New reporting feature: Average time to first response on tickets.
  • Stripe integration rebuilt using the new payment provider backend.
  • Clients can now save and manage multiple payment methods.
  • Support for selecting saved cards for recurring invoices in both the client and agent portals.
  • Initial database structure and logic added for credit management (feature not yet enabled).
  • Major backend directory restructuring.
  • Introduced stock/inventory management, including a stock ledger backend.
  • Stock quantities now update automatically when invoice items are added or removed.
  • Invoice autocomplete now includes: name, description, price, tax, stock levels, and links product_id to item_id.
  • Added a category filter to invoices.
  • Linked stock to related expenses.
  • New product fields: location, code, and type.
  • Products now separated into two types: Service and Product.
  • Dark mode introduced.
  • Projects: Now support linking closed tickets.
  • Clients: Added bulk actions for tags, referral source, industry, hourly rate, email, archive, and restore.
  • Invoices: Bulk action added to assign categories.
  • Assets: New client_uri field, visible in both the agent and client portals.
  • Client Portal: Clients can now select an asset during ticket creation.
  • Client Portal: Company logo now displays in the header.
  • Client Portal: Dashboard cards are now clickable for more detail.
  • Assets: Option added to include MAC Address in additional columns.
  • Asset Interface: Bulk actions added — set DHCP, network type, and delete.
  • API:
    • Added /location endpoint.
    • Ticket content now supports HTML formatting.
  • New option to filter and display 500 records per page in the footer.
  • Payment methods are now treated as a separate entity instead of being grouped under categories.
  • Updated libraries:
    • TinyMCE
    • Chart.js (major upgrade)
    • DataTables
    • Bootstrap
    • FullCalendar
    • php-stripe

Fixed

  • Several security vulnerabilities patched (with thanks to www.helx.io).
  • Ticket status is no longer updated when scheduling.
  • Client Portal: Tech contacts can no longer edit their own details.
  • Fixed overlapping logo issue in Invoice/Quote PDF exports.
  • Refactored check_login.php into multiple files for modular login functionality.
  • Removed redundant logging comments for redirects.
  • Renamed get_settings.php to load_global_settings.php.
  • Simplified syntax for ajax-modal and updated usage throughout the app.
  • Fixed issue where primary contact text wasn’t displaying.
  • Corrected client Net Terms display.
  • Fixed logic for recurring expense next run date.
  • Resolved broken IMAP test button.
  • Archived clients can no longer log into the portal.
  • Searching closed tickets no longer reverts to open tickets.
  • Fixed project search filter not showing completed projects.
  • Fixed issue where company logo was not being removed correctly.
  • Resolved API bugs:
    • Default rate and net terms.
    • Contact location.
    • Document endpoint.

Developer Updates

  • Replaced legacy code with newer functions like redirect(), getFieldById(), and flash_alert().
  • Significantly improved performance of queries used for filter selection boxes.

[25.06.1]

Fixed

  • Fixed a regression in setup causing it to crash and never complete, due to missing default for currency.

[25.06]

Breaking CHANGES

  • Old Document Verions will be deleted due to the major backend rewrite how document versions work.

Added / Changed

  • Improved function for retrieving remote IP address for logging purposes.
  • Ticket categories are now sorted alphabetically.
  • Visiting a deleted invoice or recurring invoice now redirects to the listing page; delete option added to invoice details page.
  • Added "Mark as Sent" and "Make Payment" actions directly on the invoice listing page.
  • Introduced Ticket Category UI for recurring tickets.
  • In Project Details, bulk actions and sorting are now available for tickets.
  • Updated ticket details UI to use full card stacks with edit icons for stackable items (e.g., asset, watchers, contact).
  • Added a new setting to toggle AutoStart Timer in ticket details (disabled by default).
  • Applied gray accent theme in the client section to visually distinguish from the global view.
  • Introduced Ticket Due Date functionality (currently supports add/edit only; more updates coming next release).
  • Added settings option to display Company Tax ID on invoices.
  • Client overview now displays badge counts for all entities.
  • Overhauled UI for Invoice, Quote, and Recurring Invoice details; switched PDF generation to TCPDF PHP from PDFMake JS.
  • Document versioning has been moved to a separate backend table to resolve permanent link issues -- SEE Breaking CHANGES.
  • Migrated Document Templates, Vendor Templates, and Software/License Templates to dedicated tables.
  • Added functionality to mark all tasks in a ticket as complete or incomplete.
  • Asset CSV import now supports a purchase date field.
  • Recurring Payments have been restructured to auto-charge on the invoice due date instead of at generation time.
  • Added "Base Template" label for vendor templates when available.
  • Backup and restore processes now use a temporary directory; files are cleaned up automatically if operations fail.
  • Added confirmation prompt when accepting or declining a quote.
  • Other minor code UI/UX cleanups and refactoring throughout the app.

Fixed

  • Resolved issue with enabling MFA.
  • Fixed UI regression where ticket listing columns would misalign.
  • Non-billable invoices are no longer included in calculations.
  • Addressed multiple minor reported security vulnerabilities.
  • Tickets with open tasks are no longer resolved in bulk; a warning is shown along with a count of affected tickets.

[25.05.1]

Added / Changed

  • Added Domain Expiring Card to Client Portal Dashboard for Primary and Technical Users.
  • Added Balance and Monthly Recurring Amount to Client Portal Dahboard for Primary and Technical Users.
  • Added Archive Searching to network and certificates also added unarchive capabilities to them as well.

Fixed

  • Add Payment not showing in Invoice.
  • Updated Client Overview Entities to not show archived client's Entities even though the entity may not be archived.

[25.05]

Added / Changed

  • Expanded file upload allow-list to include .bat and .stk file types.
  • Added full backup/restore functionality. Backup downloads a zip that includes the SQL dump and uploads folder, setup now has option to restore from zip backup.
  • Migrated Asset and Contact Links to modals to resolve variable overlap issue.
  • Added Pagination to Notification Modal.
  • Removed 500 Records Per Page option.
  • Removed unused old DB checks in the top nav.
  • Clients can now use the portal to setup Stripe automatic payments themselves for recurring invoices
  • Automatic payments are now disabled for all recurring invoices if the saved payment method is removed
  • Added Card Details and Payment added to Client Stripe.
  • UI / UX updates to guest pay Make use of cards.
  • Don't show Checkbox columns when ticket is closed, compact ticket list now matches round pills for status and priority.
  • Ticket UI/UX update allow the ticket toolbar to be a little more mobile-friendly
  • UI / UX Updates to Expenses - Combine Category and Description into 1 column.
  • Country information is now displayed in Invoices, Quotes, Recurring Invoices, Clients, Locations, and the client top header.
  • Added country-based search filters in Locations and Clients sections.
  • Changed the settings name from Integrations to Identity Providers to make room for future iDPs (e.g. Google).
  • Bump FullCalendar from 6.1.15 to 6.1.17.
  • Bump DataTables from 2.2.2 to 2.3.1.
  • Bump TCPDF from 6.8.2 to 6.9.4.
  • Bump tinyMCE from 7.7.1 to 7.9.0.
  • Bump phpMailer from 6.9.2 to 6.10.0.
  • Bump stripe-php from 16.4.0 to 17.2.1.

Fixed

  • "None" option for SMTP encryption now functions correctly.
  • Debug table row counts now reflect actual counts instead of relying on SHOW TABLE STATUS.
  • Archived Categories now display properly.
  • Stripe saved payment methods are now limited to credit/debit cards only.

[25.03.6]

Fixed

  • Set default to date to 2035-12-31 as 9999-12-31 and 2999-12-31 broke certain browsers.
  • Update Client PDF Export, add header added company logo.
  • Present Larger clearer Warning about updates on update page.
  • Allow to search by project reference.

[25.03.5]

Fixed

  • Fixed the user listing issue when copying a trip.
  • Corrected the display of recurring invoice amounts on the dashboard.
  • Fixed the linking of entities with assets and contacts.
  • Resolved the issue with displaying the correct mobile country code in the contact listing.
  • Set the default date to 9999-12-31 to ensure future items (like invoices) are displayed by default.
  • Fixed the display issue where file folders were not showing properly during document creation.
  • Migrated from Dragula to SortableJS for a more modern, mobile-friendly solution.
  • Added Handlebars icons for drag-and-drop items.
  • Changed behavior to open Contact and Asset Details pages directly instead of using a modal.

[25.03.4]

Fixed

  • Ability to remove additional assets from the ticket details screen.
  • Fix the ability to remove assets from edit ticket not working when only 1 asset exists.
  • Fix Database Backup corruption.
  • Client Portal - show ticket number instead of ticket id in ticket listing.
  • Add Purchase Reference to copy asset.
  • Add Link to asset details from the global search.
  • Fix Bulk assign ticket only showing contacts instead of ITFlow users.

[25.03.3]

Fixed

  • Fix adding ITFlow user.
  • Do not alert on inactive recurring invoices.
  • Fix ticket user assignment including bulk assignment.
  • Fix adding a location phone extension.
  • Do not default to +1 Country code, instead default to null.
  • Do not format numbers unless a country code is entered.
  • Fix editing network location.
  • Fix ticket redaction on client replies.
  • Remove more from user activity as it requires admin privledges.
  • Fix MFA Enforcement page.

[25.03.2]

Fixed

  • Revert DB.sql change

[25.03.1]

Fixed

  • Phone number missing in various sections.
  • Match Database.
  • Client Export Only display licenses users and assets from the selected client only.

[25.03]

Fixed

  • Resolved missing attachments in ticket replies processed via the email parser.
  • Fixed issue where the top half of portrait image uploads appeared cut off at the bottom.
  • Ensured all tables and fields use CHARACTER SET utf8mb4 and COLLATE utf8mb4_general_ci for updates and new installations.
  • Converted service_domains table to use InnoDB instead of MyISAM.
  • Fixed the initials function to properly handle UTF-8 characters, preventing contact-related issues.
  • Interfaces can now start with 0.
  • Adjusted AI prompt handling to focus solely on content, avoiding unnecessary additions.

Added / Changed

  • Introduced bulk delete functionality for assets.
  • Added the ability to redact ticket replies after a ticket is closed.
  • Added support for redacting specific text while a ticket is open.
  • Switched file upload hashing from SHA256 to MD5 to significantly improve performance.
  • Enabled assigning multiple assets to a single ticket.
  • Updated all many-to-many tables to support cascading deletes using foreign key associations, improving efficiency, performance, and data integrity.
  • Enabled caching for AJAX modals to reduce repeated reloads and enhance browser performance.
  • Upgraded DataTables from 2.2.1 to 2.2.2.
  • Upgraded TinyMCE from 7.6.1 to 7.7.1, providing a significant performance boost.
  • Added “Copy Credentials to Clipboard” button in AJAX asset and contact views.
  • Renamed and reorganized several tables.
  • Improved theme color organization by grouping primary colors and their related shades.
  • Displayed a user icon next to contacts who have user accounts.
  • New image uploads are now converted to optimized .webp format by default; original files are no longer saved. Existing images remain unchanged.
  • Added international phone number support throughout the system.
  • Introduced user signatures in preferences, which are now appended to all ticket replies.
  • Optimized search filters to only display defined tags.
  • Added “Projects” to the client-side navigation.
  • Enabled “Create New Ticket” from within project details.
  • Reintroduced batch payment functionality in client invoices.
  • Included client abbreviations in both client and global search options.
  • Added assigned software license details (User/Asset) to the client PDF export.
  • Replaced client-side pdfMake with the PHP-based TCPDF library for generating client export runbooks.
  • Introduced the ability to download documents as PDFs.
  • Added a “Reference” field to tickets and invoices generated from recurring templates (not yet in active use).

Breaking Changes

Important: To update to this version, you must run the following commands from the command line from the scripts directory:

php update_cli.php
php update_cli.php --db_update

Repeat --db_update until no further updates are found.

Back up your system before upgrading.
This version includes numerous backend changes critical for future development.

[25.02.4]

Fixed

  • Resolved issue preventing the addition or editing of licenses when no vendor was selected.
  • Fixed several undeclared variables in AJAX contact details.
  • Corrected the contact ticket count display.
  • Addressed an issue where clicking "More Details" in AJAX contact/asset details failed to include the client_id in the URL.
  • Fixed an issue with recurring invoices in the client URL: clicking "Inactive" or "Active" would unexpectedly navigate away from the client section.
  • Added new php function getFieldById() to return a record using just an id and sanitized as well.

[25.02.3]

Fixed

  • Fixed notifications being reversed as dismissed notifications.

[25.02.2]

Fixed

  • Corrected some edit modals not showing notes correctly.
  • Bugfix: When exporting to CSV, the first asset wasn't being shown.
  • Fix broken create / edit credentials.
  • Fixed missing Notificatons link.
  • Fixed a few dead links.
  • Fixed Overdue count also counting Non-Billable Invoices.
  • Fix Edit Client Notes.

Added / Changed

  • Implemented SSL certificate history tracking.
  • Added Inactive / Active Filter to Recurring Invoices.
  • Merged Dismissed notifications and notification in one.
  • Added Link Button to addd / edit Document WYSIWYG.
  • Added Physical location to the asset export / import.

[25.02.1]

Fixed

  • Resolved broken links in the client overview, project and client listings, and rack details.
  • Corrected asset transfer functionality to clients.
  • Fixed the ticket scheduling redirect.
  • Corrected the ticket link in the Scheduled Ticket Agent Notification email.
  • Addressed issues with credentials and ticket actions in the Contact Detail Modal.
  • Fixed text wrapping in notifications.
  • Adjusted notifications so that they are sorted with the newest first.
  • Fixed drag-and-drop functionality for tickets in the Kanban view on mobile devices.
  • Resolved a weird issue with TinyMCE that prevented using links referencing your ITFlow instance url.
  • Corrected image orientation issues during upload and the preview optimization process.

Added / Changed

  • Introduced entity link indicator icons and counts in the contacts and credentials section.
  • Implemented a fade animation for the new AJAX modal.
  • Removed the Client Overview Expire Day Select and replaced it with simplified 1, 7, or 45-day options.
  • Added the ability to link and unlink entities within asset details.
  • Introduced quick tag/category creation across the app.
  • Added a Vendor Quick Details Modal.
  • Enabled vendor linking and added a License Purchase Reference in the Software Licenses section.
  • Added download original, optimized and thumbnail option for images.
  • Added Paid status to the top corner of Invoice PDFs.

[25.02]

Fixed

  • Migrated several reports to the new permissions/roles system.
  • Resolved issue with empty task box showing for closed/resolved tickets.
  • Corrected ticket priority sorting.
  • Cloned asset interfaces when transferring assets between clients.

Added / Changed

  • Restored max number of records per page option back to 500 since we dont have repeating modals.
  • Bulk Categorize Tickets feature.
  • Renamed "Interface port" to "Interface Description." "Interface Name" should now refer to port name and/or number.
  • Changed "Transfer Asset to Client" from a single action to a bulk action.
  • Updated Filter Footer UI to show "Showing x to x of x records" instead of just the total records.
  • Added Client Overview section to view client assets, contacts, licenses, credentials, etc.
  • Introduced Quick Peek for asset details, contact information, and document viewing throughout the ITFlow App, all made possible by AJAX.
  • Enabled Simple Drag-and-Drop Ordering for Invoices, Recurring Invoices, Quotes, Ticket Tasks, and Ticket Template Tasks.
  • Added new Ticket View options: Kanban and Simple View.
  • Migrated all repeating modals to the new AJAX modal function for faster loading times and quicker development.
  • Allowed clients to upload PDF documents to accepted quotes.
  • Client Portal now shows ticket category.
  • Custom links can now be added to the Client Portal navbar.
  • Lots of little tweaks to UI, performance, bugs, etc.

Breaking Changes

  • Cron scripts have officially been moved to the /scripts folder and are no longer in the root directory; they must be updated to function properly.

[25.01.3]

Fixed

  • Fixed ticket assignment modal showing client contacts.

[25.01.2]

Fixed

  • Fixed app version.

[25.01.1]

Added / Changed

  • Redesigned the Multi-Factor Authentication (MFA) Setup and Enforcement Flow UI/UX for a more intuitive user experience.
  • Added a "Member" column in the user roles listing for improved visibility.
  • General UI/UX improvements, along with minor performance optimizations and cleanups.

Fixed

  • Fixed an issue where Stripe was not appearing as a recurring payment option.
  • Corrected inaccurate Quarter 2 Expense results in the Profit & Loss Report.
  • Resolved TOTP code not displaying correctly on hover in the Contact or Asset Details sections.
  • Archived contacts no longer appear in the Bulk Mail section.
  • Fixed an issue where the Ticket Assign Modal was showing both ITFlow and client users.
  • Fixed issue with login key redirecting to legacy client portal page.

[25.01]

Added / Changed

  • Added support for saving cards in Stripe for automatic invoice payments.
  • Page titles now display detailed information (e.g., page name, client selection, company name, ticket and invoice info) for easier multi-tab navigation.
  • Reintroduced the new admin role-check for admin pages.
  • Admin roles can now be archived.
  • Debug mode now shows the current Git branch.
  • The auto-acknowledgment email for email-parsed tickets now includes a guest link.
  • Recurring tickets no longer require a contact.
  • Stripe online payment setup now prompts you to set the income/expense account.
  • New cron/CLI scripts have been moved to the /scripts subfolder — remember to update your cron configurations!
  • Moved modal includes to /modals to tidy up the root directory.
  • Moved most include files to /includes to improve directory structure.
  • Moved guest pages to /guest for better organization.
  • Renamed the include file pagination.php to filter_footer.php, as it is used in conjunction with filter_header.php for page filtering.
  • Guest ticket feedback now shows the ticket prefix and number, not just the ID.
  • Individual POST handler logic pages are no longer directly accessible.
  • Added the ability to delete payments on the Payments and Client Payments pages.
  • Implemented domain history tracking.
  • Added Asset Interface Linking/Connections to show what interface is connected to which interface port of another asset.
  • Added Force Recurring Ticket option in more locations, not just for recurring tickets.
  • Implemented row spanning and centered devices that occupy multiple units in a rack.
  • Added tooltips to main navigation badge counts to clarify what is being counted.
  • Reduced max records per page from 500 to 100 to prevent performance issues.
  • Updated several plugins:
    • stripe-php from 10.5.0 to 16.4.0
    • Inputmask from 5.0.8 to 5.0.9
    • DataTables from 2.1.8 to 2.2.1
    • pdfmake from 0.2.8 to 0.2.18
    • php-mime-mail-parser to 9.0.1
    • TinyMCE from 7.5.1 to 7.6.1
  • Removed unused libraries from the vendor folder and moved Stripe to the plugins folder, eliminating the vendor folder.
  • Merged the MFA TOTP functionality files base32static.php and rfc6238.php into a single file (totp) and moved it to the plugins folder.
  • No longer need to pass the DB connection ($mysqli) to the addToMailQueue function.
  • Disabled HTML Purifier caching.
  • Replaced the nullable_htmlentities function with htmlspecialchars.
  • Updated filter variable naming.
  • Implemented other minor UI updates, performance optimizations, and directory cleanups.

Fixed

  • Fixed an issue where the ticket edit modal didn't show multi-client or no-client projects.
  • Fixed asset interface losing DHCP settings.
  • Fixed a 500 error when creating or editing recurring expenses due to an incorrect variable name.
  • Fixed tickets created via the portal/email not being marked as billable.
  • Fixed issues with editing recurring expenses.
  • Resolved a regression where the TinyMCE editor didn’t display when adding or editing ticket templates.
  • Fixed a TinyMCE license issue.

Removed / Deprecated

  • Deprecated the cron scripts in the root directory. Cron jobs should now use the ones in the /scripts subfolder, which no longer require a cron key and must be run via CLI.

BREAKING CHANGES

  • The client portal has been moved from /portal to /client:
    • Links in previous emails will be broken.
    • The Azure Entra ID SSO Redirect URI needs to be updated to /client.
    • You may need to update other links (e.g., website, support page).
  • Guest links have been moved from / to /guest. Previous links will be broken.

[24.12]

Added / Changed

  • Introduced versioned releases for the first time!