Settings > Invoice - * from the client's active contracts' blocks, the one running out first * first (contract_block_uses: a row per block a ticket drew from). A ticket * whose time is all covered leaves Unbilled Time; one that is partly covered * stays there with the rest, the overage, to bill as usual. A block covers * work done before it runs out. * * The nightly job adds each month's included hours and draws the tickets * done since; Draw now does it straight away, from the contract or from * Unbilled Time. A ticket can be put back until it is invoiced: its hours * return to the blocks and it is billed as usual, kept out of later draws * (a use of no hours marks it) until an agent draws it again. The owner is * told when hours run low, once, until more are added. * * agent/contract.php shows them, with Add hours (agent/modals/contract/ * contract_hours_add.php) and the handlers in agent/post/contract.php; * functions/billing.php leaves out what is covered (billingUnbilledSql(), * billingUnbilledTickets()). */ /** Whether the 3.4.4 update has run - contract_blocks is there. */ function contractHoursReady() { 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.4', '>='); } return $ready; } /** Hours as "12.5 h", "1 h". */ function contractHoursText($hours) { return rtrim(rtrim(number_format(round(floatval($hours), 2), 2, '.', ''), '0'), '.') . ' h'; } /** Hours in words - "12.5 hours", "1 hour". */ function contractHoursWords($hours) { $hours = round(floatval($hours), 2); return rtrim(rtrim(number_format($hours, 2, '.', ''), '0'), '.') . ($hours == 1 ? ' hour' : ' hours'); } /* * A contract's blocks, the one running out first first - each with what was * drawn from it, what is left, and whether it has run out ($today). */ function contractBlocks($contract_id, $today = null) { global $mysqli; $today = $today ?? date('Y-m-d'); $blocks = []; if (!contractHoursReady()) { return $blocks; } $sql = mysqli_query($mysqli, "SELECT contract_blocks.*, COALESCE(SUM(contract_block_use_hours), 0) AS used, COUNT(NULLIF(contract_block_use_hours, 0)) AS uses, invoice_prefix, invoice_number, invoice_status FROM contract_blocks LEFT JOIN contract_block_uses ON contract_block_use_block_id = contract_block_id LEFT JOIN invoices ON contract_block_invoice_id = invoice_id AND contract_block_invoice_id > 0 WHERE contract_block_contract_id = " . intval($contract_id) . " GROUP BY contract_block_id ORDER BY contract_block_expires IS NULL, contract_block_expires ASC, contract_block_bought ASC, contract_block_id ASC"); while ($row = mysqli_fetch_assoc($sql)) { $row['used'] = round(floatval($row['used']), 2); $row['left'] = max(0.0, round(floatval($row['contract_block_hours']) - $row['used'], 2)); $row['expired'] = !empty($row['contract_block_expires']) && $row['contract_block_expires'] < $today; $blocks[intval($row['contract_block_id'])] = $row; } return $blocks; } /* * Where a contract's prepaid hours stand - bought (in blocks still good), * used of those, left, left unused in blocks that ran out, the next day a * block with hours left runs out, and how many blocks. $blocks from * contractBlocks(). */ function contractHoursSummary(array $blocks) { $s = ['bought' => 0.0, 'used' => 0.0, 'left' => 0.0, 'lapsed' => 0.0, 'next_expiry' => '', 'next_expiry_hours' => 0.0, 'blocks' => count($blocks)]; foreach ($blocks as $block) { if ($block['expired']) { $s['lapsed'] += $block['left']; continue; } $s['bought'] += floatval($block['contract_block_hours']); $s['used'] += $block['used']; $s['left'] += $block['left']; if ($block['left'] > 0 && !empty($block['contract_block_expires']) && ($s['next_expiry'] === '' || $block['contract_block_expires'] < $s['next_expiry'])) { $s['next_expiry'] = (string) $block['contract_block_expires']; $s['next_expiry_hours'] = $block['left']; } } foreach (['bought', 'used', 'left', 'lapsed', 'next_expiry_hours'] as $key) { $s[$key] = round($s[$key], 2); } return $s; } /** Hours left (and prepaid) in blocks still good, for some contracts - id => [left, bought], for the ones with blocks. For lists. */ function contractHoursLeftFor(array $contract_ids, $today = null) { global $mysqli; $ids = array_filter(array_map('intval', $contract_ids)); $left = []; if (!$ids || !contractHoursReady()) { return $left; } $today = escapeSql($today ?? date('Y-m-d')); $sql = mysqli_query($mysqli, "SELECT contract_block_contract_id, SUM(GREATEST(contract_block_hours - used, 0)) AS hours_left, SUM(contract_block_hours) AS bought FROM (SELECT contract_block_contract_id, contract_block_hours, COALESCE((SELECT SUM(contract_block_use_hours) FROM contract_block_uses WHERE contract_block_use_block_id = contract_block_id), 0) AS used FROM contract_blocks WHERE contract_block_contract_id IN (" . implode(',', $ids) . ") AND (contract_block_expires IS NULL OR contract_block_expires >= '$today')) AS blocks GROUP BY contract_block_contract_id"); while ($row = mysqli_fetch_assoc($sql)) { $left[intval($row['contract_block_contract_id'])] = ['left' => round(floatval($row['hours_left']), 2), 'bought' => round(floatval($row['bought']), 2)]; } return $left; } /* * Whether time is drawn from a contract's prepaid hours - while it is active, * and once cancelled, until its last day (it is in force till then). */ function contractHoursLive(array $row, $today = null) { $today = $today ?? date('Y-m-d'); $status = (string) ($row['contract_status'] ?? ''); return $status === 'Active' || ($status === 'Cancelled' && (string) ($row['contract_end_date'] ?? '') >= $today); } /** SQL for contractHoursLive(). */ function contractHoursLiveSql($today = null) { $today = $today !== null ? "'" . escapeSql($today) . "'" : 'CURDATE()'; return "(contract_status = 'Active' OR (contract_status = 'Cancelled' AND contract_end_date >= $today))"; } /** Whether a contract has prepaid hours to show - blocks, hours each month, or it is a block hours contract. */ function contractHasHours(array $row, array $blocks) { return $blocks || floatval($row['contract_monthly_hours'] ?? 0) > 0 || ($row['contract_type'] ?? '') === 'block'; } /** The client's contracts that have prepaid hours to draw from (contractHoursLive()) - id => row with its blocks and summary. */ function contractClientHours($client_id) { global $mysqli; $contracts = []; if (!contractHoursReady()) { return $contracts; } $sql = mysqli_query($mysqli, "SELECT * FROM contracts WHERE contract_client_id = " . intval($client_id) . " AND " . contractHoursLiveSql() . " AND contract_archived_at IS NULL AND (contract_monthly_hours > 0 OR EXISTS (SELECT 1 FROM contract_blocks WHERE contract_block_contract_id = contract_id)) ORDER BY contract_id ASC"); while ($row = mysqli_fetch_assoc($sql)) { $row['blocks'] = contractBlocks(intval($row['contract_id'])); $row['hours'] = contractHoursSummary($row['blocks']); $contracts[intval($row['contract_id'])] = $row; } return $contracts; } /* * This month's included hours, when a contract has them and is in force - * a block from the first of the month (or the day it starts) to the end of * it (or its last day), made once. Whether one was made. */ function contractHoursEnsureMonthly(array $row, $today = null) { global $mysqli; $today = $today ?? date('Y-m-d'); $hours = round(floatval($row['contract_monthly_hours'] ?? 0), 2); if (!contractHoursReady() || $hours <= 0 || !contractHoursLive($row, $today)) { return false; } $start = (string) ($row['contract_start_date'] ?? ''); $end = (string) ($row['contract_end_date'] ?? ''); if (($start !== '' && $start > $today) || ($end !== '' && $end < $today)) { return false; } $month = substr($today, 0, 7); $from = max($month . '-01', $start !== '' ? $start : $month . '-01'); $to = date('Y-m-t', strtotime($today)); $to = $end !== '' && $end < $to ? $end : $to; $note = 'Included in ' . date('F Y', strtotime($today)); mysqli_query($mysqli, "INSERT IGNORE INTO contract_blocks SET contract_block_contract_id = " . intval($row['contract_id']) . ", contract_block_hours = " . sprintf('%.2f', $hours) . ", contract_block_bought = '$from', contract_block_expires = '$to', contract_block_month = '$month', contract_block_note = '" . escapeSql($note) . "'"); if (mysqli_affected_rows($mysqli) === 1) { contractHoursAlertReset(intval($row['contract_id'])); return true; } return false; } /* * After a contract is saved or made active: this month's included hours * follow a change to the hours each month (never under what was used), and * are made when it is in force and has none yet. */ function contractHoursSync($contract_id) { global $mysqli; $row = contractRow($contract_id); if (!$row || !contractHoursReady()) { return; } $hours = round(floatval($row['contract_monthly_hours']), 2); if ($hours > 0) { mysqli_query($mysqli, "UPDATE contract_blocks SET contract_block_hours = GREATEST(" . sprintf('%.2f', $hours) . ", (SELECT COALESCE(SUM(contract_block_use_hours), 0) FROM contract_block_uses WHERE contract_block_use_block_id = contract_block_id)) WHERE contract_block_contract_id = " . intval($contract_id) . " AND contract_block_month = '" . date('Y-m') . "'"); } contractHoursEnsureMonthly($row); contractHoursAlertReset(intval($contract_id)); } /** The prepaid hours a ticket drew - each with its block and contract. $kept_out: whether it was put back and is kept out of draws. */ function contractHoursTicketUses($ticket_id, &$kept_out = false) { global $mysqli; $uses = []; $kept_out = false; if (!contractHoursReady()) { return $uses; } $sql = mysqli_query($mysqli, "SELECT contract_block_uses.*, contract_block_note, contract_block_month, contract_block_bought, contract_block_expires, contract_id, contract_name, contract_client_id FROM contract_block_uses JOIN contract_blocks ON contract_block_use_block_id = contract_block_id JOIN contracts ON contract_block_contract_id = contract_id WHERE contract_block_use_ticket_id = " . intval($ticket_id) . " ORDER BY contract_block_use_id ASC"); while ($row = mysqli_fetch_assoc($sql)) { if (floatval($row['contract_block_use_hours']) > 0) { $uses[] = $row; } else { $kept_out = true; } } return $uses; } /** The tickets a contract's blocks covered, newest first - each with the hours drawn. $limit 0 for all. */ function contractHoursUses($contract_id, $limit = 0) { global $mysqli; $rows = []; if (!contractHoursReady()) { return $rows; } $sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_resolved_at, ticket_closed_at, ticket_invoice_id, ticket_client_id, ticket_contact_id, SUM(contract_block_use_hours) AS hours, MAX(contract_block_use_full) AS covered, MAX(contract_block_use_created_at) AS drawn_at FROM contract_block_uses JOIN contract_blocks ON contract_block_use_block_id = contract_block_id JOIN tickets ON contract_block_use_ticket_id = ticket_id WHERE contract_block_contract_id = " . intval($contract_id) . " AND contract_block_use_hours > 0 GROUP BY ticket_id ORDER BY drawn_at DESC, ticket_id DESC" . ($limit ? ' LIMIT ' . intval($limit) : '')); while ($row = mysqli_fetch_assoc($sql)) { $row['hours'] = round(floatval($row['hours']), 2); $rows[] = $row; } return $rows; } /* * Draw a client's done tickets from their prepaid hours - $ticket_ids, or * every one waiting in Unbilled Time. Oldest first, each from the blocks * that cover the day it was done (from the day bought to the day they run * out), the one running out first first. What a ticket needs beyond them * stays in Unbilled Time to bill. $by for the history. [drawn (references * => hours), hours drawn, overage (references => hours left to bill, for * tickets the hours ran out on), hours left, contracts]. */ function contractHoursDraw($client_id, array $ticket_ids = [], $by = 'ITFlow', $user_id = 0) { global $mysqli; $client_id = intval($client_id); $result = ['drawn' => [], 'hours' => 0.0, 'overage' => [], 'left' => 0.0, 'contracts' => []]; if (!contractHoursReady() || !$client_id) { return $result; } // One draw for a client at a time - the nightly job and an agent could start together $lock = "itflow_contract_hours_$client_id"; if (!intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT GET_LOCK('$lock', 10)"))[0] ?? 0)) { return $result; } try { $contracts = contractClientHours($client_id); $made = false; foreach ($contracts as $contract) { $made = contractHoursEnsureMonthly($contract) || $made; } if ($made) { $contracts = contractClientHours($client_id); } contractHoursRecheck($client_id); // The blocks with hours left, the one running out first first, across the client's active contracts $blocks = []; foreach ($contracts as $contract) { foreach ($contract['blocks'] as $id => $block) { if ($block['left'] > 0) { $blocks[$id] = $block + ['contract_name' => $contract['contract_name']]; } } } uasort($blocks, function ($a, $b) { return [$a['contract_block_expires'] === null ? 1 : 0, (string) $a['contract_block_expires'], $a['contract_block_bought'], $a['contract_block_id']] <=> [$b['contract_block_expires'] === null ? 1 : 0, (string) $b['contract_block_expires'], $b['contract_block_bought'], $b['contract_block_id']]; }); $result['contracts'] = $contracts; if (!$blocks) { return $result; } // Tickets put back stay out - unless an agent draws them by name if ($ticket_ids) { mysqli_query($mysqli, "DELETE FROM contract_block_uses WHERE contract_block_use_hours = 0 AND contract_block_use_ticket_id IN (" . implode(',', array_map('intval', $ticket_ids)) . ")"); } $kept_out = []; $sql = mysqli_query($mysqli, "SELECT DISTINCT contract_block_use_ticket_id FROM contract_block_uses JOIN tickets ON contract_block_use_ticket_id = ticket_id WHERE ticket_client_id = $client_id AND contract_block_use_hours = 0"); while ($row = mysqli_fetch_row($sql)) { $kept_out[intval($row[0])] = true; } $tickets = array_diff_key(billingUnbilledTickets($client_id, $ticket_ids), $kept_out); uasort($tickets, function ($a, $b) { return [contractHoursDoneOn($a), $a['ticket_id']] <=> [contractHoursDoneOn($b), $b['ticket_id']]; }); $touched = []; foreach ($tickets as $ticket_id => $ticket) { $need = round(floatval($ticket['hours']), 2); if ($need <= 0) { continue; } $done = contractHoursDoneOn($ticket); $taken = []; foreach ($blocks as $id => $block) { if ($need <= 0) { break; } if ($blocks[$id]['left'] <= 0 || $done < $block['contract_block_bought'] || (!empty($block['contract_block_expires']) && $done > $block['contract_block_expires'])) { continue; } $take = round(min($need, $blocks[$id]['left']), 2); mysqli_query($mysqli, "INSERT INTO contract_block_uses SET contract_block_use_block_id = " . intval($id) . ", contract_block_use_ticket_id = " . intval($ticket_id) . ", contract_block_use_hours = " . sprintf('%.2f', $take) . ", contract_block_use_created_by = " . intval($user_id)); $taken[] = [intval(mysqli_insert_id($mysqli)), $block['contract_name'], $take]; $blocks[$id]['left'] = round($blocks[$id]['left'] - $take, 2); $need = round($need - $take, 2); $touched[intval($block['contract_block_contract_id'])] = true; } if (!$taken) { // No hours that cover the day it was done - it is billed as usual continue; } $drawn = round(array_sum(array_column($taken, 2)), 2); $reference = $ticket['ticket_prefix'] . $ticket['ticket_number']; if ($need <= 0) { // All of it covered - it leaves Unbilled Time mysqli_query($mysqli, "UPDATE contract_block_uses SET contract_block_use_full = 1 WHERE contract_block_use_id = " . intval(end($taken)[0])); } else { $result['overage'][$reference] = $need; } $result['drawn'][$reference] = $drawn; $result['hours'] += $drawn; $names = implode(', ', array_unique(array_column($taken, 1))); logTicketHistory($ticket_id, escapeSql(contractHoursText($drawn) . " drawn from prepaid hours ($names) by $by" . ($need > 0 ? ' - ' . contractHoursText($need) . ' over, left to bill' : ''))); } $result['hours'] = round($result['hours'], 2); $today = date('Y-m-d'); foreach ($blocks as $block) { $result['left'] += empty($block['contract_block_expires']) || $block['contract_block_expires'] >= $today ? $block['left'] : 0; } $result['left'] = round($result['left'], 2); // Running low, or out - the owner hears once foreach (array_keys($touched) as $contract_id) { contractHoursAlertCheck($contract_id); } // The hours ran out part way through a ticket - the rest is to bill if ($result['overage']) { $count = count($result['overage']); appNotify("Contract", escapeSql(getFieldById('clients', $client_id, 'client_name') . " ran out of prepaid hours - " . contractHoursText(array_sum($result['overage'])) . " on $count ticket" . ($count === 1 ? '' : 's') . " left to bill"), "/agent/unbilled_time.php?client_id=$client_id", $client_id); } return $result; } finally { mysqli_query($mysqli, "SELECT RELEASE_LOCK('$lock')"); } } /** The day a ticket's work was done - resolved, else closed, else opened. */ function contractHoursDoneOn(array $ticket) { return substr((string) ($ticket['ticket_resolved_at'] ?: ($ticket['ticket_closed_at'] ?: $ticket['ticket_created_at'])), 0, 10); } /* * A ticket that was all covered and has had time added since (reopened and * worked on, say) needs the rest billed: it goes back to Unbilled Time, * keeping what it drew, for the next draw to take the rest. How many. */ function contractHoursRecheck($client_id) { global $mysqli; $rules = billingTimeRules(); $back = 0; $sql = mysqli_query($mysqli, "SELECT ticket_id, SUM(contract_block_use_hours) AS drawn, COALESCE((SELECT SUM(TIME_TO_SEC(ticket_reply_time_worked)) FROM ticket_replies WHERE ticket_reply_ticket_id = ticket_id AND ticket_reply_archived_at IS NULL), 0) AS seconds FROM contract_block_uses JOIN tickets ON contract_block_use_ticket_id = ticket_id WHERE ticket_client_id = " . intval($client_id) . " AND ticket_invoice_id = 0 AND contract_block_use_hours > 0 GROUP BY ticket_id HAVING MAX(contract_block_use_full) = 1"); while ($row = mysqli_fetch_assoc($sql)) { if (billingTimeHours(intval($row['seconds']), $rules) > round(floatval($row['drawn']), 2)) { mysqli_query($mysqli, "UPDATE contract_block_uses SET contract_block_use_full = 0 WHERE contract_block_use_ticket_id = " . intval($row['ticket_id'])); $back++; } } return $back; } /* * Put a ticket's hours back - its draws undone, so it is in Unbilled Time * again with all its time, to bill as usual, and kept out of later draws * until it is drawn by name. Not once it is on an invoice. Hours put back, * or null. */ function contractHoursPutBack($ticket_id, $by, $user_id = 0) { global $mysqli; $ticket_id = intval($ticket_id); $uses = contractHoursTicketUses($ticket_id); if (!$uses || intval(getFieldById('tickets', $ticket_id, 'ticket_invoice_id'))) { return null; } $hours = round(array_sum(array_column($uses, 'contract_block_use_hours')), 2); mysqli_query($mysqli, "DELETE FROM contract_block_uses WHERE contract_block_use_ticket_id = $ticket_id"); // A use of no hours keeps it out of the nightly draw mysqli_query($mysqli, "INSERT INTO contract_block_uses SET contract_block_use_block_id = " . intval($uses[0]['contract_block_use_block_id']) . ", contract_block_use_ticket_id = $ticket_id, contract_block_use_hours = 0, contract_block_use_created_by = " . intval($user_id)); logTicketHistory($ticket_id, escapeSql(contractHoursText($hours) . " put back on the prepaid hours by $by - the ticket is billed as usual, in Unbilled Time")); foreach (array_unique(array_column($uses, 'contract_id')) as $contract_id) { contractHoursAlertReset(intval($contract_id)); } return $hours; } /* After hours are added or put back: told again next time they run low, once they are no longer low. */ function contractHoursAlertReset($contract_id) { global $mysqli; $row = contractRow($contract_id); if (!$row || empty($row['contract_hours_alerted_at'])) { return; } $left = contractHoursSummary(contractBlocks($contract_id))['left']; if ($left > 0 && $left >= floatval($row['contract_hours_alert'])) { mysqli_query($mysqli, "UPDATE contracts SET contract_hours_alerted_at = NULL WHERE contract_id = " . intval($contract_id)); } } /* * Running low: once a contract's hours left fall under its alert level, its * owner is emailed and the agents told - once, until more are added. Whether * they were told now. */ function contractHoursAlertCheck($contract_id) { global $mysqli; $row = contractRow($contract_id); $alert = $row ? floatval($row['contract_hours_alert']) : 0; if (!$row || $alert <= 0 || !empty($row['contract_hours_alerted_at'])) { return false; } $hours = contractHoursSummary(contractBlocks($contract_id)); if ($hours['left'] >= $alert) { return false; } mysqli_query($mysqli, "UPDATE contracts SET contract_hours_alerted_at = NOW() WHERE contract_id = " . intval($contract_id) . " AND contract_hours_alerted_at IS NULL"); if (mysqli_affected_rows($mysqli) !== 1) { return false; } contractHoursAlertSend($row, $hours); return true; } /** Tell the owner - and the agents, in ITFlow - that a contract's prepaid hours are running low. */ function contractHoursAlertSend(array $row, array $hours) { global $config_base_url; $client_id = intval($row['contract_client_id']); $left = $hours['left'] > 0 ? contractHoursWords($hours['left']) . ' left' : 'no hours left'; appNotify("Contract", escapeSql("{$row['client_name']} has $left on {$row['contract_name']}"), contractAgentPath($row), $client_id, intval($row['contract_id'])); if (empty($row['owner_email'])) { return; } emailQueue([['email' => $row['owner_email'], 'name' => $row['owner_name'], 'footnote' => 'You look after this contract in ITFlow.']], "Prepaid hours running low: {$row['contract_name']} - {$row['client_name']}", [ 'kind' => 'Contract', 'tone' => 'warning', 'title' => "{$row['client_name']} has $left", 'intro' => [$hours['left'] > 0 ? "The prepaid hours on {$row['contract_name']} are down to " . contractHoursWords($hours['left']) . ", under the " . contractHoursWords($row['contract_hours_alert']) . " you set to be told at. Time beyond them goes to Unbilled Time to bill as usual - a good time to offer the client more." : "The prepaid hours on {$row['contract_name']} are used up. Time beyond them goes to Unbilled Time to bill as usual - a good time to offer the client more."], 'summary' => array_filter([ 'Client' => (string) $row['client_name'], 'Prepaid' => contractHoursWords($hours['bought']), 'Used' => contractHoursWords($hours['used']), 'Left' => contractHoursWords($hours['left']), 'Next to run out' => $hours['next_expiry'] !== '' ? contractHoursWords($hours['next_expiry_hours']) . ' on ' . date('M j, Y', strtotime($hours['next_expiry'])) : '', ]), 'button' => ['label' => 'Open the contract', 'url' => "https://$config_base_url" . contractAgentPath($row)], ]); } /* * A block of hours from the Add hours window - [fields, error]: hours, the * price (null: none), the day bought, the day it runs out ('' never), a * note, and whether to invoice it (in which category). */ function contractHoursFromInput(array $in) { $date = function ($key) use ($in) { $value = trim((string) ($in[$key] ?? '')); $parsed = DateTime::createFromFormat('!Y-m-d', $value); return $value === '' ? '' : ($parsed && $parsed->format('Y-m-d') === $value ? $value : false); }; $hours = billingNumberInput(trim((string) ($in['hours'] ?? ''))); $price = trim((string) ($in['price'] ?? '')) === '' ? null : billingNumberInput($in['price']); $f = [ 'hours' => $hours === null ? 0 : round($hours, 2), 'price' => $price === null ? null : round($price, 2), 'bought' => $date('bought'), 'expires' => $date('expires'), 'note' => mb_substr(trim(strip_tags((string) ($in['note'] ?? ''))), 0, 255), 'invoice' => !empty($in['invoice']), 'category_id' => max(0, intval($in['category'] ?? 0)), ]; if ($hours === null || $f['hours'] <= 0 || $f['hours'] >= 10000) { return [$f, 'Give the hours - more than none, under 10,000.']; } if (trim((string) ($in['price'] ?? '')) !== '' && ($price === null || $price < 0 || $price >= 1e9)) { return [$f, 'The price is an amount, such as 2,400.00 - or leave it empty.']; } if ($f['bought'] === false || $f['bought'] === '') { return [$f, 'Give the day they were bought.']; } if ($f['expires'] === false || ($f['expires'] !== '' && $f['expires'] < $f['bought'])) { return [$f, 'The day they run out is a day after they were bought - or leave it empty for never.']; } if ($f['invoice'] && !$f['price']) { return [$f, 'Give a price to invoice them.']; } return [$f, '']; } /* * Add a block of hours to a contract - and, with $f['invoice'], a draft * invoice for it, one line. [block id, invoice id (0 for none), invoice * number]. */ function contractHoursAdd(array $row, array $f, $user_id, $by) { global $mysqli; $contract_id = intval($row['contract_id']); $invoice_id = 0; $number = ''; if ($f['invoice']) { $invoice_id = billingInvoiceCreate(intval($row['contract_client_id']), date('Y-m-d'), $f['category_id'] ?? 0, 'Prepaid hours - ' . $row['contract_name'], $by); if ($invoice_id) { billingItemSave('invoice', $invoice_id, ['name' => 'Prepaid support hours', 'description' => contractHoursWords($f['hours']) . ' for ' . $row['contract_name'] . ($f['expires'] !== '' ? ', good until ' . date('M j, Y', strtotime($f['expires'])) : ''), 'qty' => 1, 'price' => $f['price'], 'tax_id' => taxChoice(TAX_USUAL, intval($row['contract_client_id']), 'time')]); billingRecalc('invoice', $invoice_id); $number = billingNumber('invoice', billingRow('invoice', $invoice_id, false)); } } mysqli_query($mysqli, "INSERT INTO contract_blocks SET contract_block_contract_id = $contract_id, contract_block_hours = " . sprintf('%.2f', $f['hours']) . ", contract_block_price = " . ($f['price'] === null ? 'NULL' : sprintf('%.2f', $f['price'])) . ", contract_block_bought = '" . escapeSql($f['bought']) . "', contract_block_expires = " . ($f['expires'] !== '' ? "'" . escapeSql($f['expires']) . "'" : 'NULL') . ", contract_block_invoice_id = $invoice_id, contract_block_note = " . ($f['note'] !== '' ? "'" . escapeSql($f['note']) . "'" : 'NULL') . ", contract_block_created_by = " . intval($user_id)); $block_id = intval(mysqli_insert_id($mysqli)); contractHoursAlertReset($contract_id); return [$block_id, $invoice_id, $number]; } /** Delete a block nothing was drawn from. Whether it went. */ function contractHoursBlockDelete($block_id) { global $mysqli; $block_id = intval($block_id); mysqli_query($mysqli, "DELETE FROM contract_blocks WHERE contract_block_id = $block_id AND NOT EXISTS (SELECT 1 FROM contract_block_uses WHERE contract_block_use_block_id = $block_id AND contract_block_use_hours > 0)"); return mysqli_affected_rows($mysqli) === 1; } /** A block with its contract - or null. */ function contractBlockRow($block_id) { global $mysqli; if (!contractHoursReady()) { return null; } return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contract_blocks.*, contract_id, contract_name, contract_client_id, (SELECT COUNT(*) FROM contract_block_uses WHERE contract_block_use_block_id = contract_block_id AND contract_block_use_hours > 0) AS uses FROM contract_blocks JOIN contracts ON contract_block_contract_id = contract_id WHERE contract_block_id = " . intval($block_id))) ?: null; } /* * The night's prepaid hours, from contractsNightly(): each month's included * hours made, and every client with hours left has its done tickets drawn. * How many tickets were drawn. */ function contractHoursNightly($today = null) { global $mysqli; if (!contractHoursReady()) { return 0; } $sql = mysqli_query($mysqli, "SELECT * FROM contracts WHERE " . contractHoursLiveSql($today) . " AND contract_archived_at IS NULL AND contract_monthly_hours > 0"); while ($row = mysqli_fetch_assoc($sql)) { contractHoursEnsureMonthly($row, $today); } $drawn = 0; $sql = mysqli_query($mysqli, "SELECT DISTINCT contract_client_id FROM contracts JOIN contract_blocks ON contract_block_contract_id = contract_id WHERE " . contractHoursLiveSql($today) . " AND contract_archived_at IS NULL"); while ($row = mysqli_fetch_row($sql)) { $drawn += count(contractHoursDraw(intval($row[0]))['drawn']); } return $drawn; }