" . escapeHtml($fields['name']) . " saved" . ($withdrawn ? " - it was out for signature, so that request was withdrawn. Send it again for the new version." : ''), $withdrawn ? 'warning' : 'success');
} else {
$contract_id = contractCreate($fields);
contractHoursSync($contract_id);
logAudit("Contract", "Create", "$session_name added " . strtolower($fields['status']) . " contract " . escapeSql($fields['name']) . " (" . escapeSql(contractTypeLabel($fields['type'])) . ")"
. ($fields['start_date'] !== '' ? " from " . escapeSql($fields['start_date']) : '') . ($fields['end_date'] !== '' ? " to " . escapeSql($fields['end_date']) : ''), $client_id, $contract_id);
flashAlert("Contract " . escapeHtml($fields['name']) . " added" . ($fields['status'] === 'Draft' ? ' as a draft' : ''));
redirect("contract.php?client_id=$client_id&contract_id=$contract_id");
}
redirect();
}
if (isset($_POST['activate_contract'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = contractPostRow($_POST['contract_id'] ?? 0);
$start = trim((string) ($_POST['start_date'] ?? ''));
if ($row['contract_status'] !== 'Draft' || !contractDateValid($start)) {
flashAlert("Pick the day it is in force from.", 'error');
redirect();
}
if ($row['contract_end_date'] && $row['contract_end_date'] < $start) {
flashAlert("It ends before that day - change its end date first.", 'error');
redirect();
}
$end = contractActivate($row, $start);
contractHoursSync(intval($row['contract_id']));
$recurring_state = contractRecurringFollow(intval($row['contract_id']), stripslashes($session_name));
logAudit("Contract", "Edit", "$session_name made contract " . escapeSql($row['contract_name']) . " active from " . escapeSql($start) . ($end !== '' ? " to " . escapeSql($end) : ''), $client_id, intval($row['contract_id']));
flashAlert("Contract " . escapeHtml($row['contract_name']) . " is active" . ($recurring_state === 'started' ? ' - its recurring invoice is running' : ''));
redirect();
}
if (isset($_POST['renew_contract'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = contractPostRow($_POST['contract_id'] ?? 0);
$end = trim((string) ($_POST['end_date'] ?? ''));
if ($row['contract_status'] === 'Draft' || !contractDateValid($end) || $end < date('Y-m-d') || ($row['contract_end_date'] && $end <= $row['contract_end_date'])) {
flashAlert("Pick a new last day - after its current one, and not in the past.", 'error');
redirect();
}
contractRenew($row, $end);
contractHoursSync(intval($row['contract_id']));
$recurring_state = contractRecurringFollow(intval($row['contract_id']), stripslashes($session_name));
logAudit("Contract", "Edit", "$session_name renewed contract " . escapeSql($row['contract_name']) . " to " . escapeSql($end), $client_id, intval($row['contract_id']));
flashAlert("Contract " . escapeHtml($row['contract_name']) . " renewed to " . date('M j, Y', strtotime($end)) . ($recurring_state === 'started' ? ' - its recurring invoice is running again' : ''));
redirect();
}
if (isset($_POST['cancel_contract'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = contractPostRow($_POST['contract_id'] ?? 0);
$last_day = trim((string) ($_POST['last_day'] ?? ''));
$reason = mb_substr(trim(strip_tags((string) ($_POST['reason'] ?? ''))), 0, 255);
if ($row['contract_status'] !== 'Active' || !contractDateValid($last_day)
|| ($row['contract_start_date'] && $last_day < $row['contract_start_date'])) {
flashAlert("Pick its last day - not before it started.", 'error');
redirect();
}
contractCancel($row, $last_day, $reason);
// Its recurring invoice stops once the last day has passed - now, when it already has
$recurring_state = contractRecurringFollow(intval($row['contract_id']), stripslashes($session_name));
// Nothing more is signed on it - a request still out, or waiting on our countersignature, is withdrawn
$withdrawn = contractSignatureWithdrawStale(0, intval($row['contract_id']));
logAudit("Contract", "Edit", "$session_name cancelled contract " . escapeSql($row['contract_name']) . ", last day " . escapeSql($last_day) . ($reason !== '' ? " - " . escapeSql($reason) : ''), $client_id, intval($row['contract_id']));
if ($withdrawn) {
logAudit("Contract", "Withdraw", "The signing request for contract " . escapeSql($row['contract_name']) . " was withdrawn - the contract was cancelled", $client_id, intval($row['contract_id']));
}
flashAlert("Contract " . escapeHtml($row['contract_name']) . " cancelled" . ($last_day >= date('Y-m-d') ? " - in force until " . date('M j, Y', strtotime($last_day)) : '')
. ($recurring_state === 'stopped' ? ' - its recurring invoice is paused' : '') . ($withdrawn ? ' - its signing request was withdrawn' : ''), 'error');
redirect();
}
if (isset($_GET['delete_contract'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 3);
$row = contractPostRow($_GET['delete_contract']);
// Not once tickets drew on its hours, or it was signed by both
$blocker = contractDeleteBlocker($row);
if ($blocker !== '') {
flashAlert("Contract " . escapeHtml($row['contract_name']) . " wasn't deleted. " . escapeHtml($blocker), 'error');
redirect();
}
mysqli_query($mysqli, "DELETE FROM contracts WHERE contract_id = " . intval($row['contract_id']));
logAudit("Contract", "Delete", "$session_name deleted contract " . escapeSql($row['contract_name']), $client_id, intval($row['contract_id']));
flashAlert("Contract " . escapeHtml($row['contract_name']) . " deleted", 'error');
redirect("contracts.php?client_id=$client_id");
}
if (isset($_POST['send_contract_signature'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = contractPostRow($_POST['contract_id'] ?? 0);
$contract_id = intval($row['contract_id']);
if (!contractSignaturesReady()) {
flashAlert("Signing needs the database update - run it from Maintenance > Update first.", 'error');
redirect();
}
if (in_array($row['contract_status'], ['Ended', 'Cancelled'], true)) {
flashAlert("An ended or cancelled contract isn't sent for signature - renew it first.", 'error');
redirect();
}
if (!empty(getFieldById('clients', $client_id, 'client_archived_at'))) {
flashAlert("The client is archived - restore them before sending them a contract to sign.", 'error');
redirect();
}
// One of the client's contacts, or a name and an address typed in
$contact_id = intval($_POST['contact_id'] ?? 0);
if ($contact_id > 0) {
$contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name, contact_email FROM contacts WHERE contact_id = $contact_id AND contact_client_id = $client_id AND contact_archived_at IS NULL"));
$to_name = (string) ($contact['contact_name'] ?? '');
$to_email = trim((string) ($contact['contact_email'] ?? ''));
if (!$contact || !filter_var($to_email, FILTER_VALIDATE_EMAIL)) {
flashAlert("That contact has no email address - pick another, or type a name and an address.", 'error');
redirect();
}
} else {
$contact_id = 0;
$to_name = mb_substr(trim(preg_replace('/\s+/u', ' ', strip_tags((string) ($_POST['to_name'] ?? '')))), 0, 200);
$to_email = trim((string) ($_POST['to_email'] ?? ''));
if ($to_name === '' || !filter_var($to_email, FILTER_VALIDATE_EMAIL) || strlen($to_email) > 255) {
flashAlert("Pick a contact, or give a name and an email address.", 'error');
redirect();
}
}
$note = mb_substr(trim((string) ($_POST['note'] ?? '')), 0, 2000);
[$round_id, $queued] = contractSignatureSend($row, $to_name, $to_email, $contact_id, $note, $session_user_id);
logAudit("Contract", "Send", "$session_name sent contract " . escapeSql($row['contract_name']) . " for signature to " . escapeSql("$to_name <$to_email>"), $client_id, $contract_id);
if ($queued) {
flashAlert("Contract " . escapeHtml($row['contract_name']) . " sent to " . escapeHtml($to_name) . " to sign");
} else {
flashAlert("Contract " . escapeHtml($row['contract_name']) . " is ready to sign, but nothing was emailed - mail isn't set up. Copy its signing link here and send it yourself.", 'warning');
}
redirect();
}
if (isset($_GET['resend_contract_signature'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$round = contractSignaturePostRound($_GET['resend_contract_signature']);
$state = contractSignatureState($round);
if (!$state || !in_array($state['key'], ['sent', 'viewed'], true)) {
flashAlert("That request isn't waiting for the client any more.", 'error');
redirect();
}
$queued = contractSignatureRequestEmail($round);
logAudit("Contract", "Send", "$session_name sent the signing link for contract " . escapeSql($round['contract_name']) . " again to " . escapeSql($round['contract_signature_sent_to_name'] . ' <' . $round['contract_signature_sent_to_email'] . '>'), $client_id, intval($round['contract_id']));
flashAlert($queued ? "Signing link sent again to " . escapeHtml($round['contract_signature_sent_to_name']) . "" : "Nothing was emailed - mail isn't set up. Copy the signing link and send it yourself.", $queued ? 'success' : 'warning');
redirect();
}
if (isset($_GET['withdraw_contract_signature'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$round = contractSignaturePostRound($_GET['withdraw_contract_signature']);
if (!contractSignaturePending($round) || !contractSignatureWithdraw($round, 'Withdrawn by ' . stripslashes($session_name))) {
flashAlert("That request isn't waiting on anyone any more.", 'error');
redirect();
}
logAudit("Contract", "Withdraw", "$session_name withdrew the signing request for contract " . escapeSql($round['contract_name']) . " sent to " . escapeSql($round['contract_signature_sent_to_name']), $client_id, intval($round['contract_id']));
flashAlert("Signing request withdrawn - its link no longer signs anything", 'error');
redirect();
}
if (isset($_POST['countersign_contract'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$round = contractSignaturePostRound($_POST['contract_signature_id'] ?? 0);
$state = contractSignatureState($round);
if (!$state || $state['key'] !== 'countersign') {
flashAlert("That contract isn't waiting for a countersignature.", 'error');
redirect();
}
[$f, $error] = contractSignatureFromInput($_POST);
if ($error !== '') {
flashAlert(escapeHtml($error) . ' It was not countersigned.', 'error');
redirect();
}
$was_draft = $round['contract_status'] === 'Draft';
if (!contractSignatureAgentSign($round, $f, $session_user_id)) {
flashAlert("That contract isn't waiting for a countersignature.", 'error');
redirect();
}
if ($was_draft) {
contractHoursSync(intval($round['contract_id']));
contractRecurringFollow(intval($round['contract_id']), stripslashes($session_name));
}
logAudit("Contract", "Sign", "$session_name countersigned contract " . escapeSql($round['contract_name']) . " as " . escapeSql($f['name']) . ($was_draft ? ", which is now active" : ''), $client_id, intval($round['contract_id']));
flashAlert("Contract " . escapeHtml($round['contract_name']) . " is signed by both" . ($was_draft ? " and active" : '') . " - a copy went to " . escapeHtml($round['contract_signature_sent_to_name']));
redirect();
}
if (isset($_GET['export_contract_pdf'])) {
enforceUserPermission('module_sales');
$row = contractPostRow($_GET['export_contract_pdf']);
$round = null;
if (!empty($_GET['round'])) {
$round = contractSignatureRow($_GET['round']);
if (!$round || intval($round['contract_id']) !== intval($row['contract_id'])) {
flashAlert("That copy of the contract is gone.", 'error');
redirect();
}
}
billingPdfSend(contractPdf($row, $round), true);
}
/* ---------------------------------------------------------------- prepaid hours */
if (isset($_POST['add_contract_hours'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = contractPostRow($_POST['contract_id'] ?? 0);
if (!contractHoursReady()) {
flashAlert("Prepaid hours need the database update - run it from Maintenance > Update first.", 'error');
redirect();
}
if (!($row['contract_status'] === 'Draft' || contractHoursLive($row))) {
flashAlert("Hours are added to a draft or active contract, or a cancelled one until its last day - renew it first.", 'error');
redirect();
}
[$f, $error] = contractHoursFromInput($_POST);
if ($error === '' && $f['invoice'] && intval($config_module_enable_accounting) !== 1) {
$error = 'Invoicing needs accounting turned on, in Admin > Settings > Modules.';
}
if ($error !== '') {
flashAlert(escapeHtml($error) . ' No hours were added.', 'error');
redirect();
}
[$block_id, $invoice_id, $number] = contractHoursAdd($row, $f, $session_user_id, stripslashes($session_name));
$hours = contractHoursWords($f['hours']);
logAudit("Contract", "Edit", "$session_name added $hours to contract " . escapeSql($row['contract_name'])
. ($f['price'] !== null ? " for " . escapeSql(billingMoney($f['price'], $session_company_currency)) : '')
. ($f['expires'] !== '' ? ", good until " . escapeSql($f['expires']) : '') . ($invoice_id ? ", on invoice " . escapeSql($number) : ''), $client_id, intval($row['contract_id']));
flashAlert("" . escapeHtml($hours) . " added to " . escapeHtml($row['contract_name']) . ""
. ($invoice_id ? " - draft invoice " . escapeHtml($number) . " made for them" : ''));
redirect();
}
if (isset($_GET['delete_contract_block'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$block = contractBlockRow($_GET['delete_contract_block']);
if (!$block) {
flashAlert("Those hours are gone - they may have been deleted.", 'error');
redirect();
}
$row = contractPostRow($block['contract_id']);
if (intval($block['uses']) || !contractHoursBlockDelete($block['contract_block_id'])) {
flashAlert("Time was drawn from those hours, so they stay - put the tickets back first.", 'error');
redirect();
}
$hours = contractHoursWords($block['contract_block_hours']);
logAudit("Contract", "Edit", "$session_name deleted $hours from contract " . escapeSql($row['contract_name']) . " (" . escapeSql($block['contract_block_note'] ?: 'bought ' . $block['contract_block_bought']) . ")", $client_id, intval($row['contract_id']));
flashAlert("" . escapeHtml($hours) . " deleted from " . escapeHtml($row['contract_name']) . "" . (intval($block['contract_block_invoice_id']) ? " - the invoice for them stays" : ''), 'error');
redirect();
}
if (isset($_POST['draw_contract_hours'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
// From a contract, Unbilled Time, or one ticket
$ticket_ids = [];
if (!empty($_POST['ticket_id'])) {
$client_id = intval(getFieldById('tickets', intval($_POST['ticket_id']), 'ticket_client_id'));
$ticket_ids = [intval($_POST['ticket_id'])];
} elseif (!empty($_POST['contract_id'])) {
$client_id = intval(contractPostRow($_POST['contract_id'])['contract_client_id']);
} else {
$client_id = intval($_POST['client_id'] ?? 0);
}
if (!$client_id || !contractHoursReady()) {
flashAlert("Pick a client with prepaid hours.", 'error');
redirect();
}
enforceClientAccess();
$result = contractHoursDraw($client_id, $ticket_ids, stripslashes($session_name), $session_user_id);
$count = count($result['drawn']);
if (!$count) {
flashAlert($result['contracts'] ? 'Nothing to draw - no done tickets waiting to bill fall within prepaid hours with time left.' : 'Nothing to draw - the client has no active contract with prepaid hours.', 'warning');
redirect();
}
logAudit("Contract", "Edit", "$session_name drew " . escapeSql(contractHoursWords($result['hours'])) . " of prepaid hours for $count ticket" . ($count === 1 ? '' : 's') . ": " . escapeSql(implode(', ', array_keys($result['drawn']))), $client_id);
$over = array_sum($result['overage']);
flashAlert("" . escapeHtml(contractHoursWords($result['hours'])) . " drawn from prepaid hours for $count ticket" . ($count === 1 ? '' : 's')
. " - " . escapeHtml(contractHoursText($result['left'])) . " left" . ($over > 0 ? ". " . escapeHtml(contractHoursText($over)) . " went over and is left to bill" : ''), $over > 0 ? 'warning' : 'success');
redirect();
}
if (isset($_GET['put_back_contract_hours'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$ticket_id = intval($_GET['put_back_contract_hours']);
$ticket = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_client_id, ticket_invoice_id FROM tickets WHERE ticket_id = $ticket_id"));
if (!$ticket || !contractHoursReady()) {
flashAlert("That ticket is gone.", 'error');
redirect();
}
$client_id = intval($ticket['ticket_client_id']);
enforceClientAccess();
$hours = contractHoursPutBack($ticket_id, stripslashes($session_name), $session_user_id);
if ($hours === null) {
flashAlert(intval($ticket['ticket_invoice_id']) ? "That ticket is on an invoice now, so its hours stay drawn." : "That ticket drew no prepaid hours.", 'error');
redirect();
}
$reference = $ticket['ticket_prefix'] . $ticket['ticket_number'];
logAudit("Contract", "Edit", "$session_name put " . escapeSql(contractHoursWords($hours)) . " back on the prepaid hours from ticket " . escapeSql($reference), $client_id, 0);
flashAlert("" . escapeHtml(contractHoursWords($hours)) . " put back from " . escapeHtml($reference) . " - it is in Unbilled Time to bill as usual");
redirect();
}
/* ---------------------------------------------------------------- the recurring invoice */
/* The contract posted, when a recurring invoice can be made or linked for it - or a flash and back. */
function contractRecurringPostRow($id) {
global $config_module_enable_accounting;
$row = contractPostRow($id);
if (!contractBillingReady()) {
flashAlert("Linking a recurring invoice needs the database update - run it from Maintenance > Update first.", 'error');
redirect();
}
if (intval($config_module_enable_accounting) !== 1) {
flashAlert("Recurring invoices need accounting turned on, in Admin > Settings > Modules.", 'error');
redirect();
}
if (!contractRecurringShouldRun($row) && $row['contract_status'] !== 'Draft') {
flashAlert("An ended contract isn't billed - renew it first.", 'error');
redirect();
}
if (contractRecurringRow($row)) {
flashAlert("It already has a recurring invoice - unlink that one first.", 'error');
redirect();
}
return $row;
}
if (isset($_POST['make_contract_recurring'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = contractRecurringPostRow($_POST['contract_id'] ?? 0);
[$f, $error] = contractRecurringFromInput($_POST);
if ($error !== '') {
flashAlert(escapeHtml($error) . ' No recurring invoice was made.', 'error');
redirect();
}
[$recurring_id, $number, $state] = contractRecurringCreate($row, $f, stripslashes($session_name));
logAudit("Contract", "Edit", "$session_name made recurring invoice " . escapeSql($number) . " for contract " . escapeSql($row['contract_name']), $client_id, intval($row['contract_id']));
flashAlert("Recurring invoice " . escapeHtml($number) . " made for " . escapeHtml($row['contract_name']) . ""
. ($state === 'started' ? ' - the first invoice is on ' . date('M j, Y', strtotime($f['start'])) : " - paused until the contract is active"));
redirect();
}
if (isset($_POST['link_contract_recurring'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = contractRecurringPostRow($_POST['contract_id'] ?? 0);
$recurring_id = intval($_POST['recurring_invoice_id'] ?? 0);
$choices = contractRecurringChoices($client_id, intval($row['contract_id']));
if (!isset($choices[$recurring_id])) {
flashAlert("Pick one of the client's recurring invoices that isn't another contract's.", 'error');
redirect();
}
$number = billingNumber('recurring', $choices[$recurring_id]);
$state = contractRecurringLink($row, $recurring_id, stripslashes($session_name));
logAudit("Contract", "Edit", "$session_name linked recurring invoice " . escapeSql($number) . " to contract " . escapeSql($row['contract_name']), $client_id, intval($row['contract_id']));
flashAlert("Recurring invoice " . escapeHtml($number) . " linked to " . escapeHtml($row['contract_name']) . ""
. ($state === 'started' ? ' - it is running now' : ($state === 'stopped' ? ' - paused until the contract is active' : '')));
redirect();
}
if (isset($_GET['unlink_contract_recurring'])) {
validateCSRFToken();
enforceUserPermission('module_sales', 2);
$row = contractPostRow($_GET['unlink_contract_recurring']);
$recurring = contractRecurringRow($row);
if (!$recurring) {
flashAlert("It has no recurring invoice to unlink.", 'error');
redirect();
}
$number = billingNumber('recurring', $recurring);
contractRecurringUnlink($row, stripslashes($session_name));
logAudit("Contract", "Edit", "$session_name unlinked recurring invoice " . escapeSql($number) . " from contract " . escapeSql($row['contract_name']), $client_id, intval($row['contract_id']));
flashAlert("Recurring invoice " . escapeHtml($number) . " unlinked - it carries on as it is, " . (intval($recurring['recurring_invoice_status']) ? 'running' : 'paused'));
redirect();
}