Files
itflow/functions/contract.php
johnnyq 1e5bffff3c Rates, net terms and SLAs come from the client's contract, else the instance defaults: database 3.5.0 adds a default after-hours rate (Settings > Defaults, now Rates and terms), gives each client whose own rate, net terms or SLAs differ from the defaults an active Standard terms contract setting just those (12-month term renewing itself, starting the day the client was added or the day before its earliest contract, archived with an archived client), deletes per-client SLA assignments and drops clients.client_rate and client_net_terms; time bills at the contract's rate, its after-hours rate or the default after-hours rate, else the default rate, new invoices are due on the contract's net terms else the default, and new tickets get the contract's SLA else the default; a client's own terms still apply until the update runs; the client add and edit windows lose the rate, terms and SLA fields for a note saying what the client is billed on and where it comes from, the Clients list loses Set Hourly Rate and Set Net Terms and shows the contract's rate, the overview's Terms shows the after-hours rate and whether terms are a contract's or the defaults, a contract's page and document show what it leaves empty from another contract in force or the default so signed documents stay unchanged, contract windows show the defaults as placeholders, contract names take a proper possessive; the client import and API create make a Standard terms contract for a rate or terms that aren't the defaults (returning contract_id), API update reports them in not_kept; the client export gives the rates, terms and SLAs as billed; demo data puts each demo client's terms on a contract; changelog updated
2026-10-27 22:22:34 -04:00

700 lines
36 KiB
PHP

<?php
/*
* Client contracts - the agreement with each client: what kind it is, when
* it starts and ends, whether it renews itself and how much notice ending it
* takes, the SLA for each ticket priority (from Admin > SLAs), the hourly
* rates, net terms and support hours, and who looks after it. A contract is
* a Draft until it is made Active; the nightly job (contractsNightly())
* renews an auto-renewing one when its term ends, ends one that doesn't, and
* reminds its owner before the notice date comes. Cancelled ones are kept.
*
* Templates (Admin > Contract Templates) hold the same terms, to start a
* contract from. agent/contracts.php, agent/contract.php and their windows
* and handlers (agent/post/contract.php) are built on these.
*/
/** The kinds of contract - key => label, icon and what it means. */
const CONTRACT_TYPES = [
'managed' => ['label' => 'Fully managed', 'icon' => 'fa-shield-alt', 'about' => 'A fixed monthly fee covers support and looking after everything'],
'comanaged' => ['label' => 'Co-managed', 'icon' => 'fa-handshake', 'about' => 'Shared with the client\'s own IT people'],
'block' => ['label' => 'Block hours', 'icon' => 'fa-hourglass-half', 'about' => 'Hours paid for up front, drawn down as work is done'],
'time_materials' => ['label' => 'Time and materials', 'icon' => 'fa-clock', 'about' => 'Billed for the time and parts each job takes'],
'break_fix' => ['label' => 'Break/fix', 'icon' => 'fa-wrench', 'about' => 'Fixed when something breaks, billed per job'],
'project' => ['label' => 'Project', 'icon' => 'fa-project-diagram', 'about' => 'One piece of work, with a start and an end'],
'other' => ['label' => 'Other', 'icon' => 'fa-file-contract', 'about' => ''],
];
/** The term lengths offered, in months. Any other number can be typed. */
const CONTRACT_TERMS = [1 => 'Monthly', 3 => '3 months', 6 => '6 months', 12 => '1 year', 24 => '2 years', 36 => '3 years', 60 => '5 years'];
/** How many days before the notice date the owner is reminded. */
const CONTRACT_REMINDER_DAYS = 30;
/** The ticket priorities a contract sets an SLA for - priority => column part. */
const CONTRACT_SLA_PRIORITIES = ['Low' => 'low', 'Medium' => 'medium', 'High' => 'high', 'Urgent' => 'urgent'];
/** Whether the 3.4.2 update has run - the contracts tables as these functions read them. */
function contractsReady() {
global $mysqli;
static $ready = null;
if ($ready === null) {
// The pages define the version; the nightly job and other scripts don't, so read it
if (defined('CURRENT_DATABASE_VERSION')) {
$version = CURRENT_DATABASE_VERSION;
} else {
$result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1");
$version = $result && ($row = mysqli_fetch_row($result)) ? (string) $row[0] : '0';
}
$ready = version_compare($version, '3.4.2', '>=');
}
return $ready;
}
/** A contract with its client, contact, owner and template names - or null. */
function contractRow($contract_id) {
global $mysqli;
$contract_id = intval($contract_id);
if ($contract_id <= 0 || !contractsReady()) {
return null;
}
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contracts.*, client_name, client_currency_code, contact_name, contact_title, contact_email, user_name AS owner_name, user_email AS owner_email,
contract_template_name
FROM contracts
LEFT JOIN clients ON contract_client_id = client_id
LEFT JOIN contacts ON contract_contact_id = contact_id
LEFT JOIN users ON contract_owner_id = user_id
LEFT JOIN contract_templates ON contracts.contract_template_id = contract_templates.contract_template_id
WHERE contract_id = $contract_id LIMIT 1")) ?: null;
}
/** A template, or null. */
function contractTemplateRow($contract_template_id) {
global $mysqli;
$contract_template_id = intval($contract_template_id);
if ($contract_template_id <= 0 || !contractsReady()) {
return null;
}
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM contract_templates WHERE contract_template_id = $contract_template_id LIMIT 1")) ?: null;
}
/* The templates in use - id => row, by name. */
function contractTemplates() {
global $mysqli;
$templates = [];
if (!contractsReady()) {
return $templates;
}
$sql = mysqli_query($mysqli, "SELECT * FROM contract_templates WHERE contract_template_archived_at IS NULL ORDER BY contract_template_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$templates[intval($row['contract_template_id'])] = $row;
}
return $templates;
}
/** A contract's name as its owner, for "Gold's rate" - a name ending in s takes only the apostrophe: "Standard terms' rate". */
function contractPossessive($name) {
$name = (string) $name;
return $name . (preg_match('/s$/i', $name) ? "'" : "'s");
}
/** A type's label - the key's, or the text itself for one ITFlow doesn't know. */
function contractTypeLabel($type) {
return CONTRACT_TYPES[(string) $type]['label'] ?? ((string) $type !== '' ? (string) $type : 'Contract');
}
/** A term in words - "1 year", "18 months". */
function contractTermLabel($months) {
$months = intval($months);
if (isset(CONTRACT_TERMS[$months])) {
return CONTRACT_TERMS[$months];
}
if ($months > 0 && $months % 12 === 0) {
return ($months / 12) . ' years';
}
return $months === 1 ? '1 month' : "$months months";
}
/** The last day of a term that starts on $start and runs $months months - 'Y-m-d'. */
function contractTermEnd($start, $months) {
$start = strtotime((string) $start);
if (!$start || intval($months) <= 0) {
return '';
}
return date('Y-m-d', strtotime('+' . intval($months) . ' months -1 day', $start));
}
/** The day after the last day of the term following one that ends on $end - its new last day. */
function contractNextEnd($end, $months) {
$end = strtotime((string) $end);
if (!$end || intval($months) <= 0) {
return '';
}
return contractTermEnd(date('Y-m-d', strtotime('+1 day', $end)), $months);
}
/** The last day notice can be given to stop it renewing (or to end it) - '' when it has no end date. */
function contractNoticeDate(array $row) {
if (empty($row['contract_end_date'])) {
return '';
}
return date('Y-m-d', strtotime('-' . max(0, intval($row['contract_notice_days'])) . ' days', strtotime($row['contract_end_date'])));
}
/** When the owner is reminded - CONTRACT_REMINDER_DAYS before the notice date. */
function contractReminderDate(array $row) {
$notice = contractNoticeDate($row);
return $notice !== '' ? date('Y-m-d', strtotime('-' . CONTRACT_REMINDER_DAYS . ' days', strtotime($notice))) : '';
}
/*
* Where a contract stands - key, label, Bootstrap tone, icon and a line
* saying when: draft, upcoming (active but not started), active, renewing
* (auto-renews soon), ending (ends soon and won't renew), ended, cancelled.
*/
function contractState(array $row, $today = null) {
$today = $today ?? date('Y-m-d');
$status = (string) $row['contract_status'];
$start = (string) ($row['contract_start_date'] ?? '');
$end = (string) ($row['contract_end_date'] ?? '');
$day = function ($date) {
return date('M j, Y', strtotime($date));
};
if ($status === 'Cancelled') {
return ['key' => 'cancelled', 'label' => 'Cancelled', 'tone' => 'secondary', 'icon' => 'fa-ban',
'when' => $end !== '' && $end >= $today ? 'Cancelled - in force until ' . $day($end) : 'Cancelled' . (!empty($row['contract_cancelled_at']) ? ' ' . $day($row['contract_cancelled_at']) : '')];
}
if ($status === 'Draft') {
return ['key' => 'draft', 'label' => 'Draft', 'tone' => 'secondary', 'icon' => 'fa-pencil-alt', 'when' => 'Not in force yet'];
}
if ($status === 'Ended' || ($end !== '' && $end < $today && !intval($row['contract_auto_renew']))) {
return ['key' => 'ended', 'label' => 'Ended', 'tone' => 'dark', 'icon' => 'fa-flag-checkered', 'when' => $end !== '' ? 'Ended ' . $day($end) : 'Ended'];
}
if ($start !== '' && $start > $today) {
return ['key' => 'upcoming', 'label' => 'Starts soon', 'tone' => 'info', 'icon' => 'fa-hourglass-start', 'when' => 'Starts ' . $day($start)];
}
if ($end === '') {
return ['key' => 'active', 'label' => 'Active', 'tone' => 'success', 'icon' => 'fa-check-circle', 'when' => 'No end date'];
}
$remind = contractReminderDate($row);
if (intval($row['contract_auto_renew'])) {
if ($remind !== '' && $today >= $remind) {
return ['key' => 'renewing', 'label' => 'Renews soon', 'tone' => 'primary', 'icon' => 'fa-sync-alt',
'when' => 'Renews ' . $day(date('Y-m-d', strtotime('+1 day', strtotime($end))))];
}
return ['key' => 'active', 'label' => 'Active', 'tone' => 'success', 'icon' => 'fa-check-circle', 'when' => 'Renews ' . $day(date('Y-m-d', strtotime('+1 day', strtotime($end))))];
}
if ($remind !== '' && $today >= $remind) {
return ['key' => 'ending', 'label' => 'Ends soon', 'tone' => 'warning', 'icon' => 'fa-exclamation-triangle', 'when' => 'Ends ' . $day($end)];
}
return ['key' => 'active', 'label' => 'Active', 'tone' => 'success', 'icon' => 'fa-check-circle', 'when' => 'Ends ' . $day($end)];
}
/** A contract's state as a badge. */
function contractStateHtml(array $row) {
$state = contractState($row);
return '<span class="badge rounded-pill text-bg-' . $state['tone'] . '" title="' . escapeHtml($state['when']) . '"><i class="fas fa-fw ' . $state['icon'] . ' me-1"></i>' . escapeHtml($state['label']) . '</span>';
}
/*
* The list's tabs - key => label, icon and SQL. Due soon is active, with an
* end date within its notice period and a month before it; Signing is out
* for signature or waiting to be countersigned (once 3.4.3 has run).
*/
function contractViews() {
$soon = "contract_status = 'Active' AND contract_end_date IS NOT NULL AND contract_end_date <= CURDATE() + INTERVAL (contract_notice_days + " . CONTRACT_REMINDER_DAYS . ") DAY";
$views = [
'active' => ['label' => 'Active', 'icon' => 'fa-check-circle', 'where' => "contract_status = 'Active'", 'title' => 'In force, or starting soon'],
'soon' => ['label' => 'Due soon', 'icon' => 'fa-hourglass-end', 'where' => $soon, 'title' => 'Renewing or ending - within a month of their notice date'],
];
// Out for signature, or signed by the client and waiting for us - a request not withdrawn and not countersigned (3.4.3 on)
if (function_exists('contractSignaturesReady') && contractSignaturesReady()) {
$views['signing'] = ['label' => 'Signing', 'icon' => 'fa-file-signature', 'title' => 'Out for signature, or waiting for you to countersign',
'where' => "EXISTS (SELECT 1 FROM contract_signatures WHERE contract_signature_contract_id = contract_id AND contract_signature_sent_at IS NOT NULL
AND contract_signature_withdrawn_at IS NULL AND contract_signature_agent_signed_at IS NULL AND contract_signature_url_key <> '')"];
}
return $views + [
'draft' => ['label' => 'Drafts', 'icon' => 'fa-pencil-alt', 'where' => "contract_status = 'Draft'", 'title' => 'Not in force yet'],
'ended' => ['label' => 'Ended', 'icon' => 'fa-flag-checkered', 'where' => "contract_status IN ('Ended', 'Cancelled')", 'title' => 'Ended or cancelled'],
'all' => ['label' => 'All', 'icon' => '', 'where' => '1 = 1'],
];
}
/* The SLAs a contract can pick - id => row, the ones in use and any it already has. */
function contractSlaChoices(array $keep = []) {
global $mysqli;
$keep = array_filter(array_map('intval', $keep));
$choices = [];
$sql = mysqli_query($mysqli, "SELECT sla_id, sla_name, sla_response_minutes, sla_resolution_minutes, sla_archived_at FROM slas
WHERE sla_archived_at IS NULL" . ($keep ? " OR sla_id IN (" . implode(',', $keep) . ")" : '') . " ORDER BY sla_response_minutes ASC, sla_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$choices[intval($row['sla_id'])] = $row;
}
return $choices;
}
/*
* What a contract's or template's field falls back to when left empty - the
* defaults (Admin > Settings > Defaults and Admin > SLAs) - for its
* placeholder: rate, after_hours and net_terms, and slas by priority.
*/
function contractDefaultHints() {
$defaults = billingDefaults();
$hints = [
'rate' => 'Default: ' . number_format($defaults['rate'], 2, '.', ''),
'after_hours' => $defaults['after_hours'] > 0 ? 'Default: ' . number_format($defaults['after_hours'], 2, '.', '') : 'The hourly rate',
'net_terms' => $defaults['net_terms'] > 0 ? 'Default: ' . $defaults['net_terms'] : 'Default: on receipt',
'slas' => [],
];
$choices = contractSlaChoices();
foreach (CONTRACT_SLA_PRIORITIES as $priority => $part) {
$sla_id = intval(getTicketSlaId(0, $priority, false));
$hints['slas'][$priority] = isset($choices[$sla_id]) ? 'The default - ' . $choices[$sla_id]['sla_name'] : 'The default - none';
}
return $hints;
}
/** An SLA's targets in words - "Respond in 1 hr, resolve in 8 hrs". */
function contractSlaTargets(array $sla) {
$text = 'Respond in ' . slaMinutesText($sla['sla_response_minutes']);
if (intval($sla['sla_resolution_minutes'] ?? 0) > 0) {
$text .= ', resolve in ' . slaMinutesText($sla['sla_resolution_minutes']);
}
return $text;
}
/** Minutes in words - "45 min", "4 hrs", "2 days". */
function slaMinutesText($minutes) {
$minutes = intval($minutes);
if ($minutes > 0 && $minutes % 1440 === 0 && $minutes >= 2880) {
return ($minutes / 1440) . ' days';
}
if ($minutes >= 60 && $minutes % 60 === 0) {
return ($minutes / 60) . ($minutes === 60 ? ' hr' : ' hrs');
}
if ($minutes >= 60) {
return round($minutes / 60, 1) . ' hrs';
}
return "$minutes min";
}
/*
* The fields of a contract or a template, from a form - [$fields, $error].
* $kind 'contract' or 'template'. Money and days that are left empty are
* null: the default rate and terms apply (Admin > Settings > Defaults).
*/
function contractFromInput(array $in, $kind = 'contract') {
global $mysqli;
$text = function ($key, $max) use ($in) {
return mb_substr(trim(preg_replace('/\s+/u', ' ', strip_tags((string) ($in[$key] ?? '')))), 0, $max);
};
$money = function ($key) use ($in) {
$value = trim((string) ($in[$key] ?? ''));
if ($value === '') {
return null;
}
$number = function_exists('billingNumberInput') ? billingNumberInput($value) : (is_numeric($value) ? floatval($value) : null);
// A rate of nothing is no rate - the default applies
return $number === null ? false : (round($number, 2) == 0 ? null : round($number, 2));
};
$hours = function ($key) use ($in) {
$value = trim(str_replace(',', '', (string) ($in[$key] ?? '')));
return $value === '' ? 0.0 : (is_numeric($value) ? round(floatval($value), 2) : false);
};
$f = [
'name' => $text('name', 255),
'type' => isset(CONTRACT_TYPES[(string) ($in['type'] ?? '')]) ? (string) $in['type'] : 'managed',
'description' => $text('description', 1000),
'term_months' => intval($in['term_months'] ?? 12),
'auto_renew' => !empty($in['auto_renew']) ? 1 : 0,
'notice_days' => intval($in['notice_days'] ?? 30),
'rate_standard' => $money('rate_standard'),
'rate_after_hours' => $money('rate_after_hours'),
'net_terms' => trim((string) ($in['net_terms'] ?? '')) === '' ? null : intval($in['net_terms']),
'support_hours' => $text('support_hours', 100),
'details' => isset($in['details']) ? documentHtmlPurify((string) $in['details']) : null,
'sla' => [],
// Prepaid hours (functions/contract_hours.php) - included each month, and how few left is low
'monthly_hours' => $hours('monthly_hours'),
'hours_alert' => $hours('hours_alert'),
];
foreach (CONTRACT_SLA_PRIORITIES as $priority => $part) {
$f['sla'][$part] = intval($in["sla_$part"] ?? 0);
}
if ($kind === 'contract') {
$f += [
'client_id' => intval($in['client_id'] ?? 0),
'contact_id' => intval($in['contact_id'] ?? 0),
'owner_id' => intval($in['owner_id'] ?? 0),
'template_id' => intval($in['template_id'] ?? 0),
'status' => in_array($in['status'] ?? '', ['Draft', 'Active'], true) ? $in['status'] : 'Draft',
'start_date' => trim((string) ($in['start_date'] ?? '')),
'end_date' => trim((string) ($in['end_date'] ?? '')),
'notes' => mb_substr(trim((string) ($in['notes'] ?? '')), 0, 10000),
];
}
if ($f['name'] === '') {
return [$f, 'Give it a name.'];
}
if ($f['term_months'] < 1 || $f['term_months'] > 120) {
return [$f, 'A term is 1 to 120 months.'];
}
if ($f['notice_days'] < 0 || $f['notice_days'] > 365) {
return [$f, 'Notice is 0 to 365 days.'];
}
if ($f['rate_standard'] === false || $f['rate_after_hours'] === false || ($f['rate_standard'] ?? 0) < 0 || ($f['rate_after_hours'] ?? 0) < 0) {
return [$f, 'A rate is an amount, such as 125.00 - or leave it empty for the default.'];
}
if ($f['net_terms'] !== null && ($f['net_terms'] < 0 || $f['net_terms'] > 365)) {
return [$f, 'Net terms are 0 to 365 days - or leave them empty for the default.'];
}
if ($f['monthly_hours'] === false || $f['monthly_hours'] < 0 || $f['monthly_hours'] >= 1000) {
return [$f, 'Hours each month are a number of hours under 1,000, such as 10 or 7.5 - or leave it empty for none.'];
}
if ($f['hours_alert'] === false || $f['hours_alert'] < 0 || $f['hours_alert'] >= 10000) {
return [$f, 'The hours to be told at are a number of hours, such as 2 - or leave it empty to not be told.'];
}
$slas = contractSlaChoices(array_values($f['sla']));
foreach ($f['sla'] as $part => $sla_id) {
if ($sla_id && !isset($slas[$sla_id])) {
return [$f, 'Pick an SLA from the list.'];
}
}
if ($kind === 'contract') {
if (!$f['client_id'] || !mysqli_num_rows(mysqli_query($mysqli, "SELECT client_id FROM clients WHERE client_id = {$f['client_id']} AND client_archived_at IS NULL" . clientScopeSql('client_id')))) {
return [$f, 'Pick a client you can open.'];
}
foreach (['start_date', 'end_date'] as $date) {
if ($f[$date] !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $f[$date]) || !strtotime($f[$date]))) {
return [$f, 'A date is a day, like 2026-11-01.'];
}
}
if ($f['start_date'] !== '' && $f['end_date'] !== '' && $f['end_date'] < $f['start_date']) {
return [$f, 'It ends before it starts.'];
}
if ($f['status'] === 'Active' && $f['start_date'] === '') {
return [$f, 'An active contract needs a start date.'];
}
if ($f['contact_id'] && !mysqli_num_rows(mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_id = {$f['contact_id']} AND contact_client_id = {$f['client_id']}"))) {
return [$f, 'Pick one of the client\'s contacts.'];
}
if ($f['owner_id'] && !mysqli_num_rows(mysqli_query($mysqli, "SELECT user_id FROM users WHERE user_id = {$f['owner_id']} AND user_type = 1 AND user_archived_at IS NULL"))) {
return [$f, 'Pick an agent to look after it.'];
}
if ($f['template_id'] && !contractTemplateRow($f['template_id'])) {
$f['template_id'] = 0;
}
}
return [$f, ''];
}
/** The SET clause shared by contracts and templates - $prefix 'contract' or 'contract_template'. */
function contractTermsSetSql(array $f, $prefix) {
global $mysqli;
$money = function ($value) {
return $value === null ? 'NULL' : sprintf('%.2f', $value);
};
$sql = [
"{$prefix}_name = '" . escapeSql($f['name']) . "'",
"{$prefix}_type = '" . escapeSql($f['type']) . "'",
"{$prefix}_term_months = {$f['term_months']}",
"{$prefix}_auto_renew = {$f['auto_renew']}",
"{$prefix}_notice_days = {$f['notice_days']}",
"{$prefix}_rate_standard = " . $money($f['rate_standard']),
"{$prefix}_rate_after_hours = " . $money($f['rate_after_hours']),
"{$prefix}_net_terms = " . ($f['net_terms'] === null ? 'NULL' : intval($f['net_terms'])),
"{$prefix}_support_hours = " . ($f['support_hours'] !== '' ? "'" . escapeSql($f['support_hours']) . "'" : 'NULL'),
];
foreach ($f['sla'] as $part => $sla_id) {
$sql[] = "{$prefix}_sla_{$part}_id = " . intval($sla_id);
}
if (function_exists('contractHoursReady') && contractHoursReady()) {
$sql[] = "{$prefix}_monthly_hours = " . sprintf('%.2f', $f['monthly_hours'] ?? 0);
$sql[] = "{$prefix}_hours_alert = " . sprintf('%.2f', $f['hours_alert'] ?? 0);
}
if ($f['details'] !== null) {
// Purified HTML - escapeSql() would strip its tags
$sql[] = "{$prefix}_details = '" . mysqli_real_escape_string($mysqli, $f['details']) . "'";
}
return implode(', ', $sql);
}
/** The SET clause for a contract's own fields. */
function contractSetSql(array $f) {
global $mysqli;
// The notes are plain text, shown escaped - kept as written, < and all
return contractTermsSetSql($f, 'contract') . ",
contract_client_id = {$f['client_id']}, contract_contact_id = {$f['contact_id']}, contract_owner_id = {$f['owner_id']}, contract_template_id = {$f['template_id']},
contract_start_date = " . ($f['start_date'] !== '' ? "'" . escapeSql($f['start_date']) . "'" : 'NULL') . ",
contract_end_date = " . ($f['end_date'] !== '' ? "'" . escapeSql($f['end_date']) . "'" : 'NULL') . ",
contract_notes = " . ($f['notes'] !== '' ? "'" . mysqli_real_escape_string($mysqli, $f['notes']) . "'" : 'NULL');
}
/*
* A client's Standard terms - the hourly rate, net terms and SLAs it is
* billed on that aren't the defaults, as an active contract of their own
* that renews itself each year, in force from $start (the update that took
* them off the client used the day the client was added). Only what differs
* from the defaults is set on it, and when nothing does, none is made. $rate
* and $net_terms null for the default; $slas priority => SLA id. The new
* contract's id, or 0.
*/
function contractStandardTermsAdd($client_id, $rate, $net_terms, array $slas = [], $start = null, $notes = '') {
$defaults = billingDefaults();
$in = ['name' => 'Standard terms', 'type' => 'other', 'term_months' => 12, 'auto_renew' => 1, 'notice_days' => 0,
'client_id' => intval($client_id), 'status' => 'Active', 'notes' => $notes, 'rate_standard' => '', 'net_terms' => ''];
$set = false;
if ($rate !== null && round(floatval($rate), 2) > 0 && round(floatval($rate), 2) != $defaults['rate']) {
$in['rate_standard'] = sprintf('%.2f', $rate);
$set = true;
}
if ($net_terms !== null && intval($net_terms) !== $defaults['net_terms']) {
$in['net_terms'] = (string) intval($net_terms);
$set = true;
}
foreach (CONTRACT_SLA_PRIORITIES as $priority => $part) {
$sla_id = intval($slas[$priority] ?? 0);
if ($sla_id && $sla_id !== intval(getTicketSlaId(0, $priority, false))) {
$in["sla_$part"] = $sla_id;
$set = true;
}
}
if (!$set) {
return 0;
}
// In force from $start, its term carried on a year at a time to the one running today
$in['start_date'] = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $start) ? $start : date('Y-m-d');
$end = contractTermEnd($in['start_date'], 12);
while ($end !== '' && $end < date('Y-m-d')) {
$end = contractNextEnd($end, 12);
}
$in['end_date'] = $end;
[$f, $error] = contractFromInput($in, 'contract');
if ($error !== '') {
return 0;
}
return contractCreate($f);
}
/** A new contract - its id. */
function contractCreate(array $f) {
global $mysqli, $session_user_id;
mysqli_query($mysqli, "INSERT INTO contracts SET " . contractSetSql($f) . ", contract_status = '" . escapeSql($f['status']) . "', contract_created_by = " . intval($session_user_id ?? 0));
return intval(mysqli_insert_id($mysqli));
}
/*
* Save a contract's fields - what changed, as phrases for the audit log. A
* new end date means a new term, so the owner is reminded again.
*/
function contractUpdate(array $row, array $f) {
global $mysqli;
$id = intval($row['contract_id']);
$reset = (string) $row['contract_end_date'] !== $f['end_date'] ? ', contract_reminded_at = NULL' : '';
$hours = function_exists('contractHoursReady') && contractHoursReady() && array_key_exists('contract_hours_alert', $row);
// A new level to be told at is told again at it
$reset .= $hours && round(floatval($row['contract_hours_alert']), 2) !== round($f['hours_alert'], 2) ? ', contract_hours_alerted_at = NULL' : '';
mysqli_query($mysqli, "UPDATE contracts SET " . contractSetSql($f) . "$reset WHERE contract_id = $id");
$changes = [];
$was = [
'name' => (string) $row['contract_name'], 'type' => contractTypeLabel($row['contract_type']), 'start' => (string) $row['contract_start_date'], 'end' => (string) $row['contract_end_date'],
'term' => contractTermLabel($row['contract_term_months']), 'notice' => intval($row['contract_notice_days']) . ' days notice',
'standard rate' => $row['contract_rate_standard'] === null ? 'the default' : sprintf('%.2f', $row['contract_rate_standard']),
'after-hours rate' => $row['contract_rate_after_hours'] === null ? 'the default' : sprintf('%.2f', $row['contract_rate_after_hours']),
'net terms' => $row['contract_net_terms'] === null ? 'the default' : intval($row['contract_net_terms']) . ' days',
'support hours' => (string) $row['contract_support_hours'],
];
if ($hours) {
$was += ['hours each month' => contractHoursText($row['contract_monthly_hours']), 'hours to be told at' => contractHoursText($row['contract_hours_alert'])];
}
$now = [
'name' => $f['name'], 'type' => contractTypeLabel($f['type']), 'start' => $f['start_date'], 'end' => $f['end_date'],
'term' => contractTermLabel($f['term_months']), 'notice' => $f['notice_days'] . ' days notice',
'standard rate' => $f['rate_standard'] === null ? 'the default' : sprintf('%.2f', $f['rate_standard']),
'after-hours rate' => $f['rate_after_hours'] === null ? 'the default' : sprintf('%.2f', $f['rate_after_hours']),
'net terms' => $f['net_terms'] === null ? 'the default' : $f['net_terms'] . ' days',
'support hours' => $f['support_hours'],
];
if ($hours) {
$now += ['hours each month' => contractHoursText($f['monthly_hours']), 'hours to be told at' => contractHoursText($f['hours_alert'])];
}
foreach ($was as $label => $value) {
if ($value !== $now[$label]) {
$changes[] = "$label from " . ($value !== '' ? $value : 'none') . ' to ' . ($now[$label] !== '' ? $now[$label] : 'none');
}
}
if (intval($row['contract_auto_renew']) !== $f['auto_renew']) {
$changes[] = $f['auto_renew'] ? 'renews itself' : 'no longer renews itself';
}
foreach ($f['sla'] as $part => $sla_id) {
if (intval($row["contract_sla_{$part}_id"]) !== $sla_id) {
$changes[] = "$part priority SLA changed";
}
}
if (intval($row['contract_owner_id']) !== $f['owner_id']) {
$changes[] = 'owner changed';
}
if (intval($row['contract_contact_id']) !== $f['contact_id']) {
$changes[] = 'contact changed';
}
if (trim((string) $row['contract_notes']) !== $f['notes']) {
$changes[] = 'notes changed';
}
if ($f['details'] !== null && (string) $row['contract_details'] !== $f['details']) {
$changes[] = 'terms changed';
}
return $changes;
}
/* Make a draft active from $start (a 'Y-m-d'), with an end date from its term when it has none. */
function contractActivate(array $row, $start) {
global $mysqli;
$end = !empty($row['contract_end_date']) ? (string) $row['contract_end_date'] : contractTermEnd($start, $row['contract_term_months']);
mysqli_query($mysqli, "UPDATE contracts SET contract_status = 'Active', contract_start_date = '" . escapeSql($start) . "', contract_end_date = " . ($end !== '' ? "'" . escapeSql($end) . "'" : 'NULL') . ",
contract_reminded_at = NULL WHERE contract_id = " . intval($row['contract_id']));
return $end;
}
/*
* Renew a contract to a new last day - an active one goes on, an ended or
* cancelled one is active again. The owner will be reminded again before
* the new term ends.
*/
function contractRenew(array $row, $new_end) {
global $mysqli;
mysqli_query($mysqli, "UPDATE contracts SET contract_status = 'Active', contract_end_date = '" . escapeSql($new_end) . "', contract_renewed_at = NOW(), contract_reminded_at = NULL,
contract_cancelled_at = NULL, contract_cancel_reason = NULL WHERE contract_id = " . intval($row['contract_id']));
}
/* Cancel a contract - its last day is $last_day (it stays in force until then), and why. */
function contractCancel(array $row, $last_day, $reason) {
global $mysqli;
mysqli_query($mysqli, "UPDATE contracts SET contract_status = 'Cancelled', contract_cancelled_at = NOW(), contract_end_date = '" . escapeSql($last_day) . "', contract_auto_renew = 0,
contract_cancel_reason = " . ($reason !== '' ? "'" . escapeSql($reason) . "'" : 'NULL') . " WHERE contract_id = " . intval($row['contract_id']));
}
/*
* The night's work, run by cron/nightly_tasks.php - [renewed, ended,
* reminded, drawn, paused] for the log:
* - an active contract whose last day has passed renews for another term
* when it renews itself (as many terms as it takes to reach today), and
* ends when it doesn't;
* - the owner of one coming up to its notice date is reminded, once a term,
* by email and in ITFlow - or everyone, when it has no owner;
* - prepaid hours: each month's included hours are added, and the tickets
* done since are drawn from them (contractHoursNightly());
* - the recurring invoice of one that ended, or is past a cancellation's
* last day, is paused (contractRecurringNightly()).
*/
function contractsNightly($today = null) {
global $mysqli;
$today = $today ?? date('Y-m-d');
$done = ['renewed' => 0, 'ended' => 0, 'reminded' => 0, 'drawn' => 0, 'paused' => 0];
if (!contractsReady()) {
return $done;
}
$sql = mysqli_query($mysqli, "SELECT contract_id FROM contracts WHERE contract_status = 'Active' AND contract_archived_at IS NULL
AND contract_end_date IS NOT NULL AND contract_end_date < '" . escapeSql($today) . "'");
while ($id = mysqli_fetch_row($sql)) {
$row = contractRow($id[0]);
if (!$row) {
continue;
}
$name = escapeSql($row['contract_name']);
if (intval($row['contract_auto_renew'])) {
$end = (string) $row['contract_end_date'];
while ($end !== '' && $end < $today) {
$end = contractNextEnd($end, $row['contract_term_months']);
}
contractRenew($row, $end);
logAudit("Contract", "Edit", "Contract $name renewed itself to " . escapeSql($end), intval($row['contract_client_id']), intval($row['contract_id']));
$done['renewed']++;
} else {
mysqli_query($mysqli, "UPDATE contracts SET contract_status = 'Ended' WHERE contract_id = " . intval($row['contract_id']));
logAudit("Contract", "Edit", "Contract $name ended on " . escapeSql($row['contract_end_date']), intval($row['contract_client_id']), intval($row['contract_id']));
appNotify("Contract", "Contract $name for " . escapeSql($row['client_name']) . " has ended", "/agent/contract.php?client_id=" . intval($row['contract_client_id']) . "&contract_id=" . intval($row['contract_id']), intval($row['contract_client_id']), intval($row['contract_id']));
$done['ended']++;
}
}
$sql = mysqli_query($mysqli, "SELECT contract_id FROM contracts WHERE contract_status = 'Active' AND contract_archived_at IS NULL AND contract_reminded_at IS NULL
AND contract_end_date IS NOT NULL AND contract_end_date >= '" . escapeSql($today) . "'
AND contract_end_date <= '" . escapeSql($today) . "' + INTERVAL (contract_notice_days + " . CONTRACT_REMINDER_DAYS . ") DAY");
while ($id = mysqli_fetch_row($sql)) {
$row = contractRow($id[0]);
if (!$row) {
continue;
}
contractRemind($row);
mysqli_query($mysqli, "UPDATE contracts SET contract_reminded_at = NOW() WHERE contract_id = " . intval($row['contract_id']));
$done['reminded']++;
}
// Prepaid hours: each month's included hours, and the tickets done since drawn from them
if (function_exists('contractHoursNightly')) {
$done['drawn'] = contractHoursNightly($today);
}
// Recurring invoices of contracts that ended, or are past a cancellation's last day, are paused
if (function_exists('contractRecurringNightly')) {
$done['paused'] = contractRecurringNightly($today);
}
return $done;
}
/* Tell the owner a contract is coming up to its notice date - in ITFlow, and by email. */
function contractRemind(array $row) {
global $config_base_url;
$client_id = intval($row['contract_client_id']);
$url = "/agent/contract.php?client_id=$client_id&contract_id=" . intval($row['contract_id']);
$renews = intval($row['contract_auto_renew']) === 1;
$notice = contractNoticeDate($row);
// A notice date already gone by isn't a date to give notice by - say it has passed
$notice_open = $notice !== '' && $notice >= date('Y-m-d');
$end = (string) $row['contract_end_date'];
$what = $renews ? 'renews itself on ' . date('M j, Y', strtotime('+1 day', strtotime($end))) : 'ends on ' . date('M j, Y', strtotime($end));
appNotify("Contract", escapeSql("Contract {$row['contract_name']} for {$row['client_name']} $what" . ($notice_open ? ' - notice by ' . date('M j, Y', strtotime($notice)) : '')), $url, $client_id, intval($row['contract_id']));
if (!empty($row['owner_email'])) {
$passed = $notice !== '' && !$notice_open ? ' Its notice date, ' . date('F j, Y', strtotime($notice)) . ', has passed.' : '';
emailQueue([['email' => $row['owner_email'], 'name' => $row['owner_name'], 'footnote' => 'You look after this contract in ITFlow.']], "Contract " . ($renews ? 'renewal' : 'ending') . ": {$row['contract_name']} - {$row['client_name']}", [
'kind' => 'Contract', 'tone' => $renews ? 'primary' : 'warning',
'title' => $row['contract_name'] . ' ' . $what,
'intro' => [$renews
? "It renews for another " . contractTermLabel($row['contract_term_months']) . ($notice_open ? " unless notice is given by " . date('F j, Y', strtotime($notice)) . ". Now is the time to review it with the client." : ".$passed")
: "It doesn't renew itself. Renew it, or let the client know it is ending" . ($notice_open ? " - notice is due by " . date('F j, Y', strtotime($notice)) . "." : ".$passed")],
'summary' => array_filter([
'Client' => (string) $row['client_name'],
'Type' => contractTypeLabel($row['contract_type']),
'Term' => (!empty($row['contract_start_date']) ? date('M j, Y', strtotime($row['contract_start_date'])) . ' to ' : 'Until ') . date('M j, Y', strtotime($end)),
'Notice' => $notice !== '' ? ($notice_open ? 'By ' : 'Passed - ') . date('M j, Y', strtotime($notice)) : '',
]),
'button' => ['label' => 'Open the contract', 'url' => "https://$config_base_url$url"],
]);
}
}
/* How many contracts a client has that are active - for the client menu. */
function contractClientCount($client_id) {
global $mysqli;
if (!contractsReady()) {
return 0;
}
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM contracts WHERE contract_client_id = " . intval($client_id) . " AND contract_status = 'Active' AND contract_archived_at IS NULL"))[0]);
}
/* A client's contracts in force now, soonest ending first - for the overview. */
function contractClientActive($client_id) {
global $mysqli;
$rows = [];
if (!contractsReady()) {
return $rows;
}
$sql = mysqli_query($mysqli, "SELECT * FROM contracts WHERE contract_client_id = " . intval($client_id) . " AND contract_status = 'Active' AND contract_archived_at IS NULL
ORDER BY contract_end_date IS NULL, contract_end_date ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}