diff --git a/CHANGELOG.md b/CHANGELOG.md index 26306f12a..bc046f5cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow. ### Upgrading -Update from Maintenance > Update — Update ITFlow hands the job to cron, which updates the files and applies this release's four database changes (3.5.4 to 3.5.8) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees, Manager Check-in and Managed Instances jobs in Maintenance > Cron are set up by themselves. +Update from Maintenance > Update — Update ITFlow hands the job to cron, which updates the files and applies this release's five database changes (3.5.4 to 3.5.9) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees, Manager Check-in and Managed Instances jobs in Maintenance > Cron are set up by themselves. ### Security @@ -21,6 +21,9 @@ Update from Maintenance > Update — Update ITFlow hands the job to cron, which - API: products can be updated through the API (`products/update`). Only the fields you send are changed, and the key's user needs edit access to Sales, the same as editing a product in ITFlow. - API: a ticket can be raised from a ticket template (`tickets/create`). Send `ticket_template_id` and the ticket gets what the template sets up - its subject and details unless you send your own, its category, priority, billing, agent, tags, due time and tasks - with the template's fill-ins answered from `ticket_fill_ins`. Send `ticket_notify` too and people hear about it as when it is raised in ITFlow: the contact gets the Ticket Created email, and the new-ticket address and the agent it is assigned to are told. The answer gives the ticket's number, as TCK-93, and the link its contact opens it with. A form on your website can open a ticket with the steps to work through already on it. - Canned Responses: on a ticket raised from a ticket template, the template's answers fill in too. A response that says [Hostname] or [Employee Name] gets that ticket's answer as it goes in, as [Contact] and [Ticket] do - so one response can serve every ticket from the template. An answer left blank stays in brackets for you to fill in. +- Invoices: a lead that pays an invoice in full becomes a client by itself, however it is paid - online, by autopay or a payment you record. It is on from this update; turn off **A lead that pays an invoice becomes a client** in Admin > Settings > Invoices and billing to keep leads as leads until you change them. +- Tickets: a ticket waiting on an invoice hears when it is paid in full - a note on the ticket says so, it goes from On Hold back to Open, and the agent it is assigned to is emailed. A quote's tickets wait on its invoice once the quote becomes one, and a ticket raised through the API can be tied to an invoice. A billable ticket whose time is on an invoice is left alone. +- API: recurring invoices can be made (`recurring_invoices/create`) from your products or from lines you spell out, with the first invoice made and emailed at once so it can be paid from its link, and read (`recurring_invoices/read`). Someone can be invited to a client's portal (`portal_invites/create`). A new ticket can start On Hold or in any other open status (`ticket_status`), wait on one of the client's invoices (`ticket_invoice_id`), and be announced to your team only (`ticket_notify` "agents"). Together, a sign-up form on your website can make the lead, invoice them, invite them to the portal and open the ticket that waits for their payment. - API keys: agents are notified in ITFlow a week before an API key expires, so an integration doesn't stop without warning. - Payment Providers: Stripe's fee on a card payment is now recorded as an expense about 15 minutes after the payment, instead of the next morning. A new Payment Provider Fees job in Maintenance > Cron does it, and you can run it more often there. Record fees now still works any time. - Payment Providers: clients can pay the card fee. Turn on Clients pay the card fee on Stripe in Admin > Payment Providers and enter what Stripe charges you, as 2.9% plus 0.30. Each card payment is then made bigger by Stripe's fee on it, so you get the full invoice. The fee goes on the invoice as a Card Fee line, with no tax, when the payment goes through. Clients see it before they pay: on Pay invoices and the checkout page in the client portal, on the page an invoice's link opens, and next to autopay. It applies to payments in the client portal, through an invoice's link, by autopay, and when you charge a saved card, where the Charge Saved Card window shows it too. One payment for several invoices has one fee, shared over them. A client can be let off it with No card fee on their Billing tab. Refunding a card payment from ITFlow takes its Card Fee line back off the invoice. It is off until you turn it on. Card surcharges have rules where you and your clients are - in the US, for one, the card networks cap them and don't allow them on debit cards - so check them before turning it on. @@ -33,6 +36,7 @@ Update from Maintenance > Update — Update ITFlow hands the job to cron, which - Transactions: the list, its Transfers pill, Reconcile and the transactions export load faster again. On a test with eight years of books, the Transfers pill went from about a third of a second in 26.11.2 to under a fifth. - Expenses: billable expenses waiting to go on an invoice slowed down every page, because the side menu counts them and each one was checked against every expense. With a few hundred waiting, that was about a third of a second on every page. The invoice page, Add Expenses and an expense's quick look had the same delay. All of them are quick again. - Transactions: on All accounts, Money in and Money out counted every transfer between your own accounts twice - once out of one account and once into the other - so both were far too high. A Stripe payout to the bank, a check deposit or paying off a card all added to both. They now leave transfers between your accounts out, as the dashboard and Income already did. The Transfers pill still lists them, and one account on its own still counts them as money in and out of that account. The Difference was always right. +- Clients: the Clients list never showed a contract as Renews soon - it read each client's contracts without their term - and wrote a PHP warning for each one. It does now. - API: adding a contact or a location through the API (`contacts/create`, `locations/create`) no longer writes a PHP warning to the server's error log for each field left out. - App Logs: the email parser logged an error every minute while email-to-ticket parsing was switched off, burying the errors that matter. It now just stops, and Maintenance > Cron says why. - Phone numbers: outside North America, a number shown with its country code kept the 0 - or the 8 in Russia - that is only dialled within the country, as "+49 0172 1234567" for a German mobile, wherever it showed: ITFlow's pages, the client portal (a contact's own profile, and the Contact us details), emails, PDFs and exports. Click-to-call dialled it that way too, and so failed. Numbers are now written the way each country writes them: "+49 172 1234567", and "0172 1234567" without the country code. This covers every country, not only the ones ITFlow had its own rules for. A German landline such as Munich's is split after its area code ("+49 89 1234567"), Italy keeps the 0 its landlines are dialled with from abroad ("+39 06 1234 5678"), and South African numbers read "+27 82 123 4567". A number typed with or without that leading 0, or with its country code at the front, shows and dials the same. North American numbers look as they did. @@ -71,6 +75,11 @@ Update from Maintenance > Update — Update ITFlow hands the job to cron, which - Shared links have a new type, `Export` (IT documentation). `agent/ajax.php` share_create makes its PDF with `clientExportOptions()`, `clientExportCheck()` and `clientExportPdf()`, as the download does, and hands it to `shareCreate()` as `export` (pdf, name, secret). The link seals the PDF's name, size, SHA-256 and whether it's password-locked into `item_payload`, and `shareExportStore()` writes the PDF to `uploads/shares/export-.bin` with AES-256-GCM under the link's content key - iv, tag, ciphertext - in a folder `backupHardenStorageDir()` keeps from the web, so the disk and the database alone open nothing. `guest/guest_download_file.php` serves File or Export, the PDF through `shareExportRead()`; `guest/share_ajax.php` describes it from `shareExportSize()` and the payload; `js/guest_share.js` downloads it as it does a file. `shareEnd()` and `shareSweep()` delete the file with the payload, and `sharePrune()` deletes any left without a link that can still open it (a client deleted with its links). `shareNeedsProtection($type, $secret)` takes whether this link holds passwords, `shareCanSee()` and `shareCanMake()` need `module_client` for it, and it's logged as a Client Export too. No database change - `item_type` is a varchar. `clientExportSectionPicker()` takes an id prefix and the sections to start ticked; Export Data's Share a link passes the ticked ones as `sections[]`. - `api/v1/tickets/create.php` takes `ticket_template_id`, `ticket_fill_ins` (a JSON object of fill-in names and answers, checked with `ticketFillInAnswers()`) and `ticket_notify`. A missing or archived template, `ticket_fill_ins` that isn't an object and a required fill-in left out are a 400 with the reason in `message`. From a template, `ticket_subject`, `ticket_details`, `ticket_priority`, `ticket_billable` and `ticket_assigned_to` not sent come from it, `ticket_category` is its category, and `ticketTemplateFinish($ticket_id, $template_id, true, true, $answers, ...)` adds its tags, due time, tasks and fill-in answers and fills its placeholders. `ticket_notify` = 1 calls `ticketMailCreated()` and `ticketMailColleaguesNew()` when `config_ticket_client_general_notifications` is on, `ticketMailNewTicketAddress($ticket_id, 'api')`, and for an assigned ticket an app notification and `ticketMailAssigned()`. The answer adds `ticket_reference`, `ticket_url` (`ticketEmailUrl()`) and, from a template, `ticket_tasks`. `ticketAgentEmailContent()`'s `new` takes how `api` ("raised a ticket through the API"). A request without the three fields is raised as before, with the two new fields in its answer. - `cannedResponseValues()` adds the ticket's fill-in answers (`ticketFillInsStored('ticket', $ticket_id)`, as `ticketFillInText()` words them), answered ones only and never over a placeholder of the same name, so `cannedResponsePlaceholders()` and `cannedResponseFill()` - the picker, the `/` search and Update & Reply - fill them in. `includes/canned_response_form.php` says so under the placeholder buttons. +- Database 3.5.9 adds `settings.config_invoice_paid_lead_to_client` (tinyint(1), default 1), after `config_invoice_paid_notification_email`. It is read where it is used, never in `load_global_settings.php`, and the switch in Admin > Settings > Invoices and billing (Paid online) shows and saves only once the column is there (`settingsColumnExists()`). +- New `invoicePaidFollowUp($invoice_id)` in `functions/billing.php`, run by `triggerCustomAction()` on `invoice_pay` before any custom handler - every road that books a payment fires that. It does nothing until `invoice_status` is Paid. Then, with the setting on, `client_lead` 1 becomes 0 (audit log Client Edit, `appNotify()`); and each ticket with `ticket_invoice_id` the invoice, `ticket_billable = 0` and not resolved, closed or archived gets an Internal reply (`ticket_reply_by = 0`), On Hold (3) becomes Open (2), the history line "Invoice paid in full" (which also keeps it from happening twice), and for its assignee a notification and `ticketMailAgents()` with the new `ticketAgentEmailContent()` kind `paid`. +- `api/v1/recurring_invoices/create.php` (Sales, Modify) goes through the new `recurringInvoiceCreate($in, $by)`: the client's currency, `validateRecurringFrequency()`, each line through `billingItemSave('recurring', ...)` - a `product_id` gives the name, description and price not sent, `tax_id` "default" is `taxDefaultFor()` - and a 400 with the reason for a client, date, product or line that won't do. `invoice_now` runs `recurringInvoiceGenerate($id, $by, true)`; one the recurring invoice didn't email by itself goes through the new `billingInvoiceIssue($invoice_id, $by)`, which emails a Draft to the primary and billing contacts when billing mail is set up and marks it Sent either way. The answer adds `recurring_invoice_reference`, `amount`, `next_date` and `invoice` (`invoice_id`, `reference`, `amount`, `currency`, `due`, `status`, `url` - `billingGuestUrl()` - and `emailed_to`). `recurring_invoices/read.php` lists them, or gets one with its `items`. +- `api/v1/portal_invites/create.php` (Clients, Modify) calls `portalInviteSend()` with no agent as the sender, so the email reads as from the company; the answer's `invite` is invited, resent or skipped (with why in `message`). `enforce_api_rbac.php` maps `recurring_invoices` to `module_sales` and `portal_invites` to `module_client`. +- `api/v1/tickets/create.php` takes `ticket_status` (an active status other than Resolved and Closed, else a 400), `ticket_invoice_id` (one of the client's invoices, else a 400) and `ticket_notify` "agents" (the new-ticket address and the assignee, not the contact). `agent/clients.php` reads `contract_term_months` with each contract, as `contractState()` needs. ## [26.11.2] diff --git a/admin/database_updates/3.5.9.php b/admin/database_updates/3.5.9.php new file mode 100644 index 000000000..4e44fe6ea --- /dev/null +++ b/admin/database_updates/3.5.9.php @@ -0,0 +1,22 @@ + Settings > Invoices and billing (admin/settings_invoice.php): * edit_invoice_settings saves the numbering, the footer and tax ID, late - * fees, who is emailed an invoice paid online, payment reminders and + * fees, who is emailed an invoice paid online and whether a lead that pays + * becomes a client, payment reminders and * statements, how time is billed, the markup billable expenses start with, * and trips and mileage. * @@ -45,6 +46,11 @@ if (isset($_POST['edit_invoice_settings'])) { foreach (['config_invoice_show_tax_id', 'config_invoice_late_fee_enable', 'config_send_invoice_reminders', 'config_statement_monthly', 'config_billing_email_attach_pdf'] as $switch) { $after[$switch] = empty($_POST[$switch]) ? 0 : 1; } + // From 3.5.9 - a lead that pays becomes a client (invoicePaidFollowUp()) + if (settingsColumnExists($mysqli, 'config_invoice_paid_lead_to_client')) { + $before += settingsCurrent(['config_invoice_paid_lead_to_client']); + $after['config_invoice_paid_lead_to_client'] = empty($_POST['config_invoice_paid_lead_to_client']) ? 0 : 1; + } // Late fees - a percent of what is owed $late_fee = billingNumberInput(str_replace('%', '', (string) $in['config_invoice_late_fee_percent'])); diff --git a/admin/settings_invoice.php b/admin/settings_invoice.php index b8a51b14b..e88905f1d 100644 --- a/admin/settings_invoice.php +++ b/admin/settings_invoice.php @@ -73,6 +73,10 @@ echo settingsPageStart('invoice'); 'fa-bell', 'type' => 'email', 'maxlength' => 200, 'placeholder' => 'billing@company.com', 'help' => 'A shared mailbox or list works best. Empty, nobody is emailed - the payment is still recorded.']) ?> + + 'However it is paid in full - online, by autopay or a payment you record. A ticket linked to the invoice that is On Hold opens again at the same time, and its agent is told.']) ?> + diff --git a/agent/clients.php b/agent/clients.php index 4a09e59d7..ab48c7aff 100644 --- a/agent/clients.php +++ b/agent/clients.php @@ -324,7 +324,7 @@ if ($clients) { if ($show_contracts) { $contract_today = date('Y-m-d'); $sql_contracts = mysqli_query($mysqli, "SELECT contract_id, contract_client_id, contract_name, contract_type, contract_status, contract_start_date, contract_end_date, - contract_auto_renew, contract_notice_days, contract_cancelled_at + contract_auto_renew, contract_notice_days, contract_term_months, contract_cancelled_at FROM contracts WHERE contract_client_id IN ($page_ids) AND contract_archived_at IS NULL AND contract_status IN ('Active', 'Draft', 'Ended', 'Cancelled') ORDER BY contract_start_date DESC, contract_id DESC"); diff --git a/api/v1/enforce_api_rbac.php b/api/v1/enforce_api_rbac.php index d55d3260a..ad834c5d8 100644 --- a/api/v1/enforce_api_rbac.php +++ b/api/v1/enforce_api_rbac.php @@ -153,8 +153,10 @@ $resource_module = [ 'clients' => 'module_client', 'contacts' => 'module_client', 'locations' => 'module_client', + 'portal_invites' => 'module_client', 'vendors' => 'module_client', 'invoices' => 'module_sales', + 'recurring_invoices' => 'module_sales', 'invoice_items' => 'module_sales', 'quotes' => 'module_sales', 'products' => 'module_sales', diff --git a/api/v1/portal_invites/create.php b/api/v1/portal_invites/create.php new file mode 100644 index 000000000..667eedfbd --- /dev/null +++ b/api/v1/portal_invites/create.php @@ -0,0 +1,76 @@ + $email, 'name' => $name]], + // From your company, not the key's agent - "You are invited to the client portal" + ['billing' => intval($_POST['billing'] ?? 0) === 1, 'technical' => intval($_POST['technical'] ?? 0) === 1], ['user_id' => 0]); + +if (isset($done['skipped'][$email])) { + $return_arr['success'] = "True"; + $return_arr['count'] = '0'; + $return_arr['invite'] = 'skipped'; + $return_arr['message'] = "Not invited - " . $done['skipped'][$email]; + echo json_encode($return_arr); + exit(); +} + +$state = in_array($email, $done['resent'], true) ? 'resent' : 'invited'; +$insert_id = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT invite_id FROM portal_invites WHERE invite_client_id = " . intval($client_id) . " + AND invite_kind = 'invite' AND invite_email = '" . escapeSql($email) . "' ORDER BY invite_id DESC LIMIT 1"))[0] ?? 0); +$return_arr['invite'] = $state; +logAudit("Contact", "Invite", escapeSql(($state === 'resent' ? 'Sent the client portal invite again to ' : 'Invited ') . "$email" . ($state === 'resent' ? '' : ' to the client portal') . " for {$client['client_name']} via API ($api_key_name)"), intval($client_id)); +logAudit("API", "Success", escapeSql("Client portal invite for $email via API ($api_key_name)"), intval($client_id)); + +// Output +require_once '../create_output.php'; diff --git a/api/v1/recurring_invoices/create.php b/api/v1/recurring_invoices/create.php new file mode 100644 index 000000000..bcacfed8d --- /dev/null +++ b/api/v1/recurring_invoices/create.php @@ -0,0 +1,87 @@ + $client_id] + (array) $_POST, $by); +if (!$recurring_invoice_id) { + $return_arr['success'] = "False"; + $return_arr['message'] = $error; + header("HTTP/1.1 400 Bad Request"); + echo json_encode($return_arr); + exit(); +} + +$insert_id = $recurring_invoice_id; +$recurring = billingRow('recurring', $recurring_invoice_id, false); +$reference = billingNumber('recurring', $recurring); +$return_arr['recurring_invoice_reference'] = $reference; +$return_arr['amount'] = (float) $recurring['recurring_invoice_amount']; + +logAudit("Recurring Invoice", "Create", escapeSql("Created recurring invoice $reference - " . $recurring['recurring_invoice_scope'] . " via API ($api_key_name)"), $client_id, $recurring_invoice_id); +logAudit("API", "Success", escapeSql("Created recurring invoice $reference via API ($api_key_name)"), $client_id); + +// The first invoice, now +if (intval($_POST['invoice_now'] ?? 0) === 1) { + $invoice_id = recurringInvoiceGenerate($recurring_invoice_id, $by, true); + if ($invoice_id) { + if (getFieldById('invoices', $invoice_id, 'invoice_status') === 'Sent') { + // Emailed by itself as it was made - the recurring invoice sends its own (recurringInvoiceGenerate()) + $emailed_to = array_map(function ($r) { + return $r['name'] !== '' ? "{$r['name']} ({$r['email']})" : $r['email']; + }, billingEmailRecipients(intval($client_id))); + } else { + $emailed_to = intval($_POST['invoice_send'] ?? 1) === 1 ? billingInvoiceIssue($invoice_id, $by) : []; + } + $invoice = billingRow('invoice', $invoice_id, false); + $return_arr['invoice'] = [ + 'invoice_id' => $invoice_id, + 'reference' => billingNumber('invoice', $invoice), + 'amount' => (float) $invoice['invoice_amount'], + 'currency' => (string) $invoice['invoice_currency_code'], + 'due' => (string) $invoice['invoice_due'], + 'status' => (string) $invoice['invoice_status'], + 'url' => billingGuestUrl('invoice', $invoice), + 'emailed_to' => $emailed_to, + ]; + } +} +$return_arr['next_date'] = (string) getFieldById('recurring_invoices', $recurring_invoice_id, 'recurring_invoice_next_date'); + +// Output +require_once '../create_output.php'; diff --git a/api/v1/recurring_invoices/read.php b/api/v1/recurring_invoices/read.php new file mode 100644 index 000000000..e08a57996 --- /dev/null +++ b/api/v1/recurring_invoices/read.php @@ -0,0 +1,29 @@ + 'True', 'count' => 1, 'data' => [$row]]); + exit(); + } +} else { + $sql = mysqli_query($mysqli, "SELECT * FROM recurring_invoices WHERE 1=1 " . apiClientScopeSql('recurring_invoice_client_id') . " ORDER BY recurring_invoice_id LIMIT $limit OFFSET $offset"); +} + +// Output +require_once "../read_output.php"; diff --git a/api/v1/tickets/create.php b/api/v1/tickets/create.php index 66661538d..1dd1056af 100644 --- a/api/v1/tickets/create.php +++ b/api/v1/tickets/create.php @@ -7,8 +7,13 @@ * tags, due time and tasks, and its fill-ins answered from ticket_fill_ins * ({"Name": "answer"}). ticket_notify = 1 tells people as a ticket raised in * ITFlow does - the contact and watchers (when ticket emails to clients are - * on), the new-ticket address and the agent it is assigned to. The answer has - * the ticket's number as people see it and where its contact opens it. + * on), the new-ticket address and the agent it is assigned to; "agents" tells + * only your team, the new-ticket address and the agent. ticket_status + * starts it in an open status of your choosing - On Hold, say - and + * ticket_invoice_id ties it to one of the client's invoices: once that is paid + * in full the ticket gets a note, goes from On Hold back to Open, and its agent + * is told (invoicePaidFollowUp()). The answer has the ticket's number as people + * see it and where its contact opens it. */ require_once '../validate_api_key.php'; @@ -74,7 +79,23 @@ if (!empty($_POST['ticket_template_id'])) { } } $category_id = $template ? ticketCategoryId($template['ticket_template_category_id']) : 0; -$notify = intval($_POST['ticket_notify'] ?? 0) === 1; +// 1: everyone, as a ticket raised in ITFlow; 'agents': only your team - the new-ticket address and its agent +$notify_posted = $_POST['ticket_notify'] ?? 0; +$notify_team = in_array($notify_posted, [1, '1', true, 'agents'], true); +$notify = $notify_team && $notify_posted !== 'agents'; + +// Its status - one still open, never Resolved or Closed (those are reached by resolving and closing it) +$status_posted = intval($_POST['ticket_status'] ?? 0); +if ($status_posted && ($status_posted === TICKET_STATUS_RESOLVED || $status_posted === TICKET_STATUS_CLOSED + || !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM ticket_statuses WHERE ticket_status_id = $status_posted AND ticket_status_active = 1")))) { + $ticket_refuse("ticket_status $status_posted isn't a status a new ticket can have - an active one other than Resolved and Closed. See Admin > Ticket Statuses."); +} + +// The invoice it waits on - the client's own. When that invoice is paid in full, the ticket is told (invoicePaidFollowUp()). +$invoice_link = intval($_POST['ticket_invoice_id'] ?? 0); +if ($invoice_link && (!$client_id || !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM invoices WHERE invoice_id = $invoice_link AND invoice_client_id = " . intval($client_id))))) { + $ticket_refuse("ticket_invoice_id $invoice_link isn't one of this client's invoices."); +} // A client that exists, or none (internal work) $client_exists = !$client_id || mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM clients WHERE client_id = " . intval($client_id))); @@ -95,6 +116,9 @@ if (!empty($subject) && $client_exists) { if ($assigned_to > 0) { $ticket_status = 2; // Set to open if we've auto-assigned an agent } + if ($status_posted) { + $ticket_status = $status_posted; + } // Atomically increment and get the new ticket number mysqli_query($mysqli, " @@ -109,7 +133,7 @@ if (!empty($subject) && $client_exists) { // Insert ticket $url_key = randomString(32); - $insert_sql = mysqli_query($mysqli,"INSERT INTO tickets SET ticket_prefix = '$ticket_prefix', ticket_number = $ticket_number, ticket_source = 'API', ticket_category = $category_id, ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = $ticket_status, ticket_billable = $billable, ticket_vendor_ticket_number = '$vendor_ticket_number', ticket_vendor_id = $vendor_id, ticket_created_by = 0, ticket_assigned_to = $assigned_to, ticket_contact_id = $contact, ticket_asset_id = $asset, ticket_url_key = '$url_key', ticket_client_id = $client_id"); + $insert_sql = mysqli_query($mysqli,"INSERT INTO tickets SET ticket_prefix = '$ticket_prefix', ticket_number = $ticket_number, ticket_source = 'API', ticket_category = $category_id, ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = $ticket_status, ticket_billable = $billable, ticket_vendor_ticket_number = '$vendor_ticket_number', ticket_vendor_id = $vendor_id, ticket_created_by = 0, ticket_assigned_to = $assigned_to, ticket_contact_id = $contact, ticket_asset_id = $asset, ticket_url_key = '$url_key', ticket_invoice_id = $invoice_link, ticket_client_id = $client_id"); // Check insert & get insert ID if ($insert_sql) { @@ -121,9 +145,9 @@ if (!empty($subject) && $client_exists) { ticketTemplateFinish($insert_id, $template['ticket_template_id'], true, true, $fill_in_answers, "Raised by the API ($api_key_name) from template " . $template['ticket_template_name']); } - // Who hears about it, as when it's raised in ITFlow - if ($notify) { - if (intval($config_ticket_client_general_notifications) === 1) { + // Who hears about it, as when it's raised in ITFlow - the contact too, unless it's for the team only + if ($notify_team) { + if ($notify && intval($config_ticket_client_general_notifications) === 1) { ticketMailCreated($insert_id, 'Ticket Created'); ticketMailColleaguesNew($insert_id); } diff --git a/db.sql b/db.sql index fadc219b6..6781aa195 100644 --- a/db.sql +++ b/db.sql @@ -3176,6 +3176,7 @@ CREATE TABLE `settings` ( `config_invoice_late_fee_enable` tinyint(1) NOT NULL DEFAULT 0, `config_invoice_late_fee_percent` decimal(5,2) NOT NULL DEFAULT 0.00, `config_invoice_paid_notification_email` varchar(200) DEFAULT NULL, + `config_invoice_paid_lead_to_client` tinyint(1) NOT NULL DEFAULT 1, `config_invoice_show_tax_id` tinyint(1) NOT NULL DEFAULT 0, `config_tax_default_id` int(11) NOT NULL DEFAULT 0, `config_tax_time` tinyint(1) NOT NULL DEFAULT 0, diff --git a/functions/billing.php b/functions/billing.php index d5e5725f4..d58c60f7d 100644 --- a/functions/billing.php +++ b/functions/billing.php @@ -2440,3 +2440,186 @@ function billingClientOpenInvoices($client_id) { } return $rows; } + +/* -------------------------------------------- Issuing an invoice, and what follows its payment */ + +/* + * Send an invoice that is still a draft: emailed to the client's primary and + * billing contacts when mail is set up, and marked Sent either way, so its + * link can be paid at once (a draft can't be). $by says who, raw. Who it was + * emailed to - [] when nobody was, or it wasn't a draft. + */ +function billingInvoiceIssue($invoice_id, $by) { + global $mysqli; + $invoice = billingRow('invoice', $invoice_id, false); + if (!$invoice || $invoice['invoice_status'] !== 'Draft') { + return []; + } + $invoice_id = intval($invoice['invoice_id']); + $sent_to = []; + if (billingEmailReady() && ($recipients = billingEmailRecipients(intval($invoice['invoice_client_id'])))) { + [$subject, $email] = billingInvoiceEmail($invoice); + $attach = []; + if (billingReminderRules()['attach_pdf'] && ($file = billingEmailPdf(billingPdf('invoice', $invoice)))) { + $attach[] = $file; + } + $sent_to = billingEmailQueue($recipients, $subject, $email, ['attach' => $attach]); + } + mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Sent' WHERE invoice_id = $invoice_id AND invoice_status = 'Draft'"); + billingLog('invoice', $invoice_id, 'Sent', $sent_to ? 'Emailed to ' . implode(', ', $sent_to) . " by $by" : "Marked sent by $by - its link was handed on, nobody was emailed"); + return $sent_to; +} + +/* + * A recurring invoice made from $in - the API's recurring_invoices/create: + * client_id; frequency (month, quarter or year); start_date (Y-m-d, today + * when left out - the first run); scope, note, category_id, discount; + * auto_send (1, the default: the invoices it makes are emailed by + * themselves); and items, each [product_id, name, description, qty, price, + * tax_id] - a product gives the name, description and price not sent, and + * tax_id 'default' is the client's usual rate (taxDefaultFor()). $by is who, + * raw. [id, null] or [null, why]. + */ +function recurringInvoiceCreate(array $in, $by) { + global $mysqli; + $client_id = intval($in['client_id'] ?? 0); + $client = $client_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_id, client_currency_code FROM clients WHERE client_id = $client_id AND client_archived_at IS NULL")) : null; + if (!$client) { + return [null, 'client_id is not a client here, or it is archived.']; + } + $items = is_array($in['items'] ?? null) ? array_values($in['items']) : []; + if (!$items || count($items) > 100) { + return [null, 'items is a list of 1 to 100 lines - each a product_id, or a name and price.']; + } + $start = trim((string) ($in['start_date'] ?? '')); + if ($start === '') { + $start = date('Y-m-d'); + } elseif (!($date = DateTime::createFromFormat('!Y-m-d', $start)) || $date->format('Y-m-d') !== $start) { + return [null, 'start_date is a date - 2026-11-05.']; + } + $frequency = validateRecurringFrequency((string) ($in['frequency'] ?? 'month')); + $category_id = intval($in['category_id'] ?? 0); + if ($category_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT category_id FROM categories WHERE category_id = $category_id AND category_type = 'Income'"))) { + $category_id = 0; + } + $discount = round(max(0, floatval($in['discount'] ?? 0)), 2); + $auto_send = intval($in['auto_send'] ?? 1) ? 1 : 0; + $currency = escapeSql((string) ($client['client_currency_code'] ?: (mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_currency FROM companies WHERE company_id = 1"))[0] ?? 'USD'))); + + // Each line as billingItemSave() takes it - checked before anything is made + $lines = []; + foreach ($items as $i => $item) { + if (!is_array($item)) { + return [null, "items[$i] is an object - a product_id, or a name and price."]; + } + $product = null; + if (!empty($item['product_id'])) { + $product = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT product_id, product_name, product_description, product_price FROM products + WHERE product_id = " . intval($item['product_id']) . " AND product_archived_at IS NULL")); + if (!$product) { + return [null, "items[$i]: there is no product " . intval($item['product_id']) . " - it may be archived."]; + } + } + $tax = (string) ($item['tax_id'] ?? '0'); + $line = [ + 'name' => trim((string) ($item['name'] ?? ($product['product_name'] ?? ''))), + 'description' => (string) ($item['description'] ?? ($product['product_description'] ?? '')), + 'qty' => (string) ($item['qty'] ?? '1'), + 'price' => (string) ($item['price'] ?? ($product['product_price'] ?? '')), + 'tax_id' => $tax === 'default' ? taxDefaultFor($client_id) : intval($tax), + 'product_id' => intval($product['product_id'] ?? 0), + ]; + if ($line['name'] === '' || $line['price'] === '' || billingNumberInput($line['qty']) === null || billingNumberInput($line['price']) === null || billingNumberInput($line['qty']) <= 0) { + return [null, "items[$i] needs a name, a price and a quantity above 0 - or a product_id for the name and price."]; + } + $lines[] = $line; + } + + mysqli_query($mysqli, "UPDATE settings SET config_recurring_invoice_next_number = LAST_INSERT_ID(config_recurring_invoice_next_number), + config_recurring_invoice_next_number = config_recurring_invoice_next_number + 1 WHERE company_id = 1"); + $number = intval(mysqli_insert_id($mysqli)); + $prefix = escapeSql((string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_recurring_invoice_prefix FROM settings WHERE company_id = 1"))[0] ?? '')); + $scope = escapeSql(mb_substr(trim((string) ($in['scope'] ?? '')), 0, 255)); + $note = mysqli_real_escape_string($mysqli, (string) ($in['note'] ?? '')); + mysqli_query($mysqli, "INSERT INTO recurring_invoices SET recurring_invoice_prefix = '$prefix', recurring_invoice_number = $number, recurring_invoice_scope = '$scope', + recurring_invoice_note = '$note', recurring_invoice_frequency = '$frequency', recurring_invoice_next_date = '$start', recurring_invoice_anchor_day = DAY('$start'), + recurring_invoice_category_id = $category_id, recurring_invoice_status = 1, recurring_invoice_discount_amount = $discount, recurring_invoice_amount = " . (0 - $discount) . ", + recurring_invoice_auto_send = $auto_send, recurring_invoice_currency_code = '$currency', recurring_invoice_client_id = $client_id"); + $recurring_invoice_id = intval(mysqli_insert_id($mysqli)); + if (!$recurring_invoice_id) { + return [null, 'The recurring invoice could not be saved.']; + } + foreach ($lines as $line) { + billingItemSave('recurring', $recurring_invoice_id, $line, 0, $error); + } + billingRecalc('recurring', $recurring_invoice_id); + mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Active', history_description = '" . escapeSql("Recurring Invoice created by $by") . "', history_recurring_invoice_id = $recurring_invoice_id"); + return [$recurring_invoice_id, null]; +} + +/* + * An invoice paid in full, wherever the money came from - recorded by hand, + * paid in the portal or from its link, autopay, an import. triggerCustomAction() + * calls it on 'invoice_pay', which every one of those roads fires. + * + * - A lead that pays becomes a client, while settings.config_invoice_paid_lead_to_client + * is on (Admin > Settings > Invoices and billing, Paid). + * - A ticket waiting on the invoice that is still open gets an internal note + * that it was paid, goes back to Open from On Hold, and the agent it is + * assigned to is told - in ITFlow and by email. Waiting on it: tied to it + * (tickets.ticket_invoice_id - by the API's tickets/create, or a quote's + * tickets going on its invoice) and not billable itself; a billable ticket + * on an invoice is its time being billed, and is left alone. + * + * Nothing until it is paid in full, and each ticket only the first time + * (its history says so). + */ +function invoicePaidFollowUp($invoice_id) { + global $mysqli; + $invoice = billingRow('invoice', $invoice_id, false); + if (!$invoice || $invoice['invoice_status'] !== 'Paid') { + return; + } + $invoice_id = intval($invoice['invoice_id']); + $client_id = intval($invoice['invoice_client_id']); + $number = billingNumber('invoice', $invoice); + $amount = billingMoney($invoice['invoice_amount'], $invoice['invoice_currency_code']); + + // A lead that pays is a client + if ($client_id && settingsColumnExists($mysqli, 'config_invoice_paid_lead_to_client') + && intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_invoice_paid_lead_to_client FROM settings WHERE company_id = 1"))[0] ?? 0) === 1) { + mysqli_query($mysqli, "UPDATE clients SET client_lead = 0 WHERE client_id = $client_id AND client_lead = 1"); + if (mysqli_affected_rows($mysqli) === 1) { + logAudit("Client", "Edit", escapeSql("{$invoice['client_name']} became a client when it paid invoice $number"), $client_id, $client_id); + appNotify("Client", escapeSql("{$invoice['client_name']} paid $number and is a client now - it was a lead"), "/agent/client_overview.php?client_id=$client_id", $client_id); + } + } + + // Tickets waiting on it + $sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_status, ticket_assigned_to FROM tickets + WHERE ticket_invoice_id = $invoice_id AND ticket_billable = 0 AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL"); + while ($ticket = mysqli_fetch_assoc($sql)) { + $ticket_id = intval($ticket['ticket_id']); + $marker = escapeSql("Invoice $number paid"); + if (mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM ticket_history WHERE ticket_history_ticket_id = $ticket_id AND ticket_history_description LIKE '$marker%' LIMIT 1"))) { + continue; + } + $note = escapeSql('

