Files
itflow/functions/contract_billing.php

457 lines
24 KiB
PHP

<?php
/*
* What a contract sets while it is in force, and the recurring invoice that
* bills it.
*
* In force: an active, ended or cancelled contract, from the day it starts
* to its last day - and an active one that renews itself after that too,
* until the nightly job renews it. While a contract is in force:
* - a new ticket of the client's gets the contract's SLA for its priority
* (getTicketSlaId() in functions/sla.php asks contractSlaFor() first),
* ahead of the client's own in Admin > SLAs;
* - time bills at the contract's rate - its after-hours rate for a ticket
* opened outside the business days and hours in Admin > Settings (the
* ones SLAs use) - going by the contract in force on the day the work was
* done (contractTicketRate(), used by billingUnbilledTickets());
* - a new invoice is due on the contract's net terms (contractNetTerms()).
* Whatever the contract leaves empty, the client's own applies. When two
* are in force, the one that started last is asked first.
*
* A contract can be linked to one of the client's recurring invoices
* (contract_recurring_invoice_id, from database 3.4.5), or make one. It is
* paused while the contract is a draft, runs while it is active (or
* cancelled and still in force), and is paused once the contract ends -
* once: contract_recurring_stopped_at notes it, so one started again by
* hand is left alone. agent/contract.php shows it, with the Recurring
* invoice window (agent/modals/contract/contract_recurring.php) and the
* handlers in agent/post/contract.php.
*/
/** Whether the 3.4.5 update has run - a contract can be linked to a recurring invoice. */
function contractBillingReady() {
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.5', '>=');
}
return $ready;
}
/* ---------------------------------------------------------------- in force */
/*
* A client's contracts that are, were or will be in force - not drafts, not
* archived, with a start date - the latest to start first. Kept for the
* request; $refresh reads them again.
*/
function contractTermsContracts($client_id, $refresh = false) {
global $mysqli;
static $cache = [];
$client_id = intval($client_id);
if ($client_id <= 0 || !contractsReady()) {
return [];
}
if ($refresh || !isset($cache[$client_id])) {
$parts = implode(', ', array_map(function ($part) { return "contract_sla_{$part}_id"; }, CONTRACT_SLA_PRIORITIES));
$rows = [];
$sql = mysqli_query($mysqli, "SELECT contract_id, contract_name, contract_status, contract_start_date, contract_end_date, contract_auto_renew,
contract_rate_standard, contract_rate_after_hours, contract_net_terms, $parts
FROM contracts WHERE contract_client_id = $client_id AND contract_status IN ('Active', 'Ended', 'Cancelled') AND contract_archived_at IS NULL
AND contract_start_date IS NOT NULL
ORDER BY contract_start_date DESC, contract_id DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
$cache[$client_id] = $rows;
}
return $cache[$client_id];
}
/** Whether a contract is in force on a day ('Y-m-d'). */
function contractInForceOn(array $row, $date) {
$start = (string) ($row['contract_start_date'] ?? '');
$end = (string) ($row['contract_end_date'] ?? '');
if (!in_array($row['contract_status'] ?? '', ['Active', 'Ended', 'Cancelled'], true) || $start === '' || $start > $date) {
return false;
}
if ($end === '' || $end >= $date) {
return true;
}
// Past its last day - an active one that renews itself is only waiting for tonight's renewal
return $row['contract_status'] === 'Active' && intval($row['contract_auto_renew']) === 1;
}
/*
* The contract in force for a client on a day that sets $what - 'rate',
* 'net', an SLA priority part ('low', 'medium', 'high', 'urgent'), or ''
* for any - the one that started last. Null when none does.
*/
function contractTermsOn($client_id, $date, $what = '') {
$date = $date ?: date('Y-m-d');
foreach (contractTermsContracts($client_id) as $row) {
if (!contractInForceOn($row, $date)) {
continue;
}
if ($what === 'rate' && floatval($row['contract_rate_standard']) <= 0 && floatval($row['contract_rate_after_hours']) <= 0) {
continue;
}
if ($what === 'net' && $row['contract_net_terms'] === null) {
continue;
}
if (in_array($what, CONTRACT_SLA_PRIORITIES, true) && !intval($row["contract_sla_{$what}_id"])) {
continue;
}
return $row;
}
return null;
}
/** The contracts in force for a client today - the latest to start first. */
function contractsInForce($client_id) {
$today = date('Y-m-d');
return array_values(array_filter(contractTermsContracts($client_id), function ($row) use ($today) {
return contractInForceOn($row, $today);
}));
}
/* ---------------------------------------------------------------- SLAs */
/*
* The SLA a contract in force today sets for a priority of the client's
* tickets - 0 when none does (the client's own applies). An archived SLA
* doesn't count.
*/
function contractSlaFor($client_id, $priority, $date = null) {
global $mysqli;
$part = CONTRACT_SLA_PRIORITIES[(string) $priority] ?? null;
if (!$part) {
return 0;
}
foreach (contractTermsContracts($client_id) as $row) {
$sla_id = intval($row["contract_sla_{$part}_id"]);
if (!$sla_id || !contractInForceOn($row, $date ?? date('Y-m-d'))) {
continue;
}
if (mysqli_num_rows(mysqli_query($mysqli, "SELECT sla_id FROM slas WHERE sla_id = $sla_id AND sla_archived_at IS NULL LIMIT 1"))) {
return $sla_id;
}
}
return 0;
}
/* ---------------------------------------------------------------- rates */
/*
* Whether a moment is outside business hours - not a business day, a
* closure day, or before or after the hours in Admin > Settings (the
* calendar SLAs use). With no business calendar set up, nothing is.
*/
function contractAfterHours($datetime) {
$settings = getSlaSettings();
$ts = strtotime((string) $datetime);
$days = $settings['business_days'];
$start = (string) $settings['business_hours_start'];
$end = (string) $settings['business_hours_end'];
if (!$ts || empty($days) || $start === '' || $end === '' || $start >= $end) {
return false;
}
$day = date('Y-m-d', $ts);
if (!in_array(intval(date('N', $ts)), $days) || isset(getBusinessHolidays()[$day])) {
return true;
}
return $ts < strtotime("$day $start") || $ts >= strtotime("$day $end");
}
/*
* The hourly rate a ticket's time bills at, and where it comes from. The
* contract in force on the day the work was done (resolved, else closed,
* else opened): its after-hours rate when the ticket was opened outside
* business hours and it has one, else its rate. Else the client's own,
* else the default. $ticket has ticket_client_id, client_rate,
* ticket_created_at, ticket_resolved_at, ticket_closed_at. [rate, source
* (after_hours, contract, client or default), contract_id, contract_name,
* note - in words].
*/
function contractTicketRate(array $ticket) {
$done = substr((string) (($ticket['ticket_resolved_at'] ?? '') ?: (($ticket['ticket_closed_at'] ?? '') ?: ($ticket['ticket_created_at'] ?? ''))), 0, 10) ?: date('Y-m-d');
$after = null;
foreach (contractTermsContracts($ticket['ticket_client_id'] ?? 0) as $row) {
if (!contractInForceOn($row, $done)) {
continue;
}
// A rate of nothing is no rate - the client's own applies
if (floatval($row['contract_rate_after_hours']) > 0) {
$after = $after ?? contractAfterHours($ticket['ticket_created_at'] ?? '');
if ($after) {
return ['rate' => floatval($row['contract_rate_after_hours']), 'source' => 'after_hours', 'contract_id' => intval($row['contract_id']), 'contract_name' => $row['contract_name'],
'note' => $row['contract_name'] . "'s after-hours rate - opened outside business hours"];
}
}
if (floatval($row['contract_rate_standard']) > 0) {
return ['rate' => floatval($row['contract_rate_standard']), 'source' => 'contract', 'contract_id' => intval($row['contract_id']), 'contract_name' => $row['contract_name'],
'note' => $row['contract_name'] . "'s rate"];
}
}
$client_rate = floatval($ticket['client_rate'] ?? 0);
return ['rate' => billingTimeRate($client_rate), 'source' => $client_rate > 0 ? 'client' : 'default', 'contract_id' => 0, 'contract_name' => '',
'note' => $client_rate > 0 ? "the client's rate" : 'the default rate'];
}
/*
* A client's hourly rate today - the contract in force's, else the client's
* own, else the default - for a price to start from. [rate, source, note].
*/
function contractClientRate($client_id) {
return contractTicketRate(['ticket_client_id' => intval($client_id), 'client_rate' => getFieldById('clients', intval($client_id), 'client_rate'), 'ticket_resolved_at' => date('Y-m-d'),
// A moment in business hours, so the standard rate is the one given
'ticket_created_at' => '']);
}
/* ---------------------------------------------------------------- net terms */
/** The days a client's invoice dated $date is due after - the contract in force's net terms, else the client's. */
function contractNetTerms($client_id, $date, $client_terms = null) {
$row = contractTermsOn($client_id, $date ?: date('Y-m-d'), 'net');
if ($row) {
return intval($row['contract_net_terms']);
}
return intval($client_terms ?? getFieldById('clients', intval($client_id), 'client_net_terms'));
}
/*
* Every client with a contract in force today that sets net terms - client
* id => [terms, contract name] - for windows that pick the client and say
* when an invoice is due.
*/
function contractNetTermsToday() {
global $mysqli;
$terms = [];
if (!contractsReady()) {
return $terms;
}
$today = date('Y-m-d');
$sql = mysqli_query($mysqli, "SELECT contract_client_id, contract_net_terms, contract_name FROM contracts
WHERE contract_status IN ('Active', 'Ended', 'Cancelled') AND contract_archived_at IS NULL AND contract_net_terms IS NOT NULL
AND contract_start_date IS NOT NULL AND contract_start_date <= '$today'
AND (contract_end_date IS NULL OR contract_end_date >= '$today' OR (contract_status = 'Active' AND contract_auto_renew = 1))
ORDER BY contract_start_date DESC, contract_id DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$terms[intval($row['contract_client_id'])] = $terms[intval($row['contract_client_id'])] ?? [intval($row['contract_net_terms']), $row['contract_name']];
}
return $terms;
}
/* ---------------------------------------------------------------- the recurring invoice */
/** The recurring invoice a contract is linked to - with its client - or null. */
function contractRecurringRow(array $row) {
if (!contractBillingReady() || !intval($row['contract_recurring_invoice_id'] ?? 0)) {
return null;
}
return billingRow('recurring', intval($row['contract_recurring_invoice_id']), false) ?: null;
}
/** The contract a recurring invoice is linked to - id and name - or null. */
function contractForRecurring($recurring_invoice_id) {
global $mysqli;
if (!contractBillingReady() || !intval($recurring_invoice_id)) {
return null;
}
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contract_id, contract_name, contract_status, contract_start_date, contract_end_date, contract_auto_renew, contract_client_id
FROM contracts WHERE contract_recurring_invoice_id = " . intval($recurring_invoice_id) . " AND contract_archived_at IS NULL ORDER BY contract_id DESC LIMIT 1")) ?: null;
}
/** The client's recurring invoices a contract can be linked to - not archived, not another contract's. */
function contractRecurringChoices($client_id, $contract_id) {
global $mysqli;
$rows = [];
$sql = mysqli_query($mysqli, "SELECT recurring_invoice_id, recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_scope, recurring_invoice_frequency, recurring_invoice_amount,
recurring_invoice_status, recurring_invoice_next_date
FROM recurring_invoices WHERE recurring_invoice_client_id = " . intval($client_id) . " AND recurring_invoice_archived_at IS NULL
AND NOT EXISTS (SELECT 1 FROM contracts WHERE contract_recurring_invoice_id = recurring_invoice_id AND contract_id <> " . intval($contract_id) . " AND contract_archived_at IS NULL)
ORDER BY recurring_invoice_status DESC, recurring_invoice_number ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['recurring_invoice_id'])] = $row;
}
return $rows;
}
/** Whether a contract's recurring invoice should be running now - active, or cancelled and still in force. */
function contractRecurringShouldRun(array $row, $today = null) {
$today = $today ?? date('Y-m-d');
$status = (string) $row['contract_status'];
return $status === 'Active' || ($status === 'Cancelled' && !empty($row['contract_end_date']) && $row['contract_end_date'] >= $today);
}
/** A recurring invoice's amount and how often - "$1,500.00 a month". */
function contractRecurringAmountText(array $recurring) {
return billingMoney($recurring['recurring_invoice_amount'], $recurring['recurring_invoice_currency_code'] ?? '') . ' a ' . recurringInvoicePeriodWord($recurring['recurring_invoice_frequency']);
}
/*
* Start a contract's recurring invoice - its next run moved on to the first
* that hasn't passed, so missed runs aren't billed. Whether it changed.
*/
function contractRecurringStart(array $row, $by) {
global $mysqli;
$recurring = contractRecurringRow($row);
mysqli_query($mysqli, "UPDATE contracts SET contract_recurring_stopped_at = NULL WHERE contract_id = " . intval($row['contract_id']));
if (!$recurring || !empty($recurring['recurring_invoice_archived_at']) || intval($recurring['recurring_invoice_status']) === 1) {
return false;
}
$id = intval($recurring['recurring_invoice_id']);
$next = (string) $recurring['recurring_invoice_next_date'];
$moved = $next < date('Y-m-d') ? recurringInvoiceCatchUp($recurring) : $next;
mysqli_query($mysqli, "UPDATE recurring_invoices SET recurring_invoice_status = 1, recurring_invoice_next_date = '" . escapeSql($moved) . "' WHERE recurring_invoice_id = $id");
billingLog('recurring', $id, 'Active', "Started with contract {$row['contract_name']} by $by" . ($moved !== $next ? ' - the next run moved on to ' . date('M j, Y', strtotime($moved)) : ''));
logAudit('Recurring Invoice', 'Edit', escapeSql("$by started recurring invoice " . billingNumber('recurring', $recurring) . " with contract {$row['contract_name']}"), intval($row['contract_client_id']), $id);
return true;
}
/* Pause a contract's recurring invoice - $why, for its history. Whether it changed. */
function contractRecurringStop(array $row, $by, $why) {
global $mysqli;
$recurring = contractRecurringRow($row);
mysqli_query($mysqli, "UPDATE contracts SET contract_recurring_stopped_at = NOW() WHERE contract_id = " . intval($row['contract_id']));
if (!$recurring || intval($recurring['recurring_invoice_status']) !== 1) {
return false;
}
$id = intval($recurring['recurring_invoice_id']);
mysqli_query($mysqli, "UPDATE recurring_invoices SET recurring_invoice_status = 0 WHERE recurring_invoice_id = $id");
billingLog('recurring', $id, 'Paused', "Paused with contract {$row['contract_name']} by $by - $why");
logAudit('Recurring Invoice', 'Edit', escapeSql("$by paused recurring invoice " . billingNumber('recurring', $recurring) . " with contract {$row['contract_name']} - $why"), intval($row['contract_client_id']), $id);
return true;
}
/*
* Make a contract's recurring invoice match where the contract stands -
* running while it is active (or cancelled and still in force), paused
* while it is a draft or once it has ended. 'started', 'stopped' or ''.
*/
function contractRecurringFollow($contract_id, $by, $today = null) {
$row = contractRow($contract_id);
if (!$row || !contractRecurringRow($row)) {
return '';
}
if (contractRecurringShouldRun($row, $today)) {
return contractRecurringStart($row, $by) ? 'started' : '';
}
$why = $row['contract_status'] === 'Draft' ? 'it is a draft' : ($row['contract_status'] === 'Cancelled' ? 'it was cancelled' : 'it ended');
return contractRecurringStop($row, $by, $why) ? 'stopped' : '';
}
/* Link a contract to one of the client's recurring invoices, and have it follow the contract. 'started', 'stopped' or ''. */
function contractRecurringLink(array $row, $recurring_invoice_id, $by) {
global $mysqli;
mysqli_query($mysqli, "UPDATE contracts SET contract_recurring_invoice_id = " . intval($recurring_invoice_id) . ", contract_recurring_stopped_at = NULL WHERE contract_id = " . intval($row['contract_id']));
$recurring = billingRow('recurring', intval($recurring_invoice_id), false);
if ($recurring) {
billingLog('recurring', intval($recurring_invoice_id), intval($recurring['recurring_invoice_status']) ? 'Active' : 'Paused', "Linked to contract {$row['contract_name']} by $by - it starts and stops with it");
}
return contractRecurringFollow(intval($row['contract_id']), $by);
}
/* Unlink it - the recurring invoice stays as it is. */
function contractRecurringUnlink(array $row, $by) {
global $mysqli;
$recurring = contractRecurringRow($row);
mysqli_query($mysqli, "UPDATE contracts SET contract_recurring_invoice_id = 0, contract_recurring_stopped_at = NULL WHERE contract_id = " . intval($row['contract_id']));
if ($recurring) {
billingLog('recurring', intval($recurring['recurring_invoice_id']), intval($recurring['recurring_invoice_status']) ? 'Active' : 'Paused', "Unlinked from contract {$row['contract_name']} by $by");
}
}
/*
* A new recurring invoice from the Recurring invoice window - [fields,
* error]: the amount each time, how often, the first invoice's date, the
* line, the category, the tax and whether it is emailed by itself.
*/
function contractRecurringFromInput(array $in) {
$amount = trim((string) ($in['amount'] ?? '')) === '' ? null : billingNumberInput($in['amount']);
$start = trim((string) ($in['start'] ?? ''));
$parsed = DateTime::createFromFormat('!Y-m-d', $start);
$f = [
'amount' => $amount === null ? 0.0 : round($amount, 2),
'frequency' => validateRecurringFrequency((string) ($in['frequency'] ?? 'month')),
'start' => $parsed && $parsed->format('Y-m-d') === $start ? $start : '',
'item_name' => mb_substr(trim(strip_tags((string) ($in['item_name'] ?? ''))), 0, 200),
'description' => mb_substr(trim(strip_tags((string) ($in['description'] ?? ''))), 0, 1000),
'category_id' => max(0, intval($in['category'] ?? 0)),
'tax_id' => intval($in['tax_id'] ?? 0),
'auto_send' => !empty($in['auto_send']) ? 1 : 0,
];
if ($amount === null || $f['amount'] <= 0 || $f['amount'] >= 1e9) {
return [$f, 'Give the amount each time - more than nothing, such as 1,500.00.'];
}
if ($f['start'] === '') {
return [$f, 'Give the day of the first invoice.'];
}
if ($f['item_name'] === '') {
return [$f, 'Give the line a name.'];
}
return [$f, ''];
}
/* A first invoice date to suggest - the day the contract starts when that is still to come, else the first of next month. */
function contractRecurringFirstDate(array $row) {
$start = (string) ($row['contract_start_date'] ?? '');
return $start !== '' && $start > date('Y-m-d') ? $start : date('Y-m-d', strtotime('first day of next month'));
}
/*
* Make a recurring invoice for a contract - one line, in the company's
* currency, its scope the contract's name - and link it. Running only
* while the contract is. [id, number, state ('started', 'stopped' or '')].
*/
function contractRecurringCreate(array $row, array $f, $by) {
global $mysqli, $config_recurring_invoice_prefix, $session_company_currency;
$client_id = intval($row['contract_client_id']);
$number = billingNextNumber('recurring');
$prefix = escapeSql($config_recurring_invoice_prefix);
$scope = escapeSql(mb_substr((string) $row['contract_name'], 0, 255));
$currency = escapeSql($session_company_currency);
$start = escapeSql($f['start']);
$status = contractRecurringShouldRun($row) ? 1 : 0;
mysqli_query($mysqli, "INSERT INTO recurring_invoices SET recurring_invoice_prefix = '$prefix', recurring_invoice_number = $number, recurring_invoice_scope = '$scope',
recurring_invoice_frequency = '" . escapeSql($f['frequency']) . "', recurring_invoice_next_date = '$start', recurring_invoice_anchor_day = DAY('$start'), recurring_invoice_status = $status,
recurring_invoice_amount = 0, recurring_invoice_currency_code = '$currency', recurring_invoice_auto_send = " . intval($f['auto_send']) . ", recurring_invoice_email_notify = 1,
recurring_invoice_category_id = " . intval($f['category_id']) . ", recurring_invoice_client_id = $client_id");
$recurring_id = intval(mysqli_insert_id($mysqli));
billingItemSave('recurring', $recurring_id, ['name' => $f['item_name'], 'description' => $f['description'], 'qty' => 1, 'price' => $f['amount'], 'tax_id' => taxChoice($f['tax_id'], $client_id, 'goods')]);
billingRecalc('recurring', $recurring_id);
$ref = billingNumber('recurring', billingRow('recurring', $recurring_id, false));
billingLog('recurring', $recurring_id, $status ? 'Active' : 'Paused', "Made for contract {$row['contract_name']} by $by - first invoice on " . date('M j, Y', strtotime($f['start'])));
logAudit('Recurring Invoice', 'Create', escapeSql("$by made recurring invoice $ref for contract {$row['contract_name']}"), $client_id, $recurring_id);
mysqli_query($mysqli, "UPDATE contracts SET contract_recurring_invoice_id = $recurring_id, contract_recurring_stopped_at = " . ($status ? 'NULL' : 'NOW()') . " WHERE contract_id = " . intval($row['contract_id']));
return [$recurring_id, $ref, $status ? 'started' : 'stopped'];
}
/*
* The night's recurring invoices, from contractsNightly(): a contract that
* has ended, or was cancelled and is past its last day, pauses its
* recurring invoice - once. How many were paused.
*/
function contractRecurringNightly($today = null) {
global $mysqli;
if (!contractBillingReady()) {
return 0;
}
$today = escapeSql($today ?? date('Y-m-d'));
$stopped = 0;
$sql = mysqli_query($mysqli, "SELECT contract_id FROM contracts WHERE contract_recurring_invoice_id > 0 AND contract_recurring_stopped_at IS NULL AND contract_archived_at IS NULL
AND (contract_status = 'Ended' OR (contract_status = 'Cancelled' AND contract_end_date < '$today'))");
while ($id = mysqli_fetch_row($sql)) {
$stopped += contractRecurringFollow(intval($id[0]), 'ITFlow', $today) === 'stopped' ? 1 : 0;
// Paused already or not, the contract is done with it
mysqli_query($mysqli, "UPDATE contracts SET contract_recurring_stopped_at = NOW() WHERE contract_id = " . intval($id[0]) . " AND contract_recurring_stopped_at IS NULL");
}
return $stopped;
}