mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
837 lines
46 KiB
PHP
837 lines
46 KiB
PHP
<?php
|
|
|
|
/*
|
|
* Contracts as documents, and signing them.
|
|
*
|
|
* The document is what a contract says, put together for the client to
|
|
* read: who it is between, the agreement in a few lines (type, term,
|
|
* renewal, support hours, rates, payment), the response times for each
|
|
* ticket priority, and its terms with their placeholders filled in -
|
|
* [Client], [Start Date], [Rate] and the rest (CONTRACT_PLACEHOLDERS), and
|
|
* [SLA] on a line of its own as the table of response times.
|
|
* contractDocumentHtml() makes it; it carries no styles of its own (the
|
|
* pages' CSS and the PDF's style block dress it), as the client portal
|
|
* allows none.
|
|
*
|
|
* Sending a contract for signature is a round (contract_signatures, from
|
|
* database 3.4.3): the document as it was sent and its SHA-256, a link with a
|
|
* key of its own, who it went to. The client signs - from the link or in the
|
|
* portal - by name, with a signature drawn or typed; then an agent
|
|
* countersigns, the signed PDF goes to the client, and a draft contract
|
|
* becomes active. A round can be withdrawn; sending again starts a new one,
|
|
* and the earlier rounds are kept. Changing a contract while it is out for
|
|
* signature withdraws the request when the document comes out different, and
|
|
* one that is cancelled or ends, or whose client is archived, has its request
|
|
* withdrawn - nothing is signed on it after that (contractSignatureWithdrawStale()).
|
|
*
|
|
* The PDF is functions/contract_pdf.php; the pages are agent/contract.php,
|
|
* agent/contract_document.php, guest/guest_view_contract.php and
|
|
* client/contract.php, with includes/contract_paper.php drawing the document.
|
|
*/
|
|
|
|
/** The placeholders a contract's terms can use - what each becomes. */
|
|
const CONTRACT_PLACEHOLDERS = [
|
|
'Client' => "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 = '<table class="itflow-contract-doc-table itflow-contract-doc-slas" width="100%" cellpadding="5" cellspacing="0"><thead><tr>'
|
|
. '<th width="22%">Priority</th><th width="38%">Respond within</th><th width="40%">Resolve within</th></tr></thead><tbody>';
|
|
foreach (contractSlaRows($row, $date) as $priority => $item) {
|
|
$sla = $item['sla'];
|
|
$html .= '<tr><td width="22%">' . escapeHtml($priority) . '</td>';
|
|
if ($sla) {
|
|
$html .= '<td width="38%">' . escapeHtml(slaMinutesText($sla['sla_response_minutes'])) . '</td>'
|
|
. '<td width="40%">' . (intval($sla['sla_resolution_minutes']) > 0 ? escapeHtml(slaMinutesText($sla['sla_resolution_minutes'])) : 'No set time') . '</td>';
|
|
} else {
|
|
$html .= '<td width="38%">No set time</td><td width="40%">No set time</td>';
|
|
}
|
|
$html .= '</tr>';
|
|
}
|
|
return $html . '</tbody></table>';
|
|
}
|
|
|
|
/** 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 = '<div class="itflow-contract-doc">';
|
|
$html .= '<h1 class="itflow-contract-doc-title">' . $e($row['contract_name']) . '</h1>';
|
|
$html .= '<p class="itflow-contract-doc-between">' . $e(contractTypeLabel($row['contract_type'])) . ' agreement between <strong>' . $e($values['company']) . '</strong> and <strong>' . $e($values['client']) . '</strong>'
|
|
. ($values['start date'] !== '' ? ', from ' . $e($values['start date']) : '') . '.</p>';
|
|
|
|
$html .= '<table class="itflow-contract-doc-parties" width="100%" cellpadding="0" cellspacing="0"><tr>'
|
|
. '<td width="50%"><div class="itflow-contract-doc-label">Provider</div><strong>' . $e($values['company']) . '</strong><br><span class="itflow-contract-doc-muted">' . nl2br($e(implode("\n", $from_lines))) . '</span></td>'
|
|
. '<td width="50%"><div class="itflow-contract-doc-label">Client</div><strong>' . $e($values['client']) . '</strong><br><span class="itflow-contract-doc-muted">' . nl2br($e(implode("\n", $to_lines))) . '</span></td>'
|
|
. '</tr></table>';
|
|
|
|
// 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 .= '<h2 class="itflow-contract-doc-heading">The agreement</h2>';
|
|
$html .= '<table class="itflow-contract-doc-table itflow-contract-doc-summary" width="100%" cellpadding="5" cellspacing="0"><tbody>';
|
|
foreach ($summary as $label => $value) {
|
|
if ($value !== '') {
|
|
$html .= '<tr><th width="26%" align="left">' . $e($label) . '</th><td width="74%">' . $e($value) . '</td></tr>';
|
|
}
|
|
}
|
|
$html .= '</tbody></table>';
|
|
|
|
$html .= '<h2 class="itflow-contract-doc-heading">Response times</h2>';
|
|
$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, '<img><table>')) !== '') {
|
|
$html .= '<h2 class="itflow-contract-doc-heading">Terms</h2>';
|
|
$html .= '<div class="itflow-contract-doc-terms">' . $terms . '</div>';
|
|
}
|
|
return $html . '</div>';
|
|
}
|
|
|
|
/** 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 = '<div class="itflow-contract-signatures">';
|
|
foreach ($sides as $side => $info) {
|
|
$signed = $round && !empty($round["contract_signature_{$side}_signed_at"]);
|
|
$html .= '<div class="itflow-contract-signature' . ($signed ? ' is-signed' : '') . '" data-contract-signature="' . $side . '">';
|
|
$html .= '<div class="itflow-contract-doc-label">Signed for ' . escapeHtml($info['for']) . '</div>';
|
|
$html .= '<div class="itflow-contract-signature-mark">';
|
|
if ($signed && !empty($round["contract_signature_{$side}_image"])) {
|
|
$src = $image_url ? $image_url($side) : (string) $round["contract_signature_{$side}_image"];
|
|
$html .= '<img src="' . escapeHtml($src) . '" alt="Signature of ' . escapeHtml($round["contract_signature_{$side}_name"]) . '">';
|
|
} elseif ($signed) {
|
|
$html .= '<span class="itflow-contract-signature-typed">' . escapeHtml($round["contract_signature_{$side}_name"]) . '</span>';
|
|
}
|
|
$html .= '</div>';
|
|
if ($signed) {
|
|
$html .= '<div class="fw-semibold">' . escapeHtml($round["contract_signature_{$side}_name"]) . '</div>';
|
|
$html .= '<div class="small text-body-secondary">' . escapeHtml(implode(' · ', array_filter([(string) $round["contract_signature_{$side}_title"], date('M j, Y', strtotime($round["contract_signature_{$side}_signed_at"]))]))) . '</div>';
|
|
} else {
|
|
$html .= '<div class="small text-body-secondary">Name, title and date</div>';
|
|
}
|
|
$html .= '</div>';
|
|
}
|
|
return $html . '</div>';
|
|
}
|
|
|
|
/*
|
|
* 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);
|
|
}
|