Invoice ' . escapeHtml($number) . ' was paid in full - ' . escapeHtml($amount) . '.

'); + mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$note', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:00:00', ticket_reply_by = 0, ticket_reply_ticket_id = $ticket_id"); + $reopened = intval($ticket['ticket_status']) === TICKET_STATUS_ON_HOLD; + if ($reopened) { + mysqli_query($mysqli, "UPDATE tickets SET ticket_status = " . TICKET_STATUS_OPEN . " WHERE ticket_id = $ticket_id"); + } + logTicketHistory($ticket_id, "Invoice $number paid in full" . ($reopened ? ' - On Hold to Open' : '')); + $row = ticketRow($ticket_id); + $assigned_to = intval($ticket['ticket_assigned_to']); + if ($row && $assigned_to) { + mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = '" . escapeSql("Ticket " . ticketReference($row) . " - $number was paid, so " . $row['ticket_subject'] . " is ready to work on") . "', + notification_action = '" . escapeSql('/agent/' . ticketUrl($ticket_id, $client_id)) . "', notification_client_id = $client_id, notification_user_id = $assigned_to"); + if ($agent = ticketMailAgent($assigned_to)) { + ticketMailAgents([$agent + ['footnote' => 'You get this as the agent ' . ticketReference($row) . ' is assigned to.']], + 'Paid - ' . ticketReference($row) . ' is ready - ' . $row['ticket_subject'], ticketAgentEmailContent($row, 'paid', ['invoice' => $number, 'amount' => $amount, 'reopened' => $reopened])); + } + } + } +} diff --git a/functions/logging.php b/functions/logging.php index 068c9ad5d..2ebde17b8 100644 --- a/functions/logging.php +++ b/functions/logging.php @@ -9,6 +9,10 @@ function triggerCustomAction($trigger, $entity) { if (function_exists('emailCapturing') && emailCapturing()) { return; } + // ITFlow's own follow-up first: an invoice paid in full (invoicePaidFollowUp(), functions/billing.php) + if ($trigger === 'invoice_pay' && function_exists('invoicePaidFollowUp')) { + invoicePaidFollowUp($entity); + } $original_dir = getcwd(); // Save chdir(dirname(__FILE__)); diff --git a/functions/settings.php b/functions/settings.php index a79f76c8c..a26055d11 100644 --- a/functions/settings.php +++ b/functions/settings.php @@ -184,6 +184,7 @@ function settingsFields() { 'config_invoice_late_fee_enable' => ['page' => 'invoice', 'section' => 'Late fees', 'label' => 'Late fees', 'words' => 'late fee overdue charge penalty'] + $on_off, 'config_invoice_late_fee_percent' => ['page' => 'invoice', 'section' => 'Late fees', 'label' => 'Late fee %', 'words' => 'late fee percent'], 'config_invoice_paid_notification_email' => ['page' => 'invoice', 'section' => 'Paid online', 'label' => 'Email invoices paid online to', 'words' => 'paid notification email stripe payment'], + 'config_invoice_paid_lead_to_client' => ['page' => 'invoice', 'section' => 'Paid online', 'label' => 'A lead that pays an invoice becomes a client', 'words' => 'lead client convert paid payment'] + $on_off, 'config_send_invoice_reminders' => ['page' => 'invoice', 'section' => 'Reminders and statements', 'label' => 'Email payment reminders', 'words' => 'overdue reminder dunning past due'] + $on_off, 'reminder_before' => ['page' => 'invoice', 'section' => 'Reminders and statements', 'label' => 'Remind before it is due', 'words' => 'reminder days before due'], 'reminder_after' => ['page' => 'invoice', 'section' => 'Reminders and statements', 'label' => 'Remind after it is due', 'words' => 'reminder days late overdue'], diff --git a/functions/ticket.php b/functions/ticket.php index db252eeb5..416bd08d2 100644 --- a/functions/ticket.php +++ b/functions/ticket.php @@ -854,6 +854,7 @@ function ticketMailAgent($user_id) { * mentioned an internal note mentions them - by (who), message (the note, HTML), reply_id (the note, to open) * sla an SLA alert - track: response or resolution, breached: true once missed, due (when) * scheduled the work was booked - when, where, or cancelled: true + * paid the invoice it waits on was paid - invoice (its number), amount, reopened (On Hold to Open) */ function ticketAgentEmailContent(array $row, $kind, array $opts = []) { $reference = ticketReference($row); @@ -873,6 +874,12 @@ function ticketAgentEmailContent(array $row, $kind, array $opts = []) { $email['message'] = ['html' => (string) $row['ticket_details'], 'label' => 'Details']; $email['summary'] = ['Client' => $client, 'Ticket' => $reference, 'Priority' => (string) $row['ticket_priority']] + (trim((string) ($row['contact_name'] ?? '')) !== '' ? ['Contact' => (string) $row['contact_name']] : []); + } elseif ($kind === 'paid') { + $email['kind'] = 'Paid'; + $email['tone'] = 'success'; + $email['intro'] = [(string) ($opts['invoice'] ?? 'Its invoice') . " for $client was paid in full - " . (string) ($opts['amount'] ?? '') . '.', + "$reference is ready to work on" . (!empty($opts['reopened']) ? ' - it is Open again, from On Hold.' : '.')]; + $email['history'] = ticketEmailHistory($row, ['limit' => 2, 'label' => 'The conversation']); } elseif ($kind === 'replied') { $how = ['portal' => ' in the client portal', 'email' => ' by email'][$opts['how'] ?? ''] ?? ''; $email['kind'] = 'Client replied';