"the client's name", 'Client Address' => "the client's main address, on one line", 'Contact' => 'the contact on the contract', 'Contact Title' => "the contact's job title", 'Contact Email' => "the contact's email", 'Company' => 'your company', 'Contract' => "the contract's name", 'Type' => 'what kind of contract - Fully managed', 'Start Date' => 'the day it starts', 'End Date' => 'its last day', 'Term' => 'how long it runs - 1 year', 'Renewal' => 'whether it renews itself, in a sentence', 'Notice' => 'the notice to end it - 30 days', 'Support Hours' => 'its support hours', 'Monthly Hours' => 'the hours of support included each month - 10 hours', 'Rate' => 'the hourly rate', 'After Hours Rate' => 'the after-hours rate', 'Net Terms' => 'when invoices are due - Net 30', 'Owner' => 'who looks after it for you', 'SLA' => 'on a line of its own, the response times for each priority as a table', 'Date' => 'the day it is sent', ]; /** The largest signature picture taken, in bytes. */ const CONTRACT_SIGNATURE_MAX_BYTES = 400000; /** Whether the 3.4.3 update has run - contract_signatures is there. */ function contractSignaturesReady() { global $mysqli; static $ready = null; if ($ready === null) { 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.3', '>='); } return $ready; } /* ---------------------------------------------------------------- the document */ /** A contract's money in the client's currency - "$165.00". */ function contractMoney($amount, array $row) { $currency = (string) ($row['client_currency_code'] ?? ''); if ($currency === '') { $currency = (string) (billingCompany()['company_currency'] ?? ''); } return billingMoney($amount, $currency); } /** Net terms in words - "Net 30", "Due on receipt". */ function contractNetTermsText($days) { $days = intval($days); return $days > 0 ? "Net $days" : 'Due on receipt'; } /* * The SLA for each priority as the contract sets it - its own, or what * applies when it sets none on $date (contractUsualTerms() - another contract * in force for the client, else the default from Admin > SLAs). [priority => * ['sla' => row or null, 'own' => whether the contract sets it, 'from' => * where one it doesn't set comes from - a contract's name, "the client's" or * '' for the default]]. */ function contractSlaRows(array $row, $date = null) { $own = []; foreach (CONTRACT_SLA_PRIORITIES as $part) { $own[] = intval($row["contract_sla_{$part}_id"] ?? 0); } $usual_slas = contractUsualTerms($row, $date)['slas']; $usual = []; foreach (CONTRACT_SLA_PRIORITIES as $priority => $part) { $usual[$priority] = intval($row["contract_sla_{$part}_id"] ?? 0) ? 0 : intval($usual_slas[$priority][0]); } $slas = contractSlaChoices(array_merge($own, array_values($usual))); $rows = []; foreach (CONTRACT_SLA_PRIORITIES as $priority => $part) { $id = intval($row["contract_sla_{$part}_id"] ?? 0); $rows[$priority] = ['sla' => $slas[$id ?: $usual[$priority]] ?? null, 'own' => $id > 0, 'from' => $id > 0 ? '' : (string) $usual_slas[$priority][1]]; } return $rows; } /** The response times as a table - for [SLA] and the document's own section - as of $date. */ function contractSlaTableHtml(array $row, $date = null) { $html = '' . ''; foreach (contractSlaRows($row, $date) as $priority => $item) { $sla = $item['sla']; $html .= ''; if ($sla) { $html .= '' . ''; } else { $html .= ''; } $html .= ''; } return $html . '
PriorityRespond withinResolve within
' . escapeHtml($priority) . '' . escapeHtml(slaMinutesText($sla['sla_response_minutes'])) . '' . (intval($sla['sla_resolution_minutes']) > 0 ? escapeHtml(slaMinutesText($sla['sla_resolution_minutes'])) : 'No set time') . 'No set timeNo set time
'; } /** What the placeholders become for a contract - lowercase name => value, raw. $as_of is the day it is sent ('Y-m-d'). */ function contractPlaceholderValues(array $row, $as_of = null) { global $mysqli; $as_of = $as_of ?: date('Y-m-d'); $company = billingCompany(); $bill_to = billingBillTo(intval($row['contract_client_id'])); $contact = intval($row['contract_contact_id']) ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name, contact_title, contact_email FROM contacts WHERE contact_id = " . intval($row['contract_contact_id']) . " AND contact_client_id = " . intval($row['contract_client_id']))) : null; $day = function ($date) { return $date ? date('M j, Y', strtotime($date)) : ''; }; $start = (string) ($row['contract_start_date'] ?? ''); $end = (string) ($row['contract_end_date'] ?? ''); if ($end === '' && $start !== '') { $end = contractTermEnd($start, $row['contract_term_months']); } $term = contractTermLabel($row['contract_term_months']); $notice = intval($row['contract_notice_days']); // What the contract leaves empty is what applies to the client on the day - another contract in force, else the default $usual = contractUsualTerms($row, $as_of); $rate = $row['contract_rate_standard'] !== null ? $row['contract_rate_standard'] : $usual['rate'][0]; $after = $row['contract_rate_after_hours'] !== null ? $row['contract_rate_after_hours'] : $usual['after_hours'][0]; $net = $row['contract_net_terms'] !== null ? $row['contract_net_terms'] : $usual['net_terms'][0]; $slas = []; foreach (contractSlaRows($row, $as_of) as $priority => $item) { if ($item['sla']) { $slas[] = "$priority: " . strtolower(contractSlaTargets($item['sla'])); } } return [ 'client' => (string) ($row['client_name'] ?? ''), 'client address' => formatAddress($bill_to['location_address'] ?? '', $bill_to['location_city'] ?? '', $bill_to['location_state'] ?? '', $bill_to['location_zip'] ?? '', $bill_to['location_country'] ?? '', ', '), 'contact' => (string) ($contact['contact_name'] ?? ''), 'contact title' => (string) ($contact['contact_title'] ?? ''), 'contact email' => (string) ($contact['contact_email'] ?? ''), 'company' => (string) ($company['company_name'] ?? ''), 'contract' => (string) $row['contract_name'], 'type' => contractTypeLabel($row['contract_type']), 'start date' => $day($start), 'end date' => $day($end), 'term' => $term, 'renewal' => intval($row['contract_auto_renew']) ? "It renews itself for another $term" . ($notice ? " unless notice is given at least $notice days before it ends." : '.') : 'It ends on its last day unless it is renewed.', 'notice' => $notice ? "$notice days" : 'none', 'support hours' => (string) ($row['contract_support_hours'] ?? ''), 'monthly hours' => floatval($row['contract_monthly_hours'] ?? 0) > 0 ? contractHoursWords($row['contract_monthly_hours']) : '', 'rate' => $rate !== null && $rate !== '' ? contractMoney($rate, $row) : '', 'after hours rate' => $after !== null ? contractMoney($after, $row) : '', 'net terms' => $net !== null && $net !== '' ? contractNetTermsText($net) : '', 'owner' => (string) ($row['owner_name'] ?? ''), 'sla' => implode('; ', $slas), 'date' => $day($as_of), ]; } /* * The terms with their placeholders filled in - [SLA] on a line of its own * becomes the table of response times, in a sentence the times in words. A * [word] with nothing to put in stays as it is. $html is already made safe. */ function contractFillTerms($html, array $row, $as_of = null) { $space = '(?:\s| |\x{00A0})'; $html = preg_replace_callback('/<(p|div|h[1-6])\b[^>]*>' . $space . '*\[SLA\]' . $space . '*<\/\1>/iu', function () use ($row, $as_of) { return contractSlaTableHtml($row, $as_of); }, (string) $html); return ticketPlaceholderFill($html, contractPlaceholderValues($row, $as_of), true); } /** The [words] left in filled-in terms - placeholders with nothing to put in, or words that aren't placeholders. */ function contractUnfilled($html) { return documentTemplateBracketsIn($html); } /* * The contract as a document - as of $as_of, the day it is sent (today when * not given). Escaped here; its terms are purified HTML. Classes only, no * styles: includes/contract_paper.php and the PDF dress it. */ function contractDocumentHtml(array $row, $as_of = null) { $company = billingCompany(); $bill_to = billingBillTo(intval($row['contract_client_id'])); $values = contractPlaceholderValues($row, $as_of); $e = function ($value) { return escapeHtml((string) $value); }; $from_lines = array_filter([ formatAddress($company['company_address'] ?? '', $company['company_city'] ?? '', $company['company_state'] ?? '', $company['company_zip'] ?? '', $company['company_country'] ?? '', "\n"), implode(' · ', array_filter([$company['company_email'] ?? '', $company['phone_display'] ?? ''])), ]); $to_lines = array_filter([ $values['client address'] !== '' ? formatAddress($bill_to['location_address'] ?? '', $bill_to['location_city'] ?? '', $bill_to['location_state'] ?? '', $bill_to['location_zip'] ?? '', $bill_to['location_country'] ?? '', "\n") : '', $values['contact'] !== '' ? 'Attention: ' . $values['contact'] . ($values['contact title'] !== '' ? ', ' . $values['contact title'] : '') : '', $values['contact email'], ]); $html = '
'; $html .= '

' . $e($row['contract_name']) . '

'; $html .= '

' . $e(contractTypeLabel($row['contract_type'])) . ' agreement between ' . $e($values['company']) . ' and ' . $e($values['client']) . '' . ($values['start date'] !== '' ? ', from ' . $e($values['start date']) : '') . '.

'; $html .= '' . '' . '' . '
Provider
' . $e($values['company']) . '
' . nl2br($e(implode("\n", $from_lines))) . '
Client
' . $e($values['client']) . '
' . nl2br($e(implode("\n", $to_lines))) . '
'; // The agreement in a few lines $term = $values['term']; if ($values['start date'] !== '') { $term .= ', from ' . $values['start date'] . ($values['end date'] !== '' ? ' to ' . $values['end date'] : ''); } $rates = []; if ($values['rate'] !== '') { $rates[] = $values['rate'] . ' an hour'; } if ($values['after hours rate'] !== '') { $rates[] = $values['after hours rate'] . ' an hour after hours'; } $net = $values['net terms']; $summary = [ 'Type' => contractTypeLabel($row['contract_type']), 'Term' => $term, 'Renewal' => $values['renewal'], 'Notice' => intval($row['contract_notice_days']) ? $values['notice'] . ' before it ends, in writing' : 'None needed', 'Support hours' => $values['support hours'], // Only when there are some - documents without them come out as they always did 'Included hours' => $values['monthly hours'] !== '' ? $values['monthly hours'] . ' of support each month, used for that month only' : '', 'Rates' => implode('; ', $rates), 'Payment' => $net === '' ? '' : ($net === 'Due on receipt' ? 'Invoices are due on receipt' : 'Invoices are due ' . intval(preg_replace('/\D/', '', $net)) . " days after they are issued ($net)"), ]; $html .= '

The agreement

'; $html .= ''; foreach ($summary as $label => $value) { if ($value !== '') { $html .= ''; } } $html .= '
' . $e($label) . '' . $e($value) . '
'; $html .= '

Response times

'; $html .= contractSlaTableHtml($row, $as_of); $terms = trim((string) $row['contract_details']) !== '' ? contractFillTerms(documentHtmlPurify((string) $row['contract_details']), $row, $as_of) : ''; if (trim(strip_tags($terms, '')) !== '') { $html .= '

Terms

'; $html .= '
' . $terms . '
'; } return $html . ''; } /** The fingerprint a document is signed by. */ function contractDocumentHash($html) { return hash('sha256', (string) $html); } /* ---------------------------------------------------------------- rounds of signing */ /** The columns a round is read with, and the contract and client it belongs to. */ function contractSignatureSelectSql() { return "SELECT contract_signatures.*, contract_id, contract_name, contract_status, contract_client_id, contract_owner_id, contract_start_date, contract_end_date, contract_term_months, client_name, client_archived_at, user_name AS owner_name, user_email AS owner_email FROM contract_signatures JOIN contracts ON contract_signature_contract_id = contract_id LEFT JOIN clients ON contract_client_id = client_id LEFT JOIN users ON contract_owner_id = user_id"; } /** A round of signing, with its contract's name, status and client - or null. */ function contractSignatureRow($contract_signature_id) { global $mysqli; $contract_signature_id = intval($contract_signature_id); if ($contract_signature_id <= 0 || !contractSignaturesReady()) { return null; } return mysqli_fetch_assoc(mysqli_query($mysqli, contractSignatureSelectSql() . " WHERE contract_signature_id = $contract_signature_id LIMIT 1")) ?: null; } /** The round a link opens - its id and key, matched exactly - or null. */ function contractSignatureByKey($contract_signature_id, $key) { $key = is_string($key) ? $key : ''; $round = contractSignatureRow($contract_signature_id); if (!$round || $key === '' || (string) $round['contract_signature_url_key'] === '' || !hash_equals((string) $round['contract_signature_url_key'], $key)) { return null; } return $round; } /** A contract's rounds, newest first. */ function contractSignatureRounds($contract_id) { global $mysqli; $rounds = []; if (!contractSignaturesReady()) { return $rounds; } $sql = mysqli_query($mysqli, contractSignatureSelectSql() . " WHERE contract_signature_contract_id = " . intval($contract_id) . " ORDER BY contract_signature_id DESC"); while ($row = mysqli_fetch_assoc($sql)) { $rounds[] = $row; } return $rounds; } /** The latest round signed by both - the copy in force - or null. */ function contractSignatureSigned(array $rounds) { foreach ($rounds as $round) { if (!empty($round['contract_signature_client_signed_at']) && !empty($round['contract_signature_agent_signed_at'])) { return $round; } } return null; } /* * Where a round stands - key, label, Bootstrap tone, icon and a line saying * when: sent, viewed, countersign (the client has signed), signed (both), * withdrawn, incomplete (recorded before 3.4.3 and not signed by both - it * has no link, and waits on no one). One still waiting on a contract that has * ended or was cancelled, or whose client was archived, is withdrawn - the * nightly job marks it so (contractSignatureWithdrawStale()). null for no round. */ function contractSignatureState($round) { if (!$round) { return null; } $day = function ($date) { return date('M j, Y', strtotime((string) $date)); }; $client = !empty($round['contract_signature_client_signed_at']); $agent = !empty($round['contract_signature_agent_signed_at']); if ($client && $agent) { return ['key' => 'signed', 'label' => 'Signed', 'tone' => 'success', 'icon' => 'fa-file-signature', 'when' => 'Signed by both ' . $day(max($round['contract_signature_client_signed_at'], $round['contract_signature_agent_signed_at']))]; } if (!empty($round['contract_signature_withdrawn_at'])) { return ['key' => 'withdrawn', 'label' => 'Withdrawn', 'tone' => 'secondary', 'icon' => 'fa-undo', 'when' => 'Withdrawn ' . $day($round['contract_signature_withdrawn_at'])]; } // Waiting on a contract that is over, or an archived client's - nothing more is signed on it $closed = in_array($round['contract_status'] ?? '', ['Ended', 'Cancelled'], true) || !empty($round['client_archived_at']); if ($closed && !$agent && !empty($round['contract_signature_sent_at']) && (string) ($round['contract_signature_url_key'] ?? 'x') !== '') { return ['key' => 'withdrawn', 'label' => 'Withdrawn', 'tone' => 'secondary', 'icon' => 'fa-undo', 'when' => ['Cancelled' => 'The contract was cancelled', 'Ended' => 'The contract ended'][$round['contract_status']] ?? 'The client was archived']; } // One recorded before 3.4.3 has no link - it waits on no one if (array_key_exists('contract_signature_url_key', $round) && (string) $round['contract_signature_url_key'] === '') { return ['key' => 'incomplete', 'label' => 'Not signed by both', 'tone' => 'secondary', 'icon' => 'fa-file-alt', 'when' => 'Recorded before signing came to ITFlow']; } if ($client) { return ['key' => 'countersign', 'label' => 'To countersign', 'tone' => 'warning', 'icon' => 'fa-pen-nib', 'when' => 'Signed by ' . $round['contract_signature_client_name'] . ' ' . $day($round['contract_signature_client_signed_at'])]; } if ($agent || empty($round['contract_signature_sent_at'])) { return ['key' => 'incomplete', 'label' => 'Not signed by both', 'tone' => 'secondary', 'icon' => 'fa-file-alt', 'when' => 'Recorded before signing came to ITFlow']; } if (!empty($round['contract_signature_viewed_at'])) { return ['key' => 'viewed', 'label' => 'Opened', 'tone' => 'info', 'icon' => 'fa-envelope-open', 'when' => 'Opened ' . $day($round['contract_signature_viewed_at'])]; } return ['key' => 'sent', 'label' => 'Out for signature', 'tone' => 'primary', 'icon' => 'fa-paper-plane', 'when' => 'Sent ' . $day($round['contract_signature_sent_at'])]; } /** Whether a round is waiting on someone - the client to sign, or us to countersign. */ function contractSignaturePending($round) { $state = contractSignatureState($round); return $state && in_array($state['key'], ['sent', 'viewed', 'countersign'], true); } /** Whether a contract has a round waiting - for the list's badge and the edit check. The latest round, or null. */ function contractSignaturePendingFor($contract_id) { $rounds = contractSignatureRounds($contract_id); return $rounds && contractSignaturePending($rounds[0]) ? $rounds[0] : null; } /** The signing link of a round - absolute when ITFlow's address is set. */ function contractSignatureUrl(array $round) { global $config_base_url; $path = '/guest/guest_view_contract.php?id=' . intval($round['contract_signature_id']) . '&key=' . rawurlencode((string) $round['contract_signature_url_key']); return !empty($config_base_url) ? "https://$config_base_url$path" : $path; } /* * A drawn signature from a form - a PNG data URI from the signing pad - checked * and redrawn: a real PNG, not too big, with something drawn on it, cut down * to what was drawn. [data URI, error]. */ function contractSignatureImageFromInput($value) { $value = trim((string) $value); if (!preg_match('/^data:image\/png;base64,([A-Za-z0-9+\/=\s]+)$/', $value, $match)) { return [null, 'Draw your signature in the box, or type it.']; } $binary = base64_decode(preg_replace('/\s+/', '', $match[1]), true); if ($binary === false || strlen($binary) > CONTRACT_SIGNATURE_MAX_BYTES) { return [null, 'That signature is too big - clear it and draw it again.']; } $info = @getimagesizefromstring($binary); if (!$info || $info[2] !== IMAGETYPE_PNG || $info[0] < 40 || $info[1] < 20 || $info[0] > 3000 || $info[1] > 1500 || !function_exists('imagecreatefromstring')) { return [null, 'That signature could not be read - clear it and draw it again.']; } $image = @imagecreatefromstring($binary); if (!$image) { return [null, 'That signature could not be read - clear it and draw it again.']; } if (!imageistruecolor($image)) { imagepalettetotruecolor($image); } $width = imagesx($image); $height = imagesy($image); // Where the ink is - anything not transparent and not near white $left = $width; $top = $height; $right = $bottom = -1; $ink = 0; for ($y = 0; $y < $height; $y++) { for ($x = 0; $x < $width; $x++) { $color = imagecolorat($image, $x, $y); $alpha = ($color >> 24) & 0x7F; $light = (($color >> 16) & 0xFF) + (($color >> 8) & 0xFF) + ($color & 0xFF); if ($alpha < 100 && $light < 600) { $ink++; $left = min($left, $x); $right = max($right, $x); $top = min($top, $y); $bottom = max($bottom, $y); } } } if ($ink < 30 || $right - $left < 15) { imagedestroy($image); return [null, 'Draw your signature in the box, or type it.']; } // Cut down to the signature, with a little room round it, on a clear background $pad = 8; $left = max(0, $left - $pad); $top = max(0, $top - $pad); $cut_width = min($width, $right + $pad + 1) - $left; $cut_height = min($height, $bottom + $pad + 1) - $top; $out = imagecreatetruecolor($cut_width, $cut_height); imagealphablending($out, false); imagesavealpha($out, true); imagefill($out, 0, 0, imagecolorallocatealpha($out, 255, 255, 255, 127)); imagecopy($out, $image, 0, 0, $left, $top, $cut_width, $cut_height); ob_start(); imagepng($out, null, 6); $png = ob_get_clean(); imagedestroy($image); imagedestroy($out); return ['data:image/png;base64,' . base64_encode($png), '']; } /* * A signature from a signing form - the name, the job title, and how it was * signed: drawn (the pad's picture) or typed (the name itself). [fields, * error]. $agree is the box saying they agree to sign electronically. */ function contractSignatureFromInput(array $in) { $text = function ($key, $max) use ($in) { return mb_substr(trim(preg_replace('/\s+/u', ' ', strip_tags((string) ($in[$key] ?? '')))), 0, $max); }; $f = ['name' => $text('signer_name', 200), 'title' => $text('signer_title', 200), 'image' => null]; if ($f['name'] === '') { return [$f, 'Write your full name.']; } if (($in['signature_mode'] ?? 'type') === 'draw') { [$f['image'], $error] = contractSignatureImageFromInput($in['signature'] ?? ''); if ($error !== '') { return [$f, $error]; } } if (empty($in['agree'])) { return [$f, 'Tick the box to say you agree to sign it.']; } return [$f, '']; } /* * Send a contract for signature - a new round with the document as it is * today, to $to_name at $to_email ($contact_id when one of the client's * contacts). Anything still waiting is withdrawn, replaced by this. The * email has $note in it when given. [round id, whether the email was queued]. */ function contractSignatureSend(array $row, $to_name, $to_email, $contact_id, $note, $user_id) { global $mysqli; $contract_id = intval($row['contract_id']); foreach (contractSignatureRounds($contract_id) as $round) { if (contractSignaturePending($round)) { contractSignatureWithdraw($round, 'Replaced by a new request'); } } $document = contractDocumentHtml($row, date('Y-m-d')); $key = bin2hex(random_bytes(24)); mysqli_query($mysqli, "INSERT INTO contract_signatures SET contract_signature_contract_id = $contract_id, contract_signature_url_key = '$key', contract_signature_document = '" . mysqli_real_escape_string($mysqli, $document) . "', contract_signature_document_hash = '" . contractDocumentHash($document) . "', contract_signature_contact_id = " . intval($contact_id) . ", contract_signature_sent_to_name = '" . escapeSql($to_name) . "', contract_signature_sent_to_email = '" . escapeSql($to_email) . "', contract_signature_sent_at = NOW(), contract_signature_sent_by = " . intval($user_id)); $round_id = intval(mysqli_insert_id($mysqli)); $queued = contractSignatureRequestEmail(contractSignatureRow($round_id), $note); return [$round_id, $queued]; } /** Email the signing link of a round to whoever it was sent to - with $note when given. Whether it was queued. */ function contractSignatureRequestEmail(array $round, $note = '') { global $mysqli; $company = billingCompany(); $company_name = (string) ($company['company_name'] ?? ''); $by = mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_name FROM users WHERE user_id = " . intval($round['contract_signature_sent_by'])))[0] ?? ''; $term = contractTermLabel($round['contract_term_months']) . (!empty($round['contract_start_date']) ? ' from ' . date('M j, Y', strtotime($round['contract_start_date'])) : ''); $sent = emailQueue([[ 'email' => (string) $round['contract_signature_sent_to_email'], 'name' => (string) $round['contract_signature_sent_to_name'], 'footnote' => "You received this because $company_name asked you to sign for {$round['client_name']}. The link is for you - please don't forward it.", ]], "Please sign: {$round['contract_name']} - $company_name", [ 'kind' => 'Contract', 'tone' => 'primary', 'title' => "$company_name sent you {$round['contract_name']} to sign", 'intro' => ['Please read it and sign it online - it takes a minute, on a phone or a computer. Once we have countersigned, you will get a copy signed by both of us.'], 'note' => trim((string) $note), 'summary' => array_filter(['Contract' => (string) $round['contract_name'], 'For' => (string) $round['client_name'], 'Term' => $term, 'Sent by' => (string) $by]), 'button' => ['label' => 'Review and sign', 'url' => contractSignatureUrl($round)], ], ['from' => contractEmailFrom()]); return (bool) $sent; } /** The mailbox contract emails to clients come from - the quotes one (Admin > Mail), else ITFlow's own. */ function contractEmailFrom() { $from = billingEmailFrom('quote'); return $from['email'] !== '' ? $from : emailFrom(); } /* * Withdraw what is still waiting - on the client's signature, or ours - on * contracts that have ended or were cancelled, or whose client was archived: * every one, or a client's ($client_id) or a contract's ($contract_id). Run * when a contract is cancelled, a client archived, and nightly. How many. */ function contractSignatureWithdrawStale($client_id = 0, $contract_id = 0) { global $mysqli; if (!contractSignaturesReady()) { return 0; } mysqli_query($mysqli, "UPDATE contract_signatures JOIN contracts ON contract_signature_contract_id = contract_id LEFT JOIN clients ON contract_client_id = client_id SET contract_signature_withdrawn_at = NOW(), contract_signature_withdrawn_reason = CASE WHEN contract_status = 'Cancelled' THEN 'The contract was cancelled' WHEN contract_status = 'Ended' THEN 'The contract ended' ELSE 'The client was archived' END WHERE contract_signature_withdrawn_at IS NULL AND contract_signature_agent_signed_at IS NULL AND contract_signature_url_key <> '' AND contract_signature_sent_at IS NOT NULL AND (contract_status IN ('Ended', 'Cancelled') OR client_archived_at IS NOT NULL)" . (intval($client_id) ? " AND contract_client_id = " . intval($client_id) : '') . (intval($contract_id) ? " AND contract_id = " . intval($contract_id) : '')); return max(0, mysqli_affected_rows($mysqli)); } /** SQL: the round's contract is still open to signing - a draft or active one, its client not archived. */ function contractSignatureOpenSql() { return "EXISTS (SELECT 1 FROM contracts JOIN clients ON contract_client_id = client_id WHERE contract_id = contract_signature_contract_id AND contract_status IN ('Draft', 'Active') AND client_archived_at IS NULL)"; } /** Withdraw a round - its link stops working for signing - and why. */ function contractSignatureWithdraw(array $round, $reason) { global $mysqli; mysqli_query($mysqli, "UPDATE contract_signatures SET contract_signature_withdrawn_at = NOW(), contract_signature_withdrawn_reason = '" . escapeSql(mb_substr((string) $reason, 0, 255)) . "' WHERE contract_signature_id = " . intval($round['contract_signature_id']) . " AND contract_signature_withdrawn_at IS NULL AND NOT (contract_signature_client_signed_at IS NOT NULL AND contract_signature_agent_signed_at IS NOT NULL)"); return mysqli_affected_rows($mysqli) === 1; } /* * The client has opened a round - the first time is noted, and the agents * told. Nothing when an agent is looking (the portal's View as contact). */ function contractSignatureViewed(array $round, $who = '') { global $mysqli; if (!empty($round['contract_signature_viewed_at']) || !contractSignaturePending($round) || !empty($round['contract_signature_client_signed_at'])) { return; } mysqli_query($mysqli, "UPDATE contract_signatures SET contract_signature_viewed_at = NOW() WHERE contract_signature_id = " . intval($round['contract_signature_id']) . " AND contract_signature_viewed_at IS NULL"); if (mysqli_affected_rows($mysqli) === 1) { $who = trim((string) $who) !== '' ? trim((string) $who) . ' in the client portal' : 'the client, from its link'; appNotify("Contract", escapeSql("{$round['contract_name']} for {$round['client_name']} was opened by $who"), contractAgentPath($round), intval($round['contract_client_id']), intval($round['contract_id'])); } } /** A contract's page on the agent side. */ function contractAgentPath(array $row) { return "/agent/contract.php?client_id=" . intval($row['contract_client_id']) . "&contract_id=" . intval($row['contract_id']); } /* * The client signs a round - $f from contractSignatureFromInput(), $via * 'link' or 'portal', $contact_id who signed in the portal. Once only, not a * withdrawn round, and only while the contract is a draft or active and its * client isn't archived. The agents are told, and the owner emailed, to * countersign. Whether it was signed. */ function contractSignatureClientSign(array $round, array $f, $via, $contact_id = 0) { global $mysqli, $config_base_url; $ip = getIP(); $agent = mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); mysqli_query($mysqli, "UPDATE contract_signatures SET contract_signature_client_name = '" . escapeSql($f['name']) . "', contract_signature_client_title = " . ($f['title'] !== '' ? "'" . escapeSql($f['title']) . "'" : 'NULL') . ", contract_signature_client_image = " . ($f['image'] ? "'" . mysqli_real_escape_string($mysqli, $f['image']) . "'" : 'NULL') . ", contract_signature_client_signed_at = NOW(), contract_signature_client_via = '" . ($via === 'portal' ? 'portal' : 'link') . "', contract_signature_client_contact_id = " . intval($contact_id) . ", contract_signature_client_ip = '" . escapeSql($ip) . "', contract_signature_client_user_agent = '" . mysqli_real_escape_string($mysqli, $agent) . "', contract_signature_viewed_at = IFNULL(contract_signature_viewed_at, NOW()) WHERE contract_signature_id = " . intval($round['contract_signature_id']) . " AND contract_signature_client_signed_at IS NULL AND contract_signature_withdrawn_at IS NULL AND contract_signature_url_key <> '' AND " . contractSignatureOpenSql()); if (mysqli_affected_rows($mysqli) !== 1) { return false; } $name = escapeSql($f['name']); $where = $via === 'portal' ? 'in the client portal' : 'from its link'; logAudit("Contract", "Sign", "$name signed contract " . escapeSql($round['contract_name']) . " for " . escapeSql($round['client_name']) . " $where", intval($round['contract_client_id']), intval($round['contract_id'])); appNotify("Contract", escapeSql("{$f['name']} signed {$round['contract_name']} for {$round['client_name']} - it is ready to countersign"), contractAgentPath($round), intval($round['contract_client_id']), intval($round['contract_id'])); if (!empty($round['owner_email'])) { emailQueue([['email' => $round['owner_email'], 'name' => $round['owner_name'], 'footnote' => 'You look after this contract in ITFlow.']], "Signed by the client: {$round['contract_name']} - {$round['client_name']}", [ 'kind' => 'Contract', 'tone' => 'success', 'title' => "{$f['name']} signed {$round['contract_name']}", 'intro' => ['It is ready for you to countersign. Once you have, the client gets a copy signed by both of you' . ($round['contract_status'] === 'Draft' ? ' and the contract becomes active.' : '.')], 'summary' => array_filter(['Client' => (string) $round['client_name'], 'Signed by' => $f['name'] . ($f['title'] !== '' ? ", {$f['title']}" : ''), 'Signed' => date('M j, Y g:i A') . " $where"]), 'button' => ['label' => 'Countersign', 'url' => "https://$config_base_url" . contractAgentPath($round)], ]); } return true; } /* * An agent countersigns a round the client has signed - $f from * contractSignatureFromInput() - while the contract is a draft or active and * its client isn't archived. A draft contract becomes active (from its start * date, or today), and the client gets the PDF signed by both - unless $email * is false. Whether it was signed. */ function contractSignatureAgentSign(array $round, array $f, $user_id, $email = true) { global $mysqli; mysqli_query($mysqli, "UPDATE contract_signatures SET contract_signature_agent_user_id = " . intval($user_id) . ", contract_signature_agent_name = '" . escapeSql($f['name']) . "', contract_signature_agent_title = " . ($f['title'] !== '' ? "'" . escapeSql($f['title']) . "'" : 'NULL') . ", contract_signature_agent_image = " . ($f['image'] ? "'" . mysqli_real_escape_string($mysqli, $f['image']) . "'" : 'NULL') . ", contract_signature_agent_signed_at = NOW(), contract_signature_agent_ip = '" . escapeSql(getIP()) . "' WHERE contract_signature_id = " . intval($round['contract_signature_id']) . " AND contract_signature_client_signed_at IS NOT NULL AND contract_signature_agent_signed_at IS NULL AND contract_signature_withdrawn_at IS NULL AND " . contractSignatureOpenSql()); if (mysqli_affected_rows($mysqli) !== 1) { return false; } $row = contractRow($round['contract_id']); if ($row && $row['contract_status'] === 'Draft') { contractActivate($row, !empty($row['contract_start_date']) ? (string) $row['contract_start_date'] : date('Y-m-d')); } if ($email) { contractSignatureSignedEmail(contractSignatureRow($round['contract_signature_id'])); } return true; } /* * Email the copy signed by both to whoever signed it for the client - and to * whoever it was sent to, when someone else signed in the portal - with the * PDF. $attach false leaves the PDF off (the email previews). Who it went to. */ function contractSignatureSignedEmail(array $round, $attach = true) { global $mysqli; $company = billingCompany(); $company_name = (string) ($company['company_name'] ?? ''); $recipients = []; if (filter_var((string) $round['contract_signature_sent_to_email'], FILTER_VALIDATE_EMAIL)) { $recipients[] = ['email' => (string) $round['contract_signature_sent_to_email'], 'name' => (string) $round['contract_signature_sent_to_name']]; } if (intval($round['contract_signature_client_contact_id'])) { $signer = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name, contact_email FROM contacts WHERE contact_id = " . intval($round['contract_signature_client_contact_id']))); if ($signer && filter_var((string) $signer['contact_email'], FILTER_VALIDATE_EMAIL)) { $recipients[] = ['email' => (string) $signer['contact_email'], 'name' => (string) $signer['contact_name']]; } } if (!$recipients) { return []; } foreach ($recipients as $i => $recipient) { $recipients[$i]['footnote'] = "You received this as the signer for {$round['client_name']}."; } $attachments = []; if ($attach) { $file = billingEmailPdf(contractPdf(contractRow($round['contract_id']), $round)); if ($file) { $attachments[] = $file; } } return emailQueue($recipients, "Signed: {$round['contract_name']} - $company_name", [ 'kind' => 'Contract', 'tone' => 'success', 'title' => "{$round['contract_name']} is signed", 'intro' => ["Thank you - both of us have signed it. A copy signed by both is attached" . ($attach ? '' : ' as a PDF') . '. Keep it for your records.'], 'summary' => array_filter([ 'Contract' => (string) $round['contract_name'], 'Signed for ' . $round['client_name'] => trim($round['contract_signature_client_name'] . ' ' . (!empty($round['contract_signature_client_signed_at']) ? 'on ' . date('M j, Y', strtotime($round['contract_signature_client_signed_at'])) : '')), "Signed for $company_name" => trim($round['contract_signature_agent_name'] . ' ' . (!empty($round['contract_signature_agent_signed_at']) ? 'on ' . date('M j, Y', strtotime($round['contract_signature_agent_signed_at'])) : '')), ]), ], ['from' => contractEmailFrom(), 'attachments' => $attachments]); } /* * The signature block - the client's and ours, each the picture drawn or the * name typed, the name, title and date; an empty line to sign on while not * signed. $image_url gives a side's picture an address (the client portal * allows no data: pictures); otherwise the picture is put in the page. */ function contractSignatureBlockHtml(array $row, $round = null, $image_url = null) { $company = billingCompany(); $sides = [ 'client' => ['for' => (string) $row['client_name']], 'agent' => ['for' => (string) ($company['company_name'] ?? '')], ]; $html = '
'; foreach ($sides as $side => $info) { $signed = $round && !empty($round["contract_signature_{$side}_signed_at"]); $html .= '
'; $html .= '
Signed for ' . escapeHtml($info['for']) . '
'; $html .= '
'; if ($signed && !empty($round["contract_signature_{$side}_image"])) { $src = $image_url ? $image_url($side) : (string) $round["contract_signature_{$side}_image"]; $html .= 'Signature of ' . escapeHtml($round['; } elseif ($signed) { $html .= '' . escapeHtml($round["contract_signature_{$side}_name"]) . ''; } $html .= '
'; if ($signed) { $html .= '
' . escapeHtml($round["contract_signature_{$side}_name"]) . '
'; $html .= '
' . escapeHtml(implode(' · ', array_filter([(string) $round["contract_signature_{$side}_title"], date('M j, Y', strtotime($round["contract_signature_{$side}_signed_at"]))]))) . '
'; } else { $html .= '
Name, title and date
'; } $html .= '
'; } return $html . '
'; } /* * What happened to a round, in order - for its page and the PDF's last * section: [when, what, detail] - raw text; the detail (from where, and how) * may be ''. */ function contractSignatureTrail(array $round) { global $mysqli; $trail = []; $at = function ($date) { return date('M j, Y g:i A', strtotime((string) $date)); }; if (!empty($round['contract_signature_sent_at'])) { $by = mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_name FROM users WHERE user_id = " . intval($round['contract_signature_sent_by'])))[0] ?? ''; $trail[] = [$at($round['contract_signature_sent_at']), 'Sent to ' . $round['contract_signature_sent_to_name'] . ($by !== '' ? " by $by" : ''), (string) $round['contract_signature_sent_to_email']]; } if (!empty($round['contract_signature_viewed_at'])) { $trail[] = [$at($round['contract_signature_viewed_at']), 'Opened by the client', '']; } if (!empty($round['contract_signature_client_signed_at'])) { $how = ['link' => 'From the link in the email', 'portal' => 'In the client portal', 'recorded' => 'Recorded before signing came to ITFlow'][$round['contract_signature_client_via']] ?? ''; $device = trim(strip_tags(getOS((string) $round['contract_signature_client_user_agent'])) . ', ' . strip_tags(getWebBrowser((string) $round['contract_signature_client_user_agent'])), ' ,-'); $trail[] = [$at($round['contract_signature_client_signed_at']), 'Signed for ' . $round['client_name'] . ' by ' . $round['contract_signature_client_name'] . (!empty($round['contract_signature_client_title']) ? ', ' . $round['contract_signature_client_title'] : ''), implode(' · ', array_filter([$how, !empty($round['contract_signature_client_ip']) ? 'IP address ' . $round['contract_signature_client_ip'] : '', $device, $round['contract_signature_client_via'] === 'recorded' ? '' : (!empty($round['contract_signature_client_image']) ? 'Signature drawn' : 'Signature typed')]))]; } if (!empty($round['contract_signature_agent_signed_at'])) { $trail[] = [$at($round['contract_signature_agent_signed_at']), 'Countersigned for ' . (billingCompany()['company_name'] ?? '') . ' by ' . $round['contract_signature_agent_name'] . (!empty($round['contract_signature_agent_title']) ? ', ' . $round['contract_signature_agent_title'] : ''), !empty($round['contract_signature_agent_ip']) ? 'IP address ' . $round['contract_signature_agent_ip'] : '']; } if (!empty($round['contract_signature_withdrawn_at']) && empty($round['contract_signature_agent_signed_at'])) { $trail[] = [$at($round['contract_signature_withdrawn_at']), 'Withdrawn', (string) $round['contract_signature_withdrawn_reason']]; } return $trail; } /* * After a contract is changed: when it is out for signature and the * document comes out different from the one sent, the request is withdrawn - * no one should sign what it no longer says. The round withdrawn, or null. */ function contractSignatureCheckChanged(array $row) { $round = contractSignaturePendingFor(intval($row['contract_id'])); if (!$round) { return null; } $as_of = date('Y-m-d', strtotime((string) $round['contract_signature_sent_at'])); if (contractDocumentHash(contractDocumentHtml($row, $as_of)) === (string) $round['contract_signature_document_hash']) { return null; } return contractSignatureWithdraw($round, 'The contract was changed after it was sent') ? $round : null; } /** Whether what a contract says now differs from a signed round - by what the document would be on the day that round was sent. */ function contractSignatureOutdated(array $row, array $round) { if (empty($round['contract_signature_sent_at'])) { return false; } $as_of = date('Y-m-d', strtotime((string) $round['contract_signature_sent_at'])); return contractDocumentHash(contractDocumentHtml($row, $as_of)) !== (string) $round['contract_signature_document_hash']; } /** A contract as a PDF - the copy of a round ($round) when given, else the document as it is today. ['name', 'data']. */ function contractPdf(array $row, $round = null) { require_once __DIR__ . '/../libs/TCPDF/tcpdf.php'; require_once __DIR__ . '/contract_pdf.php'; return contractPdfRender($row, $round); }