diff --git a/CHANGELOG.md b/CHANGELOG.md index 880f4ca32..d6def18ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.4.9) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.5.0) 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. @@ -41,7 +41,9 @@ No contract is out for signature after the update - signing starts when you send 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 applies. 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 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. @@ -102,6 +104,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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. @@ -1154,7 +1162,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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. Leave a rate, the net terms or an SLA empty and the client's own apply. + - 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. The menu shows how many are due soon. - 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. 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. @@ -1175,8 +1183,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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 client's own 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) - going by the contract in force on the day the work was done. New invoices are due on its net terms. Whatever it leaves empty, the client's own applies; when two are in force, the one that started last comes first. The contract's page says whether it is in force now. - - 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 contract's rate and net terms, and editing a client says when a contract's apply instead. + - 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 a client's contract sets under Recurring. + - 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. @@ -1199,7 +1208,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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 hourly rate and net terms a new client starts with, 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. + - 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 Add someone - a name, an email and a role. ITFlow makes their password: they are emailed their sign-in details, as Admin > Users does, or, with no email set up yet, it is shown to you once to give them. 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. @@ -2035,10 +2044,10 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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 client's own), 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. +- 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 ``, 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, client 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 client's own - `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. +- 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 `