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

This commit is contained in:
johnnyq
2026-11-05 19:00:16 -05:00
parent 294352fd8b
commit b5ceeb4eaf
15 changed files with 465 additions and 10 deletions

View File

@@ -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',

View File

@@ -0,0 +1,76 @@
<?php
/*
* API - Client Portal Invites - Create
* POST /api/v1/portal_invites/create.php
*
* Invites someone by email to a client's portal, as an agent does from the
* client's Contacts: they choose their own password from the link, and are
* made a contact with portal access - or the client's contact with that
* address is given access. Asked again, a waiting invite goes again with a
* new link. It comes from your company rather than the key's agent. Needs
* mail set up, to send it.
*
* Parameters (POST, JSON body):
* api_key required - Your API key
* client_id required - The client
* email required - Who to invite
* name optional - Their name, filled in for them
* billing optional - 1: they join as a billing contact
* technical optional - 1: they join as a technical contact
*
* Answer: invite - "invited", "resent" or "skipped", and why when skipped
* (they sign in already, here or to another account, or the email could
* not be sent).
*/
require_once '../validate_api_key.php';
require_once '../require_post_method.php';
require_once "../../../includes/load_global_settings.php";
$insert_id = false;
$email = trim((string) ($_POST['email'] ?? ''));
$client = $client_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_id = " . intval($client_id) . " AND client_archived_at IS NULL")) : null;
if (!portalInvitesReady()) {
$error = 'Client portal invites need the 3.4.6 database update.';
} elseif (!$client) {
$error = 'client_id is not a client here, or it is archived.';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = 'email is not an email address.';
} else {
$error = '';
}
if ($error !== '') {
$return_arr['success'] = "False";
$return_arr['message'] = $error;
header("HTTP/1.1 400 Bad Request");
echo json_encode($return_arr);
exit();
}
$name = mb_substr(trim(preg_replace('/\s+/', ' ', strip_tags((string) ($_POST['name'] ?? '')))), 0, 200);
$done = portalInviteSend(intval($client_id), [['email' => $email, 'name' => $name]],
// From your company, not the key's agent - "You are invited to the <company> 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';

View File

@@ -0,0 +1,87 @@
<?php
/*
* API - Recurring Invoices - Create
* POST /api/v1/recurring_invoices/create.php
*
* Makes a recurring invoice for a client, with its lines, and - with
* invoice_now - its first invoice straight away, sent so it can be paid.
*
* Parameters (POST, JSON body):
* api_key required - Your API key
* client_id required - The client
* items required - The lines, 1 to 100: [{"product_id": 4, "qty": 2}, {"name": "Setup", "price": 150}]
* each a product_id (its name, description and price unless sent) or a name and price;
* qty (1 if left out), description, and tax_id - a tax's id, or 'default' for the client's usual rate
* frequency optional - month (the default), quarter or year
* start_date optional - The first run, Y-m-d - today if left out
* scope optional - What it is for, shown on each invoice
* note optional - The note each invoice carries
* category_id optional - An income category
* discount optional - An amount off each invoice
* auto_send optional - 1 (the default): each invoice it makes later is emailed by itself; 0: left as a draft
* invoice_now optional - 1: make the first invoice now, in place of the run on start_date
* invoice_send optional - With invoice_now, 1 (the default): email it to the primary and billing contacts
* and mark it Sent, so its link can be paid; 0: leave it a draft
*
* Answer: insert_id (the recurring invoice), recurring_invoice_reference, amount, next_date, and with
* invoice_now an invoice - invoice_id, reference, amount, currency, due, status, url (where it is
* viewed and paid) and emailed_to.
*/
require_once '../validate_api_key.php';
require_once '../require_post_method.php';
require_once "../../../includes/load_global_settings.php";
$insert_id = false;
$by = 'the API (' . html_entity_decode($api_key_name, ENT_QUOTES, 'UTF-8') . ')';
[$recurring_invoice_id, $error] = recurringInvoiceCreate(['client_id' => $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';

View File

@@ -0,0 +1,29 @@
<?php
/*
* API - Recurring Invoices - Read
* GET /api/v1/recurring_invoices/read.php
*
* recurring_invoice_id optional - Just this one, with its lines as items
* client_id optional - Just this client's
* limit, offset optional - Paging
*/
require_once '../validate_api_key.php';
require_once '../require_get_method.php';
if (isset($_GET['recurring_invoice_id'])) {
$id = intval($_GET['recurring_invoice_id']);
$sql = mysqli_query($mysqli, "SELECT * FROM recurring_invoices WHERE recurring_invoice_id = $id " . apiClientScopeSql('recurring_invoice_client_id'));
if ($sql && ($row = mysqli_fetch_assoc($sql))) {
$row['items'] = billingItems('recurring', $id);
echo json_encode(['success' => '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";

View File

@@ -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);
}