diff --git a/CHANGELOG.md b/CHANGELOG.md index b459a6dd0..f96c3cca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1056,7 +1056,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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. - **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 and Documentation 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. + - 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. @@ -1080,6 +1080,11 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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. ### Bug Fixes @@ -1826,6 +1831,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 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. ### Library Updates diff --git a/admin/ai_models.php b/admin/ai_models.php index 04d00c96b..ffe166271 100644 --- a/admin/ai_models.php +++ b/admin/ai_models.php @@ -22,7 +22,7 @@ $page_title = 'AI Models'; $sortable_columns = [ 'ai_model_name' => "ai_model_name $order", 'ai_provider_name' => "ai_provider_name $order, ai_model_name ASC", - 'ai_model_use_case' => "FIELD(ai_model_use_case, 'General', 'Tickets', 'Documentation') " . ($order === 'ASC' ? 'ASC' : 'DESC') . ", ai_model_id ASC", + 'ai_model_use_case' => "FIELD(ai_model_use_case, 'General', 'Tickets', 'Documentation', 'Billing') " . ($order === 'ASC' ? 'ASC' : 'DESC') . ", ai_model_id ASC", 'requests' => "requests $order, ai_model_name ASC", ]; if (!isset($sortable_columns[$sort])) { diff --git a/admin/ai_providers.php b/admin/ai_providers.php index 251d6e1aa..10a12ebed 100644 --- a/admin/ai_providers.php +++ b/admin/ai_providers.php @@ -51,7 +51,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); // Each provider's models, with what they are for - and those nothing uses $models_by_provider = []; -$model_sql = mysqli_query($mysqli, "SELECT ai_model_id, ai_model_name, ai_model_use_case, ai_model_ai_provider_id FROM ai_models ORDER BY FIELD(ai_model_use_case, 'General', 'Tickets', 'Documentation'), ai_model_name"); +$model_sql = mysqli_query($mysqli, "SELECT ai_model_id, ai_model_name, ai_model_use_case, ai_model_ai_provider_id FROM ai_models ORDER BY FIELD(ai_model_use_case, 'General', 'Tickets', 'Documentation', 'Billing'), ai_model_name"); while ($model = mysqli_fetch_assoc($model_sql)) { $models_by_provider[intval($model['ai_model_ai_provider_id'])][] = $model; } diff --git a/agent/ajax.php b/agent/ajax.php index 6b0e7f95e..00073bbf0 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -2168,6 +2168,49 @@ if (isset($_GET['ai_document_review'])) { exit; } +/* + * Quotes and invoices (functions/ai_billing.php). Each answers JSON; the agent + * needs Use AI, the client, and Sales to change them. Nothing is saved here. + */ + +// A ticket's work in words, for the invoice line that bills its time +if (isset($_GET['ai_ticket_invoice_description'])) { + $row = $aiTicket($_POST['ticket_id'] ?? 0, 1); + enforceUserPermission('module_sales', 2); + if (!intval($row['ticket_client_id'])) { + echo json_encode(['error' => 'Move the ticket to a client before invoicing it.']); + exit; + } + $result = aiTicketInvoiceDescription(intval($row['ticket_id'])); + echo json_encode($result['ok'] ? ['text' => $result['text']] : ['error' => $result['error']]); + exit; +} + +// A quote or invoice written from a line on what it is for - its scope, lines and a note, for the agent to tick and change +if (isset($_GET['ai_billing_draft'])) { + header('Content-Type: application/json'); + if (!aiCan()) { + $aiRefuse(); + } + enforceUserPermission('module_sales', 2); + $type = (string) ($_POST['type'] ?? ''); + $row = in_array($type, ['quote', 'invoice'], true) ? billingRow($type, intval($_POST['doc_id'] ?? 0)) : null; + if (!$row) { + echo json_encode(['error' => 'That document no longer exists, or is not one you can open.']); + exit; + } + if (!hasClientAccess(intval($row['client_id']))) { + permissionRefuse("You don't have access to {$row['client_name']}."); + } + if (!billingCanEditItems($type, $row)) { + echo json_encode(['error' => billingNumber($type, $row) . " can't be changed now - " . ($type === 'quote' ? 'it has been answered or invoiced.' : 'it is paid, cancelled or non-billable.')]); + exit; + } + $result = aiBillingDraft($type, $row, (string) ($_POST['prompt'] ?? ''), !empty($_POST['use_ticket']), !empty($_POST['use_client'])); + echo json_encode($result['ok'] ? array_diff_key($result, ['ok' => 1]) : ['error' => $result['error']]); + exit; +} + // Stops people trying to use sub-domains in the domains tracker /* * The domain name in a New / Edit Domain form, checked as it is typed: what it diff --git a/agent/invoice.php b/agent/invoice.php index 4fe0384a3..c7f94ed03 100644 --- a/agent/invoice.php +++ b/agent/invoice.php @@ -112,6 +112,7 @@ $paper = [ 'paid' => $state['paid'], 'balance' => $state['balance'], 'edit' => $can_edit_lines, + 'ai' => $can_edit_lines && aiReady('Billing'), 'notes_edit' => $can_edit, 'taxes' => $can_edit_lines ? billingTaxes($invoice['invoice_created_at']) : [], 'logo_url' => billingCompany()['logo_url'], diff --git a/agent/modals/billing/billing_ai_writer.php b/agent/modals/billing/billing_ai_writer.php new file mode 100644 index 000000000..5ef7a7842 --- /dev/null +++ b/agent/modals/billing/billing_ai_writer.php @@ -0,0 +1,422 @@ + 'That document no longer exists.'])); +} +$client_id = intval($doc['client_id']); +enforceClientAccess(); + +// Using AI is a permission of its own +if (!aiCan()) { + permissionRefuse(permissionRefusedText('module_ai', 1, 0)); +} +if (!billingCanEditItems($type, $doc)) { + exit(json_encode(['error' => billingNumber($type, $doc) . " can't be changed now - " . ($type === 'quote' ? 'it has been answered or invoiced.' : 'it is paid, cancelled or non-billable.')])); +} + +$t = billingType($type); +$c = $t['col']; +$number = billingNumber($type, $doc); +$currency = (string) $doc["{$c}_currency_code"]; +$scope = trim((string) $doc["{$c}_scope"]); +$support = lookupUserPermission('module_support') >= 1; +$ticket = $support ? aiBillingTicket($type, $doc_id) : null; +$rate = billingTimeRate(getFieldById('clients', $client_id, 'client_rate')); +$catalog_count = count(aiBillingCatalog()); +$prompt = $scope !== '' ? $scope : ($ticket ? $ticket['ticket_subject'] : ''); +$word = $type === 'quote' ? 'quote' : 'invoice'; + +ob_start(); + +?> + + +
+ + + + + +
+ + + + Settings > Invoice says) and the client's rate * are filled in, ready to change. A ticket still open can be billed from - * here too - time logged after that isn't billed. ?ticket_id= + * here too - time logged after that isn't billed. Write it (with AI, for + * the Billing use case) says what was done in the description, from the + * replies and notes, for the client. ?ticket_id= */ require_once '../../../includes/modal_header.php'; @@ -52,6 +54,7 @@ while ($draft = mysqli_fetch_assoc($sql)) { $drafts[intval($draft['invoice_id'])] = $draft; } $taxes = billingTaxes(); +$ai_describe = aiReady('Billing'); $scopes = ["Ticket $reference - " . $row['ticket_subject'], 'Support - ' . date('F Y')]; ob_start(); @@ -152,8 +155,14 @@ ob_start();
- + + + +
+ +
Write it - AI says what was done, from the replies and notes, for the client to read.
+
@@ -237,6 +246,60 @@ ob_start(); form.addEventListener('input', update); form.addEventListener('change', update); update(); + + // Write it - the work done, from the replies and notes, in place of the description + var ai = form.querySelector('[data-ticket-invoice-ai]'); + if (ai) { + var description = form.querySelector('[data-ticket-invoice-description]'); + var said = form.querySelector('[data-ticket-invoice-ai-said]'); + var before = null; + ai.addEventListener('click', function () { + ai.disabled = true; + said.className = 'form-text'; + said.innerHTML = 'AI is reading the ticket...'; + fetch('/agent/ajax.php?ai_ticket_invoice_description', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + credentials: 'same-origin', + body: new URLSearchParams({ ticket_id: form.querySelector('[name="ticket_id"]').value }).toString() + }) + .then(function (response) { + return response.json().catch(function () { + throw new Error(''); + }); + }) + .then(function (answer) { + if (answer.error || !answer.text) { + throw new Error(answer.error || ''); + } + if (before === null) { + before = description.value; + } + description.value = answer.text; + description.rows = Math.min(8, answer.text.split('\n').length + 1); + said.textContent = 'Written by AI from the replies and notes - check it before billing. '; + var undo = document.createElement('a'); + undo.href = '#'; + undo.textContent = 'Put back what was there'; + undo.setAttribute('data-ticket-invoice-ai-undo', ''); + undo.addEventListener('click', function (e) { + e.preventDefault(); + description.value = before; + description.rows = 3; + before = null; + said.textContent = 'Put back.'; + }); + said.appendChild(undo); + }) + .catch(function (error) { + said.className = 'form-text text-danger-emphasis'; + said.textContent = error && error.message ? error.message : 'Could not write it - try again.'; + }) + .then(function () { + ai.disabled = false; + }); + }); + } })(); diff --git a/agent/post/billing.php b/agent/post/billing.php index 8e371dc08..8cf0349f0 100644 --- a/agent/post/billing.php +++ b/agent/post/billing.php @@ -135,6 +135,97 @@ if (isset($_POST['billing_item_save'])) { } +/* + * Lines written with AI (modals/billing/billing_ai_writer.php), on a quote or + * an invoice - the ticked ones added in order, each taxed as a line of its + * kind is (a product at its own rate, labor as time, the rest as typed-in + * lines); then the scope and the note, when the agent kept them - the note + * goes after any already there. A line that can't go on is said, and the rest + * still do. + */ +if (isset($_POST['billing_ai_lines'])) { + + validateCSRFToken(); + + enforceUserPermission('module_sales', 2); + + $type = (string) ($_POST['type'] ?? ''); + $doc_id = intval($_POST['doc_id'] ?? 0); + if (!in_array($type, ['quote', 'invoice'], true)) { + flashAlert('That document no longer exists, or is not one you can open.', 'error'); + redirect(); + } + $row = billingPostDocument($type, $doc_id); + $t = billingType($type); + $c = $t['col']; + $number = billingNumber($type, $row); + + $added = []; + $failed = []; + foreach (array_slice((array) ($_POST['lines'] ?? []), 0, 25) as $line) { + if (!is_array($line) || empty($line['include'])) { + continue; + } + $name = trim((string) ($line['name'] ?? '')); + $kind = in_array($line['kind'] ?? '', ['catalog', 'labor', 'other'], true) ? $line['kind'] : 'other'; + $product_id = $kind === 'catalog' ? intval($line['product_id'] ?? 0) : 0; + $product_tax_id = $product_id ? getFieldById('products', $product_id, 'product_tax_id') : null; + if ($product_tax_id === null) { + $product_id = 0; + } + if (trim((string) ($line['price'] ?? '')) === '') { + $failed[] = ($name !== '' ? $name : 'A line') . ' - it needs a price'; + continue; + } + $in = [ + 'name' => $name, + 'description' => str_replace("\r", '', (string) ($line['description'] ?? '')), + 'qty' => $line['qty'] ?? '1', + 'price' => $line['price'], + 'tax_id' => $kind === 'labor' ? taxDefaultFor($client_id, 'time') : ($product_id ? taxDefaultFor($client_id, 'product', intval($product_tax_id)) : taxDefaultFor($client_id)), + 'product_id' => $product_id, + ]; + if ($type === 'quote') { + $in['frequency'] = (string) ($line['frequency'] ?? ''); + } + if (billingItemSave($type, $doc_id, $in, 0, $error)) { + $added[] = $name; + } else { + $failed[] = ($name !== '' ? $name : 'A line') . ' - ' . rtrim($error, '.'); + } + } + + // The scope and the note, when kept + $kept = []; + $scope = trim((string) ($_POST['scope'] ?? '')); + if (!empty($_POST['use_scope']) && $scope !== '') { + mysqli_query($mysqli, "UPDATE {$t['table']} SET {$c}_scope = '" . escapeSql(mb_substr($scope, 0, 255)) . "' WHERE {$c}_id = $doc_id"); + $kept[] = 'the scope'; + } + $note = trim(str_replace("\r", '', (string) ($_POST['note'] ?? ''))); + if (!empty($_POST['use_note']) && $note !== '') { + $old_note = trim((string) $row["{$c}_note"]); + $note = $old_note === '' ? $note : "$old_note\n\n$note"; + mysqli_query($mysqli, "UPDATE {$t['table']} SET {$c}_note = '" . escapeSql($note) . "' WHERE {$c}_id = $doc_id"); + $kept[] = 'the note'; + } + + $not_added = $failed ? '
Not added: ' . escapeHtml(implode('; ', $failed)) . '.' : ''; + if (!$added && !$kept) { + flashAlert('Nothing was added to ' . escapeHtml($number) . ' - ' . ($failed ? 'none of the lines could go on.' . $not_added : 'tick the lines to add.'), 'error'); + billingPostBack($type, $doc_id); + } + + $what = $added ? count($added) . ' line' . (count($added) === 1 ? '' : 's') : ''; + $what = trim($what . ($what !== '' && $kept ? ', with ' : '') . implode(' and ', $kept)); + logAudit(billingAuditType($type), 'Edit', "$session_name added " . escapeSql($what) . ' written with AI to ' . escapeSql($number), $client_id, $doc_id); + + flashAlert('' . escapeHtml(ucfirst($what)) . ' added to ' . escapeHtml($number) . '' . $not_added, $failed ? 'warning' : 'success'); + + billingPostBack($type, $doc_id); + +} + if (isset($_GET['billing_item_delete'])) { validateCSRFToken(); diff --git a/agent/quote.php b/agent/quote.php index d0d6f0073..da699b3dd 100644 --- a/agent/quote.php +++ b/agent/quote.php @@ -77,6 +77,7 @@ $paper = [ 'items' => $items, 'totals' => $totals, 'edit' => $can_edit_lines, + 'ai' => $can_edit_lines && aiReady('Billing'), 'notes_edit' => $can_edit, 'taxes' => $can_edit_lines ? billingTaxes($quote['quote_created_at']) : [], 'logo_url' => billingCompany()['logo_url'], diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 6f9e9e0e5..96bf60123 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -8433,3 +8433,13 @@ a.itflow-portal-request:focus-visible { .itflow-ai-summary p:last-child { margin-bottom: 0; } +/* Write it with AI - each line's numbers, side by side, wrapping on a phone */ +.itflow-ai-line-qty { + flex: 0 0 7rem; +} +.itflow-ai-line-billed { + flex: 0 0 9.5rem; +} +.itflow-ai-line-price { + flex: 0 0 9rem; +} diff --git a/functions.php b/functions.php index 1e5d821b9..2fc95318f 100644 --- a/functions.php +++ b/functions.php @@ -48,6 +48,7 @@ require_once __DIR__ . '/functions/ai.php'; require_once __DIR__ . '/functions/ai_ticket.php'; require_once __DIR__ . '/functions/ai_document.php'; require_once __DIR__ . '/functions/ai_client.php'; +require_once __DIR__ . '/functions/ai_billing.php'; require_once __DIR__ . '/functions/export.php'; require_once __DIR__ . '/functions/calendar.php'; require_once __DIR__ . '/functions/backup.php'; diff --git a/functions/ai.php b/functions/ai.php index 886a3b9ce..5d74eab44 100644 --- a/functions/ai.php +++ b/functions/ai.php @@ -62,6 +62,7 @@ function aiUseCases() { 'General' => 'Everything without a model of its own - Reword and translate in any editor, and client briefings.', 'Tickets' => 'Drafting replies, triage, summaries, and Reword and translate in the ticket reply editor.', 'Documentation' => 'Drafting and reviewing documents, writing a ticket up as a document, and drafting document templates.', + 'Billing' => 'Writing quotes and invoices - their lines from your catalog, labor at the client\'s rate - and the work done on a ticket for its invoice line.', ]; } @@ -79,6 +80,8 @@ function aiFeatures() { 'document_draft' => 'Document draft', 'document_review' => 'Document review', 'client_briefing' => 'Client briefing', + 'billing_draft' => 'Quote or invoice written', + 'invoice_description' => 'Ticket time described for its invoice', 'test' => 'Test', ]; } @@ -411,9 +414,9 @@ function callAiApi($model, $messages, array $context = []) { aiUsageRecord($usage + ['ok' => true, 'tokens_in' => intval($decoded['usage']['prompt_tokens'] ?? 0), 'tokens_out' => intval($decoded['usage']['completion_tokens'] ?? 0)]); - // Put back - or, for what is kept (a document), left out for good + // Put back - or, for what is kept (a document, an invoice line), left out for good; a placeholder the model made up as well if (($context['secrets'] ?? 'restore') === 'remove') { - return ['ok' => true, 'content' => aiRestore($content, array_fill_keys(array_keys($secrets), '[removed]')), 'removed' => count($secrets)]; + return ['ok' => true, 'content' => preg_replace('/\[secret-\d+\]/', '[removed]', aiRestore($content, array_fill_keys(array_keys($secrets), '[removed]'))), 'removed' => count($secrets)]; } return ['ok' => true, 'content' => aiRestore($content, $secrets)]; } @@ -885,7 +888,7 @@ function aiUseCaseStripHtml() { $tone = 'bg-body'; } $html .= '
' - . '' + . '' . escapeHtml($use_case) . ' ' . $says . '
'; } return $html . '
'; diff --git a/functions/ai_billing.php b/functions/ai_billing.php new file mode 100644 index 000000000..a2f1eac4d --- /dev/null +++ b/functions/ai_billing.php @@ -0,0 +1,335 @@ + row (name, type, code, + * description, price, frequency, seat source, tax id). Not archived, at most + * $limit, by name. + */ +function aiBillingCatalog($limit = 200) { + global $mysqli; + $catalog = []; + $sql = mysqli_query($mysqli, "SELECT product_id, product_name, product_type, product_code, product_description, product_price, product_frequency, product_seat_source, product_tax_id + FROM products WHERE product_archived_at IS NULL ORDER BY product_name ASC LIMIT " . max(1, intval($limit))); + while ($row = mysqli_fetch_assoc($sql)) { + $catalog[intval($row['product_id'])] = $row; + } + return $catalog; +} + +/* The catalog as a model reads it - one line each: id | name | service or product | how it is billed | price | what it is. */ +function aiBillingCatalogText(array $catalog, $currency) { + $billed = ['month' => 'monthly', 'quarter' => 'quarterly', 'year' => 'yearly']; + $per = ['contacts' => ' per user', 'assets' => ' per device']; + $lines = []; + foreach ($catalog as $id => $row) { + $description = aiHtmlToText((string) $row['product_description'], 120); + $lines[] = $id . ' | ' . $row['product_name'] . ($row['product_code'] ? " ({$row['product_code']})" : '') . ' | ' . $row['product_type'] + . ' | ' . ($billed[(string) $row['product_frequency']] ?? 'once') . ($per[(string) $row['product_seat_source']] ?? '') + . ' | ' . billingMoney($row['product_price'], $currency) . ($description !== '' ? ' | ' . str_replace("\n", ' ', $description) : ''); + } + return implode("\n", $lines); +} + +/* ------------------------------------------------------------- A ticket's time, described */ + +/* + * A ticket's work as a model reads it, for its invoice line - what was asked, + * then each reply and note in order, saying who it is from and the time it + * logged, then the tasks. The latest of a long one, to about 30,000 + * characters. Null when the ticket is gone. + */ +function aiTicketWorkText($ticket_id) { + global $mysqli; + $ticket_id = intval($ticket_id); + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_details, ticket_created_at, ticket_client_id, client_name + FROM tickets LEFT JOIN clients ON client_id = ticket_client_id WHERE ticket_id = $ticket_id")); + if (!$row) { + return null; + } + $lines = ['Ticket: ' . ticketReference($row) . ' - ' . $row['ticket_subject'], 'Client: ' . ($row['client_name'] ?: 'none'), "\nWhat was asked:\n" . aiHtmlToText($row['ticket_details'], 6000)]; + + $replies = []; + $seconds = 0; + $sql = mysqli_query($mysqli, "SELECT ticket_reply, ticket_reply_type, ticket_reply_created_at, TIME_TO_SEC(ticket_reply_time_worked) AS seconds + FROM ticket_replies WHERE ticket_reply_ticket_id = $ticket_id AND ticket_reply_archived_at IS NULL ORDER BY ticket_reply_id ASC"); + while ($reply = mysqli_fetch_assoc($sql)) { + $worked = intval($reply['seconds']); + $seconds += $worked; + $who = ['Client' => 'The client', 'Internal' => 'Internal note'][$reply['ticket_reply_type']] ?? 'Reply to the client'; + $replies[] = '[' . date('M j, Y', strtotime($reply['ticket_reply_created_at'])) . "] $who" . ($worked ? ' - ' . billingDuration($worked) . ' logged' : '') . ":\n" . aiHtmlToText($reply['ticket_reply'], 3000); + } + $kept = []; + $room = 30000; + foreach (array_reverse($replies) as $reply) { + if (mb_strlen($reply) > $room) { + break; + } + $room -= mb_strlen($reply); + array_unshift($kept, $reply); + } + $lines[] = $kept ? "\nThe work, in order:\n" . implode("\n\n", $kept) : "\nNo replies or notes on it."; + if ($seconds) { + $lines[] = "\nTime logged in all: " . billingDuration($seconds); + } + + $tasks = []; + $sql = mysqli_query($mysqli, "SELECT task_name FROM tasks WHERE task_ticket_id = $ticket_id AND task_completed_at IS NOT NULL ORDER BY task_order, task_id"); + while ($task = mysqli_fetch_assoc($sql)) { + $tasks[] = '- ' . $task['task_name']; + } + if ($tasks) { + $lines[] = "\nTasks done:\n" . implode("\n", $tasks); + } + return ['text' => implode("\n", $lines), 'row' => $row]; +} + +/* + * What a model wrote as lines of plain text, tidied - no HTML, code fences or + * bold, each line a "- " point, at most $max of them. + */ +function aiBillingPoints($content, $max = 6) { + $text = aiHtmlToText(preg_replace('/^```[a-z]*\s*|\s*```$/im', '', (string) $content)); + $points = []; + foreach (preg_split('/\n+/', $text) as $line) { + $line = trim(str_replace(['**', '__'], '', $line)); + $line = trim(preg_replace('/^(?:[-*•–]|\d+[.)])\s*/u', '', $line)); + if ($line === '' || preg_match('/:$/', $line)) { + continue; + } + $points[] = '- ' . mb_substr($line, 0, 200); + if (count($points) >= $max) { + break; + } + } + return $points; +} + +/* + * The invoice line description for a ticket's time - ['ok', 'text'] or ['ok' + * => false, 'error']. The usual first line (billingTimeDescription() - the + * ticket, its subject and date), then the work done as short points, for the + * client. Passwords and keys are never sent, and never come back. + */ +function aiTicketInvoiceDescription($ticket_id) { + $model = getAiModel('Billing'); + if (!$model) { + return ['ok' => false, 'error' => aiModelMissingError('Billing')]; + } + $work = aiTicketWorkText($ticket_id); + if (!$work) { + return ['ok' => false, 'error' => 'That ticket no longer exists.']; + } + $model['ai_provider_redact'] = 1; + $result = callAiApi($model, [ + ['role' => 'system', 'content' => 'You write the description on an invoice line that bills the time worked on an IT support ticket, for the client who pays it.' + . ' From the ticket, say what was done: one to five short lines, each starting with "- ", in plain past tense (like "- Replaced the failed switch in the server room"), the work that took the time first.' + . ' No times, dates, prices or the names of the people who did it. Nothing from internal notes the client shouldn\'t read - passwords, guesses, blame or remarks about the client - and never the placeholders like [secret-1].' + . ' Plain text only: no introduction, no headings, nothing after the lines.'], + ['role' => 'user', 'content' => $work['text']], + ], ['feature' => 'invoice_description', 'client_id' => intval($work['row']['ticket_client_id']), 'secrets' => 'remove']); + if (!$result['ok']) { + return $result; + } + $points = aiBillingPoints($result['content'], 6); + if (!$points) { + return ['ok' => false, 'error' => 'The AI provider answered with nothing to use - try again.']; + } + return ['ok' => true, 'text' => billingTimeDescription($work['row']) . "\n" . implode("\n", $points)]; +} + +/* ------------------------------------------------------------- A quote or invoice, written */ + +/* The ticket a quote was raised for - the row, or null. Only quotes have one to draw on. */ +function aiBillingTicket($type, $doc_id) { + global $mysqli; + if ($type !== 'quote') { + return null; + } + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject FROM tickets WHERE ticket_quote_id = " . intval($doc_id) . " AND ticket_archived_at IS NULL LIMIT 1")); + return $row ?: null; +} + +/* How a line the model wrote is billed - '' (once), month, quarter or year. */ +function aiBillingFrequency($value) { + $value = strtolower(trim((string) $value)); + $value = ['monthly' => 'month', 'quarterly' => 'quarter', 'yearly' => 'year', 'annually' => 'year', 'annual' => 'year'][$value] ?? $value; + return quoteFrequency($value); +} + +/* + * One line the model wrote, made safe to show - ['kind' => catalog, labor or + * other, 'product_id', 'name', 'description', 'qty', 'frequency', 'price' (null + * when there is none to give it)], or null when it has no name. A catalog line + * takes the product's name and price, and how the product is billed when it + * says; a line named exactly as a product is that product. Labor is hours at + * the rate. Anything else has no price - the model's own is never taken. + */ +function aiBillingLine($type, $line, array $catalog, $rate) { + if (!is_array($line)) { + return null; + } + $text = function ($key) use ($line) { + return is_scalar($line[$key] ?? null) ? trim(strip_tags((string) $line[$key])) : ''; + }; + $name = $text('name'); + $description = $text('description'); + $product_id = is_scalar($line['product_id'] ?? null) ? intval($line['product_id']) : 0; + if (!isset($catalog[$product_id])) { + $product_id = 0; + foreach ($catalog as $id => $product) { + if ($name !== '' && mb_strtolower(trim($product['product_name'])) === mb_strtolower($name)) { + $product_id = $id; + break; + } + } + } + $labor = !$product_id && filter_var($line['labor'] ?? false, FILTER_VALIDATE_BOOLEAN); + $qty = is_numeric($line['qty'] ?? null) ? round(floatval($line['qty']), 2) : 1.0; + $qty = $qty > 0 ? min($qty, 99999) : 1.0; + $frequency = $type === 'quote' ? aiBillingFrequency($text('billed')) : ''; + + if ($product_id) { + $product = $catalog[$product_id]; + $out = ['kind' => 'catalog', 'product_id' => $product_id, 'name' => (string) $product['product_name'], + 'description' => $description !== '' ? $description : aiHtmlToText((string) $product['product_description'], 1000), + 'price' => round(floatval($product['product_price']), 2)]; + if ($type === 'quote' && quoteFrequency($product['product_frequency']) !== '') { + $frequency = quoteFrequency($product['product_frequency']); + } + } elseif ($labor) { + $out = ['kind' => 'labor', 'product_id' => 0, 'name' => $name !== '' ? $name : billingTimeRules()['item_name'], 'description' => $description, + 'price' => $rate > 0 ? round($rate, 2) : null]; + $qty = round(ceil($qty * 4) / 4, 2); + } else { + if ($name === '') { + return null; + } + $out = ['kind' => 'other', 'product_id' => 0, 'name' => $name, 'description' => $description, 'price' => null]; + } + $out['name'] = mb_substr($out['name'], 0, 200); + $out['description'] = mb_substr($out['description'], 0, 1000); + $out['qty'] = $qty; + $out['frequency'] = $frequency; + return $out; +} + +/* + * A quote or invoice written from a line on what it is for - ['ok', 'scope', + * 'lines' => aiBillingLine()s, 'note' => for the client, 'checks' => what the + * agent should confirm, 'rate', 'ticket' => its reference when it was used] or + * ['ok' => false, 'error']. $row is the document (billingRow()); with + * $use_ticket, the ticket a quote was raised for goes with it, and with + * $use_client, what ITFlow knows of the client (its people, sites and + * assets - for sizing per-user and per-device lines). The ticket and the + * client's documentation only for an agent with Support; without it, just + * how many people the client has. + */ +function aiBillingDraft($type, array $row, $prompt, $use_ticket = true, $use_client = true) { + global $mysqli; + $model = getAiModel('Billing'); + if (!$model) { + return ['ok' => false, 'error' => aiModelMissingError('Billing')]; + } + $prompt = trim((string) $prompt); + if ($prompt === '') { + return ['ok' => false, 'error' => 'Say what it is for first.']; + } + $t = billingType($type); + $c = $t['col']; + $doc_id = intval($row["{$c}_id"]); + $client_id = intval($row['client_id']); + $currency = (string) $row["{$c}_currency_code"]; + $rate = billingTimeRate(getFieldById('clients', $client_id, 'client_rate')); + $catalog = aiBillingCatalog(); + + // The client's documentation and its tickets go only for an agent who may read them + $support = lookupUserPermission('module_support') >= 1; + $profile = $use_client && $support ? aiClientProfile($client_id) : ''; + $facts = [$profile !== '' ? $profile : 'Client: ' . $row['client_name']]; + if ($use_client) { + $facts[] = 'People (contacts): ' . intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL"))[0]); + } + $facts[] = 'Labor is billed by the hour' . ($rate > 0 ? ', at ' . billingMoney($rate, $currency) : '') . '.'; + $ticket = $use_ticket && $support ? aiBillingTicket($type, $doc_id) : null; + $ticket_text = ''; + if ($ticket) { + $context = aiTicketContext(intval($ticket['ticket_id'])); + $ticket_text = $context ? "\n\nThe ticket it is for (internal notes are for you only - never quote them):\n" . $context['text'] : ''; + } + $existing = []; + foreach (billingItems($type, $doc_id) as $item) { + $existing[] = '- ' . $item['item_name'] . ' x ' . billingQuantity($item['item_quantity']); + } + + $quote = $type === 'quote'; + $result = callAiApi($model, [ + ['role' => 'system', 'content' => 'You write ' . ($quote ? 'quotes' : 'invoices') . ' for a managed service provider - the lines a client reads and pays for.' + . ' From what the agent says it is for, choose the lines: products and services from the catalog given, by their id, wherever one fits; labor in hours for the work to do it; and anything else needed that isn\'t in the catalog.' + . ' Never write a price - prices come from the catalog and the hourly rate. Be realistic about quantities and hours, and size per-user and per-device lines from the client\'s numbers when they are given.' + . ' Line descriptions are for the client: a plain sentence or two on what it is, or what the work covers.' + . ($quote ? ' Say how each line is billed: once, or each month, quarter or year - licenses and managed services are usually monthly, hardware and project labor once.' : ' Everything on an invoice is billed once.') + . ' Answer with JSON only, in this shape: {"scope": "a short line on what it is for, under 80 characters", "lines": [{"product_id": a catalog id or null, "labor": true for hours of work, "name": "the line\'s name", "description": "for the client", "qty": a number - hours for labor' + . ($quote ? ', "billed": "once" or "month" or "quarter" or "year"' : '') . '}], "note": "for the client: what is assumed and what is not included, as short lines starting with - ", "checks": ["for the agent: what to confirm before sending - quantities you guessed, what the catalog didn\'t have"]}. At most 20 lines.'], + ['role' => 'user', 'content' => "What it is for: $prompt\n\n" . implode("\n", $facts) + . ($existing ? "\n\nOn it already (don't repeat these):\n" . implode("\n", $existing) : '') + . $ticket_text + . "\n\nThe catalog (id | name | type | billed | price | what it is):\n" . ($catalog ? aiBillingCatalogText($catalog, $currency) : 'Empty - nothing is in the catalog.')], + ], ['feature' => 'billing_draft', 'client_id' => $client_id, 'secrets' => 'remove']); + if (!$result['ok']) { + return $result; + } + $answer = aiJsonFromText($result['content']); + if (!$answer || !is_array($answer['lines'] ?? null)) { + return ['ok' => false, 'error' => "The AI provider's answer couldn't be read - try again."]; + } + $lines = []; + foreach (array_slice($answer['lines'], 0, 20) as $line) { + $line = aiBillingLine($type, $line, $catalog, $rate); + if ($line) { + $lines[] = $line; + } + } + if (!$lines) { + return ['ok' => false, 'error' => 'The AI provider answered with no lines to use - say a little more about what it is for.']; + } + $checks = []; + foreach (array_slice((array) ($answer['checks'] ?? []), 0, 6) as $check) { + $check = trim(strip_tags(is_scalar($check) ? (string) $check : '')); + if ($check !== '') { + $checks[] = mb_substr($check, 0, 300); + } + } + $note = is_scalar($answer['note'] ?? null) ? implode("\n", aiBillingPoints((string) $answer['note'], 10)) : ''; + return [ + 'ok' => true, + 'scope' => mb_substr(trim(strip_tags(is_scalar($answer['scope'] ?? null) ? (string) $answer['scope'] : '')), 0, 255), + 'lines' => $lines, + 'note' => mb_substr($note, 0, 2000), + 'checks' => $checks, + 'rate' => $rate, + 'ticket' => $ticket ? ticketReference($ticket) : '', + ]; +} diff --git a/includes/billing_paper.php b/includes/billing_paper.php index 5f858ff17..5f73b763e 100644 --- a/includes/billing_paper.php +++ b/includes/billing_paper.php @@ -18,6 +18,8 @@ * logo_url the company logo, relative to the page * client shown to the client (the client portal) - no agent-only notes * csrf the agent's token, for the edit controls + * ai Write it with AI under the add-a-line row (quotes and + * invoices, when the agent may use AI for Billing) * * Everything in it is escaped here. */ @@ -297,6 +299,14 @@ $paper_bill_lines = array_filter([ + +
+ + Say what it's for - lines from your catalog, labor at the client's rate. +
+