Files
itflow/functions/billing.php
johnnyq 1e5bffff3c Rates, net terms and SLAs come from the client's contract, else the instance defaults: database 3.5.0 adds a default after-hours rate (Settings > Defaults, now Rates and terms), gives each client whose own rate, net terms or SLAs differ from the defaults an active Standard terms contract setting just those (12-month term renewing itself, starting the day the client was added or the day before its earliest contract, archived with an archived client), deletes per-client SLA assignments and drops clients.client_rate and client_net_terms; time bills at the contract's rate, its after-hours rate or the default after-hours rate, else the default rate, new invoices are due on the contract's net terms else the default, and new tickets get the contract's SLA else the default; a client's own terms still apply until the update runs; the client add and edit windows lose the rate, terms and SLA fields for a note saying what the client is billed on and where it comes from, the Clients list loses Set Hourly Rate and Set Net Terms and shows the contract's rate, the overview's Terms shows the after-hours rate and whether terms are a contract's or the defaults, a contract's page and document show what it leaves empty from another contract in force or the default so signed documents stay unchanged, contract windows show the defaults as placeholders, contract names take a proper possessive; the client import and API create make a Standard terms contract for a rate or terms that aren't the defaults (returning contract_id), API update reports them in not_kept; the client export gives the rates, terms and SLAs as billed; demo data puts each demo client's terms on a contract; changelog updated
2026-10-27 22:22:34 -04:00

2443 lines
120 KiB
PHP

<?php
/*
* Billing documents - invoices, quotes and recurring invoices.
*
* The three share their shape: a numbered document for a client, with a
* scope, an income category, line items (a name, a description, a quantity,
* a price and a tax), a discount and a total. Each keeps its items in its own
* table - invoice_items, quote_items, recurring_invoice_items - with the same
* columns, and its history in the history table under its own id column.
* billingTypes() says where each keeps what, so one set of functions serves
* all three: the line items (billingItemSave(), billingItemDelete(),
* billingRecalc()), the totals, the numbers, and the statuses and pills the
* lists and pages show.
*
* Amounts are rounded to the cent where they are made - a line's tax and
* total, a document's total - so what is stored is what is shown, printed
* and paid.
*/
/** Where each kind of billing document keeps its parts. */
function billingTypes() {
return [
'invoice' => [
'label' => 'Invoice', 'plural' => 'Invoices', 'icon' => 'fa-file-invoice-dollar',
'table' => 'invoices', 'col' => 'invoice', 'items' => 'invoice_items', 'item_fk' => 'item_invoice_id',
'history_fk' => 'history_invoice_id', 'page' => 'invoice.php', 'list' => 'invoices.php', 'param' => 'invoice_id',
'number_setting' => 'config_invoice_next_number',
],
'quote' => [
'label' => 'Quote', 'plural' => 'Quotes', 'icon' => 'fa-comment-dollar',
'table' => 'quotes', 'col' => 'quote', 'items' => 'quote_items', 'item_fk' => 'item_quote_id',
'history_fk' => 'history_quote_id', 'page' => 'quote.php', 'list' => 'quotes.php', 'param' => 'quote_id',
'number_setting' => 'config_quote_next_number',
],
'recurring' => [
'label' => 'Recurring invoice', 'plural' => 'Recurring invoices', 'icon' => 'fa-redo-alt',
'table' => 'recurring_invoices', 'col' => 'recurring_invoice', 'items' => 'recurring_invoice_items', 'item_fk' => 'item_recurring_invoice_id',
'history_fk' => 'history_recurring_invoice_id', 'page' => 'recurring_invoice.php', 'list' => 'recurring_invoices.php', 'param' => 'recurring_invoice_id',
'number_setting' => 'config_recurring_invoice_next_number',
],
];
}
/** One kind's map, or null. */
function billingType($type) {
return billingTypes()[$type] ?? null;
}
/**
* An amount in a currency, the way the rest of the app shows money - in the
* company's locale, also on the guest pages and in PDFs where the agent
* session's formatter isn't set up.
*/
function billingMoney($amount, $currency_code = '') {
global $currency_format, $session_company_currency;
static $formatter = null;
if ($currency_format instanceof NumberFormatter) {
$format = $currency_format;
} else {
$formatter = $formatter ?: numfmt_create((string) (billingCompany()['company_locale'] ?? '') ?: 'en_US', NumberFormatter::CURRENCY);
$format = $formatter;
}
$currency_code = (string) $currency_code !== '' ? (string) $currency_code : (string) (($session_company_currency ?? '') ?: (billingCompany()['company_currency'] ?? '') ?: 'USD');
return numfmt_format_currency($format, floatval($amount), $currency_code);
}
/** A quantity without trailing zeros - 1, 2.5, 0.25. */
function billingQuantity($quantity) {
$text = rtrim(rtrim(number_format(floatval($quantity), 2, '.', ''), '0'), '.');
return $text === '' || $text === '-0' ? '0' : $text;
}
/** A document's number as shown - INV-123. */
function billingNumber($type, $row) {
$t = billingType($type);
return (string) ($row["{$t['col']}_prefix"] ?? '') . (string) ($row["{$t['col']}_number"] ?? '');
}
/**
* One document by id, with its client - raw, or null. $scoped limits it to
* the clients this agent may open; the guest pages pass false and check the
* document's own url key instead.
*/
function billingRow($type, $id, $scoped = true) {
global $mysqli;
$t = billingType($type);
$id = intval($id);
if (!$t || !$id) {
return null;
}
$c = $t['col'];
$scope = $scoped ? clientScopeSql("{$c}_client_id") : '';
$extra = $type === 'recurring' ? ', recurring_payment_id, recurring_payment_saved_payment_id, recurring_payment_method' : '';
$join = $type === 'recurring' ? 'LEFT JOIN recurring_payments ON recurring_payment_recurring_invoice_id = recurring_invoice_id' : '';
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT {$t['table']}.*, client_id, client_name, client_abbreviation, client_currency_code,
client_tax_id_number, category_name $extra
FROM {$t['table']}
LEFT JOIN clients ON client_id = {$c}_client_id
LEFT JOIN categories ON category_id = {$c}_category_id
$join
WHERE {$c}_id = $id $scope
LIMIT 1"));
return $row ?: null;
}
/** A document's line items, in order. */
function billingItems($type, $id) {
global $mysqli;
$t = billingType($type);
$id = intval($id);
$items = [];
if (!$t || !$id) {
return $items;
}
$seat_columns = $type === 'recurring' ? ', item_seat_source, item_seat_filter, item_seat_minimum' : ($type === 'quote' ? ', item_frequency' : '');
$sql = mysqli_query($mysqli, "SELECT item_id, item_name, item_description, item_quantity, item_price, item_subtotal, item_tax, item_total,
item_order, item_tax_id, item_product_id, item_created_at, tax_name, tax_percent $seat_columns
FROM {$t['items']}
LEFT JOIN taxes ON tax_id = item_tax_id
WHERE {$t['item_fk']} = $id
ORDER BY item_order ASC, item_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = $row;
}
return $items;
}
/**
* The taxes to offer - those not archived, and those archived after $since
* (a document's or line's date), so an old line keeps the tax it was given.
*/
function billingTaxes($since = null) {
global $mysqli;
$since_sql = $since ? "OR tax_archived_at > '" . escapeSql($since) . "'" : '';
$taxes = [];
$sql = mysqli_query($mysqli, "SELECT tax_id, tax_name, tax_percent FROM taxes WHERE tax_archived_at IS NULL $since_sql ORDER BY tax_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$taxes[intval($row['tax_id'])] = $row;
}
return $taxes;
}
/** The income categories to offer - the same rule as billingTaxes(): current ones, and those archived since $since (categoryPicker()). */
function billingCategories($since = null, $keep_id = 0) {
return categoryPicker('Income', [$keep_id], ['since' => $since]);
}
/** A line's subtotal, tax and total, each to the cent, at a single percent. */
function billingLineAmounts($quantity, $price, $tax_percent) {
$subtotal = round(floatval($quantity) * floatval($price), 2);
$tax = round($subtotal * floatval($tax_percent) / 100, 2);
return [$subtotal, $tax, round($subtotal + $tax, 2)];
}
/** A line's subtotal, tax and total at a rate (functions/tax.php) - each of its parts to the cent, on $base when a discount came off first. */
function billingLineTax($quantity, $price, $tax_id, $base = null) {
$subtotal = round(floatval($quantity) * floatval($price), 2);
$tax = intval($tax_id) ? taxAmounts($base === null ? $subtotal : $base, $tax_id)['tax'] : 0.0;
return [$subtotal, $tax, round($subtotal + $tax, 2)];
}
/** Whether a document's lines may still change - so its tax is worked out again. Not a paid, cancelled or non-billable invoice, nor an answered or invoiced quote. */
function billingLinesOpen($type, $status) {
if ($type === 'invoice') {
return !in_array((string) $status, ['Paid', 'Cancelled', 'Non-Billable'], true);
}
if ($type === 'quote') {
return !in_array((string) $status, ['Accepted', 'Declined', 'Invoiced'], true);
}
return true;
}
/**
* A discount shared out over lines in proportion to what each comes to -
* item id => its share, to the cent, adding up to the discount (never more
* than the lines come to). Lines that come to nothing or less take none.
*/
function billingDiscountShares(array $items, $discount) {
$discount = round(floatval($discount), 2);
$positive = array_filter($items, function ($item) {
return floatval($item['item_subtotal']) > 0;
});
$pool = array_sum(array_map(function ($item) {
return floatval($item['item_subtotal']);
}, $positive));
if ($discount == 0 || $pool <= 0) {
return [];
}
$sign = $discount < 0 ? -1 : 1;
$cents = intval(round(min(abs($discount), $pool) * 100));
$shares = [];
$remainders = [];
$given = 0;
foreach ($positive as $item) {
$exact = $cents * floatval($item['item_subtotal']) / $pool;
$whole = intval(floor($exact));
$shares[intval($item['item_id'])] = $whole;
$remainders[intval($item['item_id'])] = $exact - $whole;
$given += $whole;
}
arsort($remainders);
foreach (array_keys($remainders) as $item_id) {
if ($given >= $cents) {
break;
}
$shares[$item_id]++;
$given++;
}
return array_map(function ($share) use ($sign) {
return $sign * $share / 100;
}, $shares);
}
/**
* Work out each line's tax again - on what the line comes to, less its share
* of the discount when discounts come off before tax (Admin > Taxes). A quote's
* discount is on its one-time lines, or the first recurring group when it has
* none. A line whose rate no longer exists keeps the tax it has.
*/
function billingRecalcTaxes($type, $doc_id, $discount) {
global $mysqli;
$t = billingType($type);
$doc_id = intval($doc_id);
$frequency_column = $type === 'quote' ? ', item_frequency' : '';
$items = [];
$sql = mysqli_query($mysqli, "SELECT item_id, item_quantity, item_price, item_subtotal, item_tax, item_total, item_tax_id $frequency_column
FROM {$t['items']} WHERE {$t['item_fk']} = $doc_id");
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = $row;
}
$shares = [];
if (taxSettings()['discount_before'] && floatval($discount) != 0) {
$pool = $items;
if ($type === 'quote') {
$order = ['', 'month', 'quarter', 'year'];
$present = array_unique(array_map(function ($item) {
return quoteFrequency($item['item_frequency'] ?? '');
}, $items));
$on = '';
foreach ($order as $frequency) {
if (in_array($frequency, $present, true)) {
$on = $frequency;
break;
}
}
$pool = array_filter($items, function ($item) use ($on) {
return quoteFrequency($item['item_frequency'] ?? '') === $on;
});
}
$shares = billingDiscountShares($pool, $discount);
}
foreach ($items as $item) {
$tax_id = intval($item['item_tax_id']);
if ($tax_id && !taxRow($tax_id)) {
continue;
}
$subtotal = floatval($item['item_subtotal']);
$base = round($subtotal - ($shares[intval($item['item_id'])] ?? 0), 2);
$tax = $tax_id ? taxAmounts($base, $tax_id)['tax'] : 0.0;
$total = round($subtotal + $tax, 2);
if ($tax != floatval($item['item_tax']) || $total != floatval($item['item_total'])) {
mysqli_query($mysqli, "UPDATE {$t['items']} SET item_tax = $tax, item_total = $total WHERE item_id = " . intval($item['item_id']));
}
}
}
/**
* A document's tax by part, for its totals - label => amount, adding up to
* the tax on its lines. A line's tax is shared over its rate's parts as they
* come to on the line, so a rate made of parts shows each (State 6%, County
* 1%), and parts of the same name and percent on different rates are one.
* Also the registration numbers it is charged under, by the rate or part each is for.
*/
function billingTaxParts(array $items) {
$parts = [];
$registrations = [];
foreach ($items as $item) {
$tax = round(floatval($item['item_tax']), 2);
if ($tax == 0) {
continue;
}
// A rate no longer on file shows by the name and percent the line still has
$label = trim(($item['tax_name'] ?? 'Tax') . (isset($item['tax_percent']) && $item['tax_percent'] !== null ? ' ' . taxPercentText($item['tax_percent']) . '%' : ''));
$split = taxSplit($tax, taxRow($item['item_tax_id'] ?? 0), floatval($item['item_subtotal']), $label);
foreach ($split['parts'] as $part => $amount) {
$parts[$part] = round(($parts[$part] ?? 0) + $amount, 2);
}
$registrations += $split['registrations'];
}
return ['parts' => $parts, 'registrations' => $registrations];
}
/** A number from a form - "1,250.50", "$99", "-15" - or null when it is not one. */
function billingNumberInput($value) {
$value = trim(str_replace([',', ' ', '$', '€', '£'], '', (string) $value));
if ($value === '') {
return 0.0;
}
return is_numeric($value) ? round(floatval($value), 2) : null;
}
/**
* May a document's lines change? Not once an invoice is paid, cancelled or
* non-billable, nor once a quote has been answered or invoiced.
*/
function billingCanEditItems($type, $row) {
if (!$row || lookupUserPermission('module_sales') < 2) {
return false;
}
if ($type === 'invoice') {
return !in_array($row['invoice_status'], ['Paid', 'Cancelled', 'Non-Billable'], true);
}
if ($type === 'quote') {
return !in_array($row['quote_status'], ['Accepted', 'Declined', 'Invoiced'], true);
}
return true;
}
/**
* Add a line to a document, or change one. $in: name, description, qty,
* price, tax_id, product_id. For an invoice, a tracked product's stock goes
* down by what the line takes (and back up if the quantity drops). The
* document's total follows. The line's id, or null with $error.
*/
function billingItemSave($type, $doc_id, array $in, $item_id = 0, &$error = '') {
global $mysqli;
$error = '';
$t = billingType($type);
$doc_id = intval($doc_id);
$item_id = intval($item_id);
if (!$t || !$doc_id) {
$error = 'That document no longer exists.';
return null;
}
$name = trim((string) ($in['name'] ?? ''));
$description = trim((string) ($in['description'] ?? ''));
$quantity = billingNumberInput($in['qty'] ?? '1');
$price = billingNumberInput($in['price'] ?? '0');
$tax_id = intval($in['tax_id'] ?? 0);
$product_id = intval($in['product_id'] ?? 0);
if ($name === '') {
$error = 'A line needs a name.';
return null;
}
if (mb_strlen($name) > 200) {
$error = 'That name is too long - keep it under 200 characters, and put the rest in the description.';
return null;
}
// A recurring line can count its quantity from the client - see recurringSeatSources()
$seat_set = '';
if ($type === 'recurring' && array_key_exists('seat_source', $in)) {
$seat_source = (string) $in['seat_source'];
if (isset(recurringSeatSources()[$seat_source])) {
$seat_filter = recurringSeatFilter($seat_source, [
'tag' => $in['seat_tag'] ?? 0, 'types' => (array) ($in['seat_types'] ?? []),
'software_id' => $in['seat_software_id'] ?? 0, 'count' => $in['seat_count'] ?? 'used',
]);
$seat_minimum = billingNumberInput($in['seat_minimum'] ?? '0');
if ($seat_minimum === null || $seat_minimum < 0) {
$error = 'The minimum has to be a number, zero or more.';
return null;
}
$seat_count = recurringSeatCount(getFieldById('recurring_invoices', $doc_id, 'recurring_invoice_client_id'), $seat_source, $seat_filter);
if ($seat_count === null) {
$error = $seat_source === 'license' ? "Pick one of the client's licenses to count." : "Those can't be counted.";
return null;
}
$quantity = max(floatval($seat_count), $seat_minimum);
$seat_set = ", item_seat_source = '$seat_source', item_seat_filter = '" . escapeSql(json_encode($seat_filter)) . "', item_seat_minimum = $seat_minimum";
} else {
$seat_set = ", item_seat_source = NULL, item_seat_filter = NULL, item_seat_minimum = 0";
}
}
// A quote line is billed once, or each month, quarter or year
if ($type === 'quote' && array_key_exists('frequency', $in)) {
$frequency = quoteFrequency($in['frequency']);
$seat_set = ", item_frequency = " . ($frequency === '' ? 'NULL' : "'$frequency'");
}
if ($quantity === null || $price === null) {
$error = 'The quantity and price have to be numbers.';
return null;
}
if (abs($quantity) >= 1e9 || abs($price) >= 1e11) {
$error = 'That amount is too large.';
return null;
}
if ($tax_id && !taxRow($tax_id)) {
$error = 'That tax no longer exists.';
return null;
}
// Its tax on the whole line for now - billingRecalc() takes any discount off first
[$subtotal, $tax_amount, $total] = billingLineTax($quantity, $price, $tax_id);
$old = null;
if ($item_id) {
$old = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT item_quantity, item_product_id FROM {$t['items']} WHERE item_id = $item_id AND {$t['item_fk']} = $doc_id"));
if (!$old) {
$error = 'That line no longer exists.';
return null;
}
$product_id = intval($old['item_product_id']);
} elseif ($product_id && !getFieldById('products', $product_id, 'product_name')) {
$product_id = 0;
}
// Stock moves only with invoices, and only for products whose stock is tracked
$stock_change = 0;
if ($type === 'invoice' && $product_id && intval(getFieldById('products', $product_id, 'product_track_stock')) === 1) {
$stock_change = round($quantity - ($old ? floatval($old['item_quantity']) : 0), 2);
if ($stock_change > 0) {
$available = productOnHand($product_id);
if ($available < $stock_change) {
$error = 'Not enough ' . $name . ' in stock - ' . billingQuantity($available) . ' left.';
return null;
}
}
}
$set = "item_name = '" . escapeSql($name) . "', item_description = '" . escapeSql($description) . "',
item_quantity = $quantity, item_price = $price, item_subtotal = $subtotal, item_tax = $tax_amount, item_total = $total, item_tax_id = $tax_id$seat_set";
if ($item_id) {
mysqli_query($mysqli, "UPDATE {$t['items']} SET $set WHERE item_id = $item_id");
} else {
$order = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(MAX(item_order), 0) + 1 FROM {$t['items']} WHERE {$t['item_fk']} = $doc_id"))[0]);
mysqli_query($mysqli, "INSERT INTO {$t['items']} SET $set, item_order = $order, item_product_id = $product_id, {$t['item_fk']} = $doc_id");
$item_id = mysqli_insert_id($mysqli);
}
if ($stock_change != 0) {
productStockMove($product_id, -$stock_change, $stock_change > 0 ? 'sold' : 'returned', [
'invoice_id' => $doc_id, 'item_id' => $item_id, 'note' => billingNumber('invoice', billingRow('invoice', $doc_id, false)) . ($old ? ' - quantity changed' : ''),
]);
}
billingRecalc($type, $doc_id);
return $item_id;
}
/** Change a line's price - its amounts at its tax rate, then the document's total. False when the line isn't on it. */
function billingItemSetPrice($type, $doc_id, $item_id, $price) {
global $mysqli;
$t = billingType($type);
$doc_id = intval($doc_id);
$item_id = intval($item_id);
$item = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT item_quantity, item_subtotal, item_tax, item_tax_id FROM {$t['items']}
WHERE item_id = $item_id AND {$t['item_fk']} = $doc_id"));
if (!$item) {
return false;
}
$price = round(floatval($price), 2);
if (!intval($item['item_tax_id']) || taxRow($item['item_tax_id'])) {
[$subtotal, $tax, $total] = billingLineTax($item['item_quantity'], $price, $item['item_tax_id']);
} else {
// Its rate is gone - the rate it was charged at
$percent = floatval($item['item_subtotal']) != 0 ? floatval($item['item_tax']) / floatval($item['item_subtotal']) * 100 : 0;
[$subtotal, $tax, $total] = billingLineAmounts($item['item_quantity'], $price, $percent);
}
mysqli_query($mysqli, "UPDATE {$t['items']} SET item_price = $price, item_subtotal = $subtotal, item_tax = $tax, item_total = $total WHERE item_id = $item_id");
billingRecalc($type, $doc_id);
return true;
}
/** Take a line off a document - a tracked product goes back on the shelf. The line, or null. */
function billingItemDelete($type, $doc_id, $item_id) {
global $mysqli;
$t = billingType($type);
$doc_id = intval($doc_id);
$item_id = intval($item_id);
$item = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT item_name, item_quantity, item_product_id FROM {$t['items']} WHERE item_id = $item_id AND {$t['item_fk']} = $doc_id"));
if (!$item) {
return null;
}
mysqli_query($mysqli, "DELETE FROM {$t['items']} WHERE item_id = $item_id");
$product_id = intval($item['item_product_id']);
if ($type === 'invoice' && $product_id && intval(getFieldById('products', $product_id, 'product_track_stock')) === 1 && floatval($item['item_quantity']) > 0) {
productStockMove($product_id, floatval($item['item_quantity']), 'returned', [
'invoice_id' => $doc_id, 'item_id' => $item_id, 'note' => billingNumber('invoice', billingRow('invoice', $doc_id, false)) . ' - line taken off',
]);
}
// Assets made from the line stay, still bought on the invoice; an expense or trip billed on it is to bill again
if ($type === 'invoice') {
mysqli_query($mysqli, "UPDATE assets SET asset_invoice_item_id = 0 WHERE asset_invoice_id = $doc_id AND asset_invoice_item_id = $item_id");
mysqli_query($mysqli, "UPDATE expenses SET expense_invoice_id = NULL, expense_invoice_item_id = NULL WHERE expense_invoice_id = $doc_id AND expense_invoice_item_id = $item_id");
mysqli_query($mysqli, "UPDATE trips SET trip_invoice_id = 0, trip_invoice_item_id = 0 WHERE trip_invoice_id = $doc_id AND trip_invoice_item_id = $item_id");
}
billingRecalc($type, $doc_id);
return $item;
}
/** Put a document's lines in this order - item ids, first to last. */
function billingItemsOrder($type, $doc_id, array $item_ids) {
global $mysqli;
$t = billingType($type);
$doc_id = intval($doc_id);
foreach (array_values($item_ids) as $order => $item_id) {
$item_id = intval($item_id);
mysqli_query($mysqli, "UPDATE {$t['items']} SET item_order = " . ($order + 1) . " WHERE item_id = $item_id AND {$t['item_fk']} = $doc_id");
}
}
/**
* A document's total from its lines, less its discount - stored, and for an
* invoice the status follows what has been paid against the new total.
*/
function billingRecalc($type, $doc_id) {
global $mysqli;
$t = billingType($type);
$doc_id = intval($doc_id);
$c = $t['col'];
$status_column = $type === 'recurring' ? "''" : "{$c}_status";
$doc = mysqli_fetch_row(mysqli_query($mysqli, "SELECT {$c}_discount_amount, $status_column FROM {$t['table']} WHERE {$c}_id = $doc_id"));
$discount = floatval($doc[0] ?? 0);
// Each line's tax again, while its lines may change - on its share of the
// discount taken off first, when discounts come off before tax
if ($doc && billingLinesOpen($type, $doc[1])) {
billingRecalcTaxes($type, $doc_id, $discount);
}
if ($type === 'quote') {
// What is billed once, and what the recurring lines come to a month
$groups = quoteLineGroups(billingItems('quote', $doc_id), $discount);
$amount = isset($groups['']) ? $groups['']['total'] : 0.0;
mysqli_query($mysqli, "UPDATE quotes SET quote_amount = $amount, quote_monthly_amount = " . quoteMonthly($groups) . " WHERE quote_id = $doc_id");
return $amount;
}
$lines = floatval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(item_total), 0) FROM {$t['items']} WHERE {$t['item_fk']} = $doc_id"))[0]);
$amount = round($lines - $discount, 2);
mysqli_query($mysqli, "UPDATE {$t['table']} SET {$c}_amount = $amount WHERE {$c}_id = $doc_id");
if ($type === 'invoice') {
$status = getFieldById('invoices', $doc_id, 'invoice_status');
if (in_array($status, ['Sent', 'Viewed', 'Partial', 'Paid'], true) && invoicePaid($doc_id) > 0) {
updateInvoiceStatusFromPayments($doc_id);
}
}
return $amount;
}
/** Take the tax off every line of a document - a tax exempt client's - and work it out again. How many lines were taxed. */
function billingRemoveTax($type, $doc_id) {
global $mysqli;
$t = billingType($type);
$doc_id = intval($doc_id);
mysqli_query($mysqli, "UPDATE {$t['items']} SET item_tax_id = 0, item_tax = 0, item_total = item_subtotal
WHERE {$t['item_fk']} = $doc_id AND (item_tax_id != 0 OR item_tax != 0)");
$lines = mysqli_affected_rows($mysqli);
billingRecalc($type, $doc_id);
return $lines;
}
/**
* A document's tax for the API - its subtotal, tax, the tax by part
* ([{name, amount}]) and the registration numbers it is charged under, keyed
* with the document's column prefix (invoice_subtotal, quote_tax...). A
* quote's are for what it bills up front.
*/
function billingApiTaxFields($type, array $row) {
$c = billingType($type)['col'];
$items = billingItems($type, $row["{$c}_id"]);
$totals = billingTotals($type, $row, $items);
$subtotal = $totals['subtotal'];
$tax = $totals['tax'];
$parts = $totals['taxes'];
if ($type === 'quote' && !empty($totals['recurring'])) {
$once = $totals['groups'][''] ?? ['subtotal' => 0.0, 'tax' => 0.0, 'taxes' => []];
[$subtotal, $tax, $parts] = [$once['subtotal'], $once['tax'], $once['taxes']];
}
$out = [];
foreach ($parts as $name => $amount) {
$out[] = ['name' => $name, 'amount' => round($amount, 2)];
}
return [
"{$c}_subtotal" => round($subtotal, 2),
"{$c}_tax" => round($tax, 2),
"{$c}_taxes" => $out,
"{$c}_tax_registrations" => billingTaxRegistrationLines($totals),
];
}
/** Subtotal, discount, tax and total for showing - from the lines, and the stored total. */
function billingTotals($type, $row, array $items) {
$c = billingType($type)['col'];
$subtotal = 0;
$tax = 0;
foreach ($items as $item) {
$subtotal += floatval($item['item_subtotal']);
$tax += floatval($item['item_tax']);
}
$tax_parts = billingTaxParts($items);
$totals = [
'subtotal' => round($subtotal, 2),
'discount' => floatval($row["{$c}_discount_amount"] ?? 0),
'credit' => floatval($row["{$c}_credit_amount"] ?? 0),
'tax' => round($tax, 2),
'taxes' => $tax_parts['parts'],
'tax_registrations' => $tax_parts['registrations'],
'total' => floatval($row["{$c}_amount"] ?? 0),
];
if ($type === 'quote') {
// One-time and recurring apart - total is what is billed once
$totals['groups'] = quoteLineGroups($items, $totals['discount']);
$totals['recurring'] = quoteHasRecurring($totals['groups']);
$totals['monthly'] = floatval($row['quote_monthly_amount'] ?? 0);
}
return $totals;
}
/** The next number for a new invoice, quote or recurring invoice - taken atomically. */
function billingNextNumber($type) {
global $mysqli;
$setting = billingType($type)['number_setting'];
mysqli_query($mysqli, "UPDATE settings SET $setting = LAST_INSERT_ID($setting), $setting = $setting + 1 WHERE company_id = 1");
return intval(mysqli_insert_id($mysqli));
}
/** A document's history, newest first. */
function billingHistory($type, $id) {
global $mysqli;
$t = billingType($type);
$id = intval($id);
$rows = [];
// A recurring invoice's entry for the lines a quote added carries the quote's id too - it is the recurring invoice's
$own = $type === 'quote' ? ' AND history_recurring_invoice_id = 0' : '';
$sql = mysqli_query($mysqli, "SELECT history_status, history_description, history_created_at FROM history WHERE {$t['history_fk']} = $id$own ORDER BY history_id DESC LIMIT 200");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** Add to a document's history. $description raw. */
function billingLog($type, $id, $status, $description) {
$id = intval($id);
logHistory(escapeSql($status), escapeSql($description), $type === 'invoice' ? $id : 0, $type === 'quote' ? $id : 0, $type === 'recurring' ? $id : 0);
}
/** The company the documents come from - its name, address and how to reach it, raw. */
function billingCompany() {
global $mysqli;
static $company = null;
if ($company === null) {
$company = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_name, company_address, company_city, company_state, company_zip, company_country,
company_phone, company_phone_country_code, company_email, company_website, company_logo, company_tax_id, company_locale, company_currency FROM companies WHERE company_id = 1")) ?: [];
$company['phone_display'] = !empty($company['company_phone']) ? formatPhoneNumber($company['company_phone'], $company['company_phone_country_code']) : '';
$logo = basename((string) ($company['company_logo'] ?? ''));
$company['logo_path'] = $logo !== '' && is_file(dirname(__DIR__) . "/uploads/settings/$logo") ? dirname(__DIR__) . "/uploads/settings/$logo" : '';
$company['logo_url'] = $company['logo_path'] !== '' ? '/uploads/settings/' . rawurlencode($logo) : '';
}
return $company;
}
/**
* Who a document is for - the client, its primary location and its billing
* contact (or primary contact), raw.
*/
function billingBillTo($client_id) {
global $mysqli;
$client_id = intval($client_id);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name, client_tax_id_number, location_address, location_city, location_state, location_zip, location_country
FROM clients
LEFT JOIN locations ON location_client_id = client_id AND location_primary = 1 AND location_archived_at IS NULL
WHERE client_id = $client_id LIMIT 1")) ?: [];
$contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name, contact_email, contact_phone, contact_phone_country_code, contact_extension
FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL AND (contact_billing = 1 OR contact_primary = 1)
ORDER BY contact_billing DESC, contact_primary DESC LIMIT 1")) ?: [];
$row['contact_name'] = $contact['contact_name'] ?? '';
$row['contact_email'] = $contact['contact_email'] ?? '';
$row['contact_phone'] = !empty($contact['contact_phone']) ? formatPhoneNumber($contact['contact_phone'], $contact['contact_phone_country_code']) . (!empty($contact['contact_extension']) ? ' x' . $contact['contact_extension'] : '') : '';
// "Tax exempt - certificate 12-345", printed under the client when nothing on the document is taxed
$row['tax_exempt_text'] = taxExemptText(taxClient($client_id), false);
return $row;
}
/** The registration numbers a document's tax is charged under - "GST No. 123456789 RT0001" - for under the company's details. */
function billingTaxRegistrationLines(array $totals) {
$lines = [];
foreach ($totals['tax_registrations'] ?? [] as $name => $number) {
// One number used by two rates is shown once
if (!isset($lines[$number])) {
$lines[$number] = "$name No. $number";
}
}
return array_values($lines);
}
/** The guest link for an invoice or a quote. */
function billingGuestUrl($type, $row) {
global $config_base_url;
if ($type === 'invoice') {
return "https://$config_base_url/guest/guest_view_invoice.php?invoice_id=" . intval($row['invoice_id']) . '&url_key=' . rawurlencode((string) $row['invoice_url_key']);
}
return "https://$config_base_url/guest/guest_view_quote.php?quote_id=" . intval($row['quote_id']) . '&url_key=' . rawurlencode((string) $row['quote_url_key']);
}
/** How many of a client's contacts the Send window would offer - none means no Send. */
function billingEmailableContacts($type, $client_id) {
global $mysqli;
$client_id = intval($client_id);
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL
AND contact_email IS NOT NULL AND contact_email != '' " . documentContactFilterSql($type)))[0]);
}
/* ---------------------------------------------------------------- Invoices */
/** What has been paid against an invoice. */
function invoicePaid($invoice_id) {
global $mysqli;
$invoice_id = intval($invoice_id);
return round(floatval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(payment_amount), 0) FROM payments WHERE payment_invoice_id = $invoice_id"))[0]), 2);
}
/** Invoice statuses still waiting on money. */
function invoiceOpenStatuses() {
return ['Sent', 'Viewed', 'Partial'];
}
/**
* Where an invoice stands for payment: open (owed and not late), overdue,
* draft, paid, cancelled or non-billable - with the balance, and how many
* days until it is due or since it was.
*/
function invoiceState($row, $paid) {
$status = (string) $row['invoice_status'];
$balance = round(floatval($row['invoice_amount']) - floatval($paid), 2);
$due = strtotime((string) $row['invoice_due']);
$today = strtotime(date('Y-m-d'));
$days = $due ? intval(round(($due - $today) / 86400)) : 0;
$state = [
'Draft' => 'draft', 'Paid' => 'paid', 'Cancelled' => 'cancelled', 'Non-Billable' => 'nonbillable',
][$status] ?? 'open';
if ($state === 'open' && $balance <= 0) {
$state = 'paid';
}
if ($state === 'open' && $days < 0) {
$state = 'overdue';
}
return ['state' => $state, 'balance' => $balance, 'paid' => round(floatval($paid), 2), 'days' => $days];
}
/** An invoice's status as a badge - its stored status, and Overdue when it is late. */
function invoiceStatusBadge($status, $overdue = false) {
$status = (string) $status;
$html = '<span class="badge rounded-pill text-bg-' . getInvoiceBadgeColor($status) . '">' . escapeHtml($status) . '</span>';
if ($overdue) {
$html .= ' <span class="badge rounded-pill text-bg-danger">Overdue</span>';
}
return $html;
}
/** "Due in 5 days", "Due today", "12 days overdue" - for an invoice still owed. */
function invoiceDuePill($state) {
if (!in_array($state['state'], ['open', 'overdue'], true)) {
return '';
}
$days = $state['days'];
if ($days < 0) {
$late = -$days;
return '<span class="badge rounded-pill text-bg-danger fw-normal" title="Past its due date"><i class="fas fa-exclamation-circle me-1"></i>' . $late . ' ' . ($late === 1 ? 'day' : 'days') . ' overdue</span>';
}
if ($days === 0) {
return '<span class="badge rounded-pill text-bg-warning fw-normal"><i class="fas fa-clock me-1"></i>Due today</span>';
}
$class = $days <= 7 ? 'bg-warning-subtle text-warning-emphasis' : 'bg-secondary-subtle text-secondary-emphasis';
return '<span class="badge rounded-pill ' . $class . ' fw-normal"><i class="far fa-clock me-1"></i>Due in ' . $days . ' ' . ($days === 1 ? 'day' : 'days') . '</span>';
}
/** May a payment be recorded against this invoice? */
/*
* The money on invoices - taking payments, changing and deleting them, and
* the receipts, reminders and statements that ask for or confirm them - is
* accounting work. Each needs Financial at a level, with Sales at Read to see
* the invoices; or Sales at a level (with Financial at a level), which is
* what reminders, statements and receipts took before. Changing an invoice's
* lines, sending it and deleting it stay with Sales.
*
* [action => [Financial level with Sales Read, [Sales level, Financial level] that will also do, or null]]
*/
function billingMoneyRules() {
return [
'take' => [2, null], // record a payment, charge a saved card, a batch payment
'edit' => [2, null], // change a payment
'delete' => [3, null], // delete a payment, or refund it through Stripe
'receipt' => [2, [2, 1]], // email a payment's receipt
'remind' => [2, [2, 0]], // email a payment reminder for a sent invoice
'statement' => [2, [2, 0]], // email a client their statement
];
}
/** May the agent do this with the money on invoices (billingMoneyRules())? */
function billingMoneyCan($action) {
$rule = billingMoneyRules()[$action] ?? null;
if (!$rule) {
return false;
}
$sales = intval(lookupUserPermission('module_sales'));
$financial = intval(lookupUserPermission('module_financial'));
if ($financial >= $rule[0] && $sales >= 1) {
return true;
}
return $rule[1] !== null && $sales >= $rule[1][0] && $financial >= $rule[1][1];
}
/** Turn the agent away unless billingMoneyCan($action), saying what it takes. */
function enforceBillingMoney($action) {
if (billingMoneyCan($action)) {
return;
}
$rule = billingMoneyRules()[$action] ?? [2, null];
$sales = intval(lookupUserPermission('module_sales'));
permissionRefuse($sales < 1
? permissionRefusedText('module_sales', 1, 0)
: permissionRefusedText('module_financial', $rule[0], intval(lookupUserPermission('module_financial'))));
}
function invoiceCanPay($row, $state) {
return in_array($row['invoice_status'], invoiceOpenStatuses(), true) && $state['balance'] > 0;
}
/** Does the client have a card on file with an active provider, to charge from the invoice? */
function invoiceHasSavedCard($client_id) {
global $mysqli;
$client_id = intval($client_id);
return (bool) mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM client_saved_payment_methods
JOIN payment_providers ON saved_payment_provider_id = payment_provider_id
WHERE saved_payment_client_id = $client_id AND payment_provider_active = 1 LIMIT 1"));
}
/** An invoice's payments, newest first, with the account they went to. */
function invoicePayments($invoice_id) {
global $mysqli;
$invoice_id = intval($invoice_id);
$rows = [];
$sql = mysqli_query($mysqli, "SELECT payment_id, payment_date, payment_amount, payment_currency_code, payment_method, payment_reference, account_name
FROM payments LEFT JOIN accounts ON account_id = payment_account_id
WHERE payment_invoice_id = $invoice_id ORDER BY payment_date DESC, payment_id DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** The tickets billed on an invoice, with the time worked on each. */
function invoiceTickets($invoice_id) {
global $mysqli;
$invoice_id = intval($invoice_id);
$rows = [];
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_created_at, ticket_closed_at,
COALESCE(SUM(TIME_TO_SEC(ticket_reply_time_worked)), 0) AS seconds
FROM tickets LEFT JOIN ticket_replies ON ticket_reply_ticket_id = ticket_id AND ticket_reply_archived_at IS NULL
WHERE ticket_invoice_id = $invoice_id
GROUP BY ticket_id ORDER BY ticket_id DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/* ------------------------------------------------------------ Billing time */
/*
* Time is billed by the ticket: a billable ticket that is resolved or closed
* is billed once, with all the time worked on it, onto an invoice line at the
* client's hourly rate (or the default rate when the client has none). The
* time is rounded the way Admin > Settings > Invoice says - to a step of
* minutes, to the nearest step or always up, never under a minimum - and
* ticket_invoice_id marks it billed. Unbilled Time, the ticket's Invoice
* window, Add Tickets on an invoice and the Unbilled Work report all count
* and price it with the functions here.
*/
/**
* The steps time can be rounded to, in minutes => label - each a whole
* number of hundredths of an hour, so the hours on a line are exact.
*/
function billingTimeIncrements() {
return [6 => '6 minutes (a tenth of an hour)', 12 => '12 minutes', 15 => '15 minutes (a quarter hour)', 30 => '30 minutes', 60 => 'An hour'];
}
/**
* How time is billed: increment (minutes), rounding ('nearest' or 'up'),
* minimum (minutes a ticket bills at least, once it has any time) and
* item_name (the line's name). Read once a request; until the database has
* the settings (2.9.2) it is the old way - the nearest 15 minutes.
*/
function billingTimeRules($fresh = false) {
global $mysqli;
static $rules = null;
if ($rules !== null && !$fresh) {
return $rules;
}
$rules = ['increment' => 15, 'rounding' => 'nearest', 'minimum' => 0, 'item_name' => 'Support [Hourly]'];
try {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_time_increment, config_time_rounding, config_time_minimum, config_time_item_name FROM settings WHERE company_id = 1"));
} catch (mysqli_sql_exception $e) {
$row = null;
}
if ($row) {
$increment = intval($row['config_time_increment']);
$rules['increment'] = isset(billingTimeIncrements()[$increment]) ? $increment : 15;
$rules['rounding'] = $row['config_time_rounding'] === 'up' ? 'up' : 'nearest';
$rules['minimum'] = max(0, min(1440, intval($row['config_time_minimum'])));
$rules['item_name'] = trim((string) $row['config_time_item_name']) !== '' ? (string) $row['config_time_item_name'] : 'Support [Hourly]';
}
return $rules;
}
/** The rules in words - "to the nearest 15 minutes", "up to the next 6 minutes, at least 1 hour". */
function billingTimeRulesText(?array $rules = null) {
$rules = $rules ?? billingTimeRules();
$step = $rules['increment'] === 60 ? 'hour' : $rules['increment'] . ' minutes';
$text = $rules['rounding'] === 'up' ? "up to the next $step" : "to the nearest $step";
if ($rules['minimum'] > 0) {
$text .= ', at least ' . billingDuration($rules['minimum'] * 60);
}
return $text;
}
/**
* The hours to bill for some seconds of work: rounded to the step, then
* never under the minimum. No time logged bills nothing.
*/
function billingTimeHours($seconds, ?array $rules = null) {
$rules = $rules ?? billingTimeRules();
$seconds = max(0, intval($seconds));
if ($seconds === 0) {
return 0.0;
}
$step = $rules['increment'] * 60;
$steps = $rules['rounding'] === 'up' ? intdiv($seconds + $step - 1, $step) : intdiv($seconds, $step) + ($seconds % $step > $step / 2 ? 1 : 0);
return round(max($steps * $step, $rules['minimum'] * 60) / 3600, 2);
}
/** Whether the 3.5.0 update has run - the default after-hours rate is there. */
function billingAfterHoursReady() {
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.5.0', '>=');
}
return $ready;
}
/**
* What a client is billed on when no contract in force sets it - the
* defaults in Admin > Settings > Defaults: ['rate' => the hourly rate,
* 'after_hours' => the after-hours rate (0: none, the hourly rate applies),
* 'net_terms' => days]. Read once a request.
*/
function billingDefaults() {
global $mysqli;
static $defaults = null;
if ($defaults === null) {
$after = billingAfterHoursReady() ? ', config_default_after_hours_rate' : '';
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_default_hourly_rate, config_default_net_terms$after FROM settings WHERE company_id = 1")) ?: [];
$defaults = [
'rate' => round(floatval($row['config_default_hourly_rate'] ?? 0), 2),
'after_hours' => round(floatval($row['config_default_after_hours_rate'] ?? 0), 2),
'net_terms' => intval($row['config_default_net_terms'] ?? 0),
];
}
return $defaults;
}
/*
* Until the 3.5.0 update drops it, a client row has client_net_terms - NOT
* NULL, no default - so a client added before the update runs is given the
* default's, which the update then leaves be, as it is no different. The
* columns a new client needs as [column => value]; [] once the update has run.
*/
function billingClientLegacyFields() {
return billingAfterHoursReady() ? [] : ['client_net_terms' => billingDefaults()['net_terms']];
}
/*
* Until the 3.5.0 update has run, a client still has the rate, net terms and
* SLAs of its own it had, and they apply where no contract in force sets them,
* as they did - nothing bills differently before the update turns them into
* the client's Standard terms contract. ['rate' => float or null (none),
* 'net_terms' => int or null, 'slas' => [priority => sla_id, 0 for none]];
* null once the update has run.
*/
function billingClientLegacyTerms($client_id) {
global $mysqli;
static $terms = [];
if (billingAfterHoursReady()) {
return null;
}
$client_id = intval($client_id);
if (!isset($terms[$client_id])) {
$row = $client_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_rate, client_net_terms FROM clients WHERE client_id = $client_id")) : null;
$slas = [];
$sql = $client_id ? mysqli_query($mysqli, "SELECT sla_assignment_priority, sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = $client_id") : false;
while ($sql && ($sla = mysqli_fetch_assoc($sql))) {
$slas[(string) $sla['sla_assignment_priority']] = intval($sla['sla_assignment_sla_id']);
}
$terms[$client_id] = [
'rate' => $row && round(floatval($row['client_rate']), 2) > 0 ? round(floatval($row['client_rate']), 2) : null,
'net_terms' => $row && $row['client_net_terms'] !== null ? intval($row['client_net_terms']) : null,
'slas' => $slas,
];
}
return $terms[$client_id];
}
/** billingClientLegacyFields() for an INSERT INTO clients SET - ", client_net_terms = 30", or ''. */
function billingClientLegacySql() {
$sql = '';
foreach (billingClientLegacyFields() as $column => $value) {
$sql .= ", $column = " . intval($value);
}
return $sql;
}
/** The default hourly rate - what time bills at when no contract in force sets a rate. */
function billingTimeRate() {
return billingDefaults()['rate'];
}
/**
* The SQL that makes a ticket unbilled: billable, resolved or closed, not
* archived, on no invoice, and not all covered by a contract's prepaid hours
* (functions/contract_hours.php). $include_open takes open ones too - the
* ticket's own Invoice window can bill a ticket before it is done.
*/
function billingUnbilledSql($include_open = false) {
return "ticket_billable = 1 AND ticket_invoice_id = 0 AND ticket_archived_at IS NULL AND ticket_client_id > 0"
. ($include_open ? '' : " AND ticket_status IN (" . TICKET_STATUS_RESOLVED . ", " . TICKET_STATUS_CLOSED . ")")
. (contractHoursReady() ? " AND NOT EXISTS (SELECT 1 FROM contract_block_uses WHERE contract_block_use_ticket_id = ticket_id AND contract_block_use_full = 1)" : '');
}
/**
* Unbilled tickets - one client's ($client_id), or every client the agent
* can open (0), optionally only $ticket_ids - longest waiting first. Each
* with its client, its assignee, the seconds worked (archived replies left
* out), the hours that bills - less any a contract's prepaid hours covered
* ('prepaid', the hours worked before that are 'worked') - the rate and what
* that comes to, and the days since it was done. The rate is the contract
* in force's when there is one (contractTicketRate() - 'rate_source' and
* 'rate_note' say whose), else the default.
*/
function billingUnbilledTickets($client_id = 0, array $ticket_ids = [], $include_open = false) {
global $mysqli;
$client_id = intval($client_id);
$ids = array_filter(array_map('intval', $ticket_ids));
$where = billingUnbilledSql($include_open && $ids) . ($client_id ? " AND ticket_client_id = $client_id" : '') . ($ids ? ' AND ticket_id IN (' . implode(',', $ids) . ')' : '')
. ' ' . clientScopeSql('ticket_client_id');
$rules = billingTimeRules();
$rows = [];
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_status, ticket_onsite, ticket_created_at, ticket_resolved_at, ticket_closed_at,
ticket_client_id, ticket_assigned_to, client_name, client_abbreviation, user_name, user_avatar,
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,
" . (contractHoursReady() ? 'COALESCE((SELECT SUM(contract_block_use_hours) FROM contract_block_uses WHERE contract_block_use_ticket_id = ticket_id), 0)' : '0') . " AS prepaid,
DATEDIFF(CURDATE(), COALESCE(ticket_resolved_at, ticket_closed_at, ticket_created_at)) AS waiting
FROM tickets
JOIN clients ON client_id = ticket_client_id
LEFT JOIN users ON user_id = ticket_assigned_to
WHERE $where
ORDER BY client_name ASC, waiting DESC, ticket_number ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$row['seconds'] = intval($row['seconds']);
$row['waiting'] = max(0, intval($row['waiting']));
$row['worked'] = billingTimeHours($row['seconds'], $rules);
$row['prepaid'] = round(floatval($row['prepaid']), 2);
$row['hours'] = max(0.0, round($row['worked'] - $row['prepaid'], 2));
$rate = function_exists('contractTicketRate') ? contractTicketRate($row)
: ['rate' => billingTimeRate(), 'source' => 'default', 'note' => 'the default rate'];
$row['rate'] = $rate['rate'];
$row['rate_source'] = $rate['source'];
$row['rate_note'] = $rate['note'];
$row['default_rate'] = $rate['source'] === 'default';
$row['value'] = round($row['hours'] * $row['rate'], 2);
$rows[intval($row['ticket_id'])] = $row;
}
return $rows;
}
/**
* The same, by client - each client with its tickets, time, hours (and the
* prepaid hours that covered part of them), value, the rate (its first
* ticket's, and 'rates' - each one they bill at), how long the oldest has
* waited, how many have no time logged, and its draft invoices. Biggest
* value first.
*/
function billingUnbilledByClient($client_id = 0) {
global $mysqli;
$clients = [];
foreach (billingUnbilledTickets($client_id) as $ticket) {
$id = intval($ticket['ticket_client_id']);
if (!isset($clients[$id])) {
$clients[$id] = ['client_id' => $id, 'client_name' => $ticket['client_name'], 'client_abbreviation' => $ticket['client_abbreviation'],
'rate' => $ticket['rate'], 'default_rate' => $ticket['default_rate'], 'rate_note' => $ticket['rate_note'] ?? '', 'rate_source' => $ticket['rate_source'] ?? '', 'rates' => [], 'tickets' => [], 'seconds' => 0, 'hours' => 0.0, 'prepaid' => 0.0, 'value' => 0.0,
'oldest' => 0, 'no_time' => 0, 'drafts' => []];
}
$clients[$id]['tickets'][intval($ticket['ticket_id'])] = $ticket;
// Each rate its tickets bill at - more than one when a contract's after-hours rate, or a change of contract, comes in
$clients[$id]['rates'][sprintf('%.2f', $ticket['rate'])] = true;
$clients[$id]['seconds'] += $ticket['seconds'];
$clients[$id]['hours'] += $ticket['hours'];
$clients[$id]['prepaid'] += $ticket['prepaid'];
$clients[$id]['value'] += $ticket['value'];
$clients[$id]['oldest'] = max($clients[$id]['oldest'], $ticket['waiting']);
$clients[$id]['no_time'] += $ticket['seconds'] === 0 ? 1 : 0;
}
if ($clients) {
$sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_date, invoice_amount, invoice_client_id FROM invoices
WHERE invoice_status = 'Draft' AND invoice_archived_at IS NULL AND invoice_client_id IN (" . implode(',', array_keys($clients)) . ")
ORDER BY invoice_date DESC, invoice_id DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$clients[intval($row['invoice_client_id'])]['drafts'][intval($row['invoice_id'])] = $row;
}
}
uasort($clients, function ($a, $b) {
return $b['value'] <=> $a['value'] ?: count($b['tickets']) <=> count($a['tickets']);
});
return $clients;
}
/** How many unbilled tickets the agent can see - for the menu. */
function billingUnbilledCount() {
global $mysqli;
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM tickets WHERE " . billingUnbilledSql() . ' ' . clientScopeSql('ticket_client_id')))[0]);
}
/** A ticket's invoice line description - "Ticket TCK-123 - Subject - 10/14/2026". */
function billingTimeDescription(array $ticket) {
return 'Ticket ' . $ticket['ticket_prefix'] . $ticket['ticket_number'] . ' - ' . $ticket['ticket_subject'] . ' - ' . date('m/d/Y', strtotime((string) $ticket['ticket_created_at']));
}
/**
* A new draft invoice for a client, as New Invoice makes one: the next
* number, due on the client's terms, in the company's currency. Its id.
*/
function billingInvoiceCreate($client_id, $date, $category_id, $scope, $by) {
global $mysqli, $config_invoice_prefix, $session_company_currency;
$client_id = intval($client_id);
$date = ($parsed = DateTime::createFromFormat('!Y-m-d', (string) $date)) && $parsed->format('Y-m-d') === (string) $date ? (string) $date : date('Y-m-d');
// The contract in force's net terms, else the client's
$terms = function_exists('contractNetTerms') ? contractNetTerms($client_id, $date) : billingDefaults()['net_terms'];
$number = billingNextNumber('invoice');
$prefix = escapeSql($config_invoice_prefix);
$scope = escapeSql(mb_substr(trim((string) $scope), 0, 255));
$currency = escapeSql($session_company_currency);
$category_id = intval($category_id);
$url_key = randomString(32);
mysqli_query($mysqli, "INSERT INTO invoices SET invoice_prefix = '$prefix', invoice_number = $number, invoice_scope = '$scope', invoice_date = '$date',
invoice_due = DATE_ADD('$date', INTERVAL $terms DAY), invoice_amount = 0, invoice_currency_code = '$currency', invoice_category_id = $category_id,
invoice_status = 'Draft', invoice_url_key = '$url_key', invoice_client_id = $client_id");
$invoice_id = intval(mysqli_insert_id($mysqli));
if ($invoice_id) {
billingLog('invoice', $invoice_id, 'Draft', "Invoice created by $by");
}
return $invoice_id;
}
/**
* Bill tickets' time on an invoice. $hours is ticket id => hours to bill
* (null: what the rules make of the time logged). Only the client's
* unbilled tickets are taken, and each is claimed on the way so two agents
* can't bill one twice. $opts:
* invoice_id one of the client's invoices whose lines can change, or 0 for a new draft
* date, category_id, scope for a new draft
* tax_id (TAX_USUAL: the client's usual rate for time), item_name, price (null or '': each ticket at its own rate -
* the contract in force's, else the client's), description (one ticket only)
* combine one line for all of them instead of one each (one for each rate, when they differ)
* lines false to put the tickets on without adding lines (billed by hand)
* include_open take tickets that aren't done yet too
* by who, for the history
* A ticket with no hours goes on the invoice without a line. Returns
* [invoice_id, number, new, tickets (references), hours, lines, no_charge]
* or null with $error.
*/
function billingTimeInvoice($client_id, array $hours, array $opts, &$error = '') {
global $mysqli;
$error = '';
$client_id = intval($client_id);
$by = (string) ($opts['by'] ?? 'ITFlow');
$tickets = $hours ? billingUnbilledTickets($client_id, array_keys($hours), !empty($opts['include_open'])) : [];
$add_lines = !isset($opts['lines']) || $opts['lines'];
if (!$client_id || !$tickets) {
$error = 'Nothing to bill - those tickets are already on an invoice, or no longer billable.';
return null;
}
// What each one bills
$bill = [];
foreach ($tickets as $ticket_id => $ticket) {
$value = $hours[$ticket_id] ?? null;
$h = ($value === null || $value === '') ? $ticket['hours'] : billingNumberInput($value);
if ($h === null || $h < 0 || $h >= 10000) {
$error = 'The hours for ' . $ticket['ticket_prefix'] . $ticket['ticket_number'] . ' have to be a number, zero or more.';
return null;
}
$bill[$ticket_id] = round($h, 2);
}
// One rate typed for them all, or each at its own
$price = isset($opts['price']) && $opts['price'] !== '' && $opts['price'] !== null ? billingNumberInput($opts['price']) : false;
if ($price === null || ($price !== false && abs($price) >= 1e9)) {
$error = 'The rate has to be a number.';
return null;
}
$tax_id = taxChoice($opts['tax_id'] ?? 0, $client_id, 'time');
if ($tax_id && !taxRow($tax_id)) {
$error = 'That tax no longer exists.';
return null;
}
$item_name = trim((string) ($opts['item_name'] ?? ''));
$item_name = $item_name !== '' ? $item_name : billingTimeRules()['item_name'];
if (mb_strlen($item_name) > 200) {
$error = 'That line name is too long - keep it under 200 characters.';
return null;
}
// The invoice
$invoice_id = intval($opts['invoice_id'] ?? 0);
$new = false;
if ($invoice_id) {
$invoice = billingRow('invoice', $invoice_id);
if (!$invoice || intval($invoice['client_id']) !== $client_id || !billingCanEditItems('invoice', $invoice)) {
$error = "That invoice isn't one of the client's you can still add to.";
return null;
}
} else {
$invoice_id = billingInvoiceCreate($client_id, $opts['date'] ?? '', $opts['category_id'] ?? 0, $opts['scope'] ?? '', $by);
if (!$invoice_id) {
$error = "The invoice couldn't be made.";
return null;
}
$new = true;
}
$number = billingNumber('invoice', billingRow('invoice', $invoice_id, false));
// Claim them - only those still unbilled go on
mysqli_query($mysqli, "UPDATE tickets SET ticket_invoice_id = $invoice_id WHERE ticket_id IN (" . implode(',', array_keys($bill)) . ") AND ticket_client_id = $client_id
AND ticket_invoice_id = 0 AND ticket_billable = 1");
$claimed = [];
$sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_invoice_id = $invoice_id AND ticket_id IN (" . implode(',', array_keys($bill)) . ")");
while ($row = mysqli_fetch_row($sql)) {
$claimed[intval($row[0])] = true;
}
if (!$claimed) {
if ($new) {
mysqli_query($mysqli, "DELETE FROM history WHERE history_invoice_id = $invoice_id");
mysqli_query($mysqli, "DELETE FROM invoices WHERE invoice_id = $invoice_id");
}
$error = 'Nothing to bill - someone else billed those tickets just now.';
return null;
}
// The lines
$lines = 0;
$no_charge = [];
$references = [];
$total_hours = 0.0;
$combined = [];
foreach ($bill as $ticket_id => $h) {
if (!isset($claimed[$ticket_id])) {
continue;
}
$ticket = $tickets[$ticket_id];
$reference = $ticket['ticket_prefix'] . $ticket['ticket_number'];
$references[] = $reference;
$total_hours += $h;
if (!$add_lines) {
logTicketHistory($ticket_id, escapeSql("Put on invoice $number by $by - no line added"));
continue;
}
if ($h <= 0) {
$no_charge[] = $reference;
} elseif (!empty($opts['combine'])) {
$line_price = $price !== false ? $price : floatval($ticket['rate']);
$key = sprintf('%.2f', $line_price);
$combined[$key] = $combined[$key] ?? ['price' => $line_price, 'hours' => 0.0, 'lines' => []];
$combined[$key]['hours'] += $h;
$combined[$key]['lines'][] = billingTimeDescription($ticket) . ' (' . billingQuantity($h) . ' h)';
} else {
$description = count($bill) === 1 && isset($opts['description']) ? (string) $opts['description'] : billingTimeDescription($ticket);
if (billingItemSave('invoice', $invoice_id, ['name' => $item_name, 'description' => $description, 'qty' => $h, 'price' => $price !== false ? $price : floatval($ticket['rate']), 'tax_id' => $tax_id])) {
$lines++;
}
}
logTicketHistory($ticket_id, escapeSql("Billed on invoice $number by $by - " . ($h > 0 ? billingQuantity($h) . ' h' : 'no charge')));
}
// One line for each rate - one line, unless a contract's after-hours rate or a change of contract makes more
foreach ($combined as $group) {
if (billingItemSave('invoice', $invoice_id, ['name' => $item_name, 'description' => implode("\n", $group['lines']), 'qty' => round($group['hours'], 2), 'price' => $group['price'], 'tax_id' => $tax_id])) {
$lines++;
}
}
billingRecalc('invoice', $invoice_id);
$invoice = billingRow('invoice', $invoice_id, false);
$what = (count($references) === 1 ? 'ticket ' : count($references) . ' tickets ') . implode(', ', array_slice($references, 0, 20)) . (count($references) > 20 ? ' and more' : '');
if ($add_lines) {
billingLog('invoice', $invoice_id, $invoice['invoice_status'], ucfirst($what) . " billed by $by - " . billingQuantity($total_hours) . ' h');
logAudit('Invoice', $new ? 'Create' : 'Edit', escapeSql("$by billed $what on invoice $number - " . billingQuantity($total_hours) . ' h'), $client_id, $invoice_id);
} else {
billingLog('invoice', $invoice_id, $invoice['invoice_status'], ucfirst($what) . " added by $by, without lines");
logAudit('Invoice', 'Edit', escapeSql("$by added $what to invoice $number, without lines"), $client_id, $invoice_id);
}
if ($new) {
triggerCustomAction('invoice_create', $invoice_id);
}
return ['invoice_id' => $invoice_id, 'number' => $number, 'new' => $new, 'tickets' => $references, 'hours' => round($total_hours, 2), 'lines' => $lines, 'no_charge' => $no_charge];
}
/**
* Mark unbilled tickets not billable - written off, so they leave Unbilled
* Time. Each gets it in its history. The references it changed.
*/
function billingTimeWriteOff($client_id, array $ticket_ids, $by) {
global $mysqli;
$tickets = $ticket_ids ? billingUnbilledTickets($client_id, $ticket_ids) : [];
$done = [];
foreach ($tickets as $ticket_id => $ticket) {
mysqli_query($mysqli, "UPDATE tickets SET ticket_billable = 0 WHERE ticket_id = $ticket_id AND ticket_invoice_id = 0 AND ticket_billable = 1");
if (mysqli_affected_rows($mysqli) === 1) {
logTicketHistory($ticket_id, escapeSql("Marked not billable by $by - " . billingDuration($ticket['seconds']) . ' logged was not billed'));
$done[] = $ticket['ticket_prefix'] . $ticket['ticket_number'];
}
}
return $done;
}
/** Seconds as "1h 30m". */
function billingDuration($seconds) {
$seconds = intval($seconds);
$hours = intdiv($seconds, 3600);
$minutes = intdiv($seconds % 3600, 60);
if (!$hours && !$minutes) {
return $seconds ? '<1m' : '-';
}
return trim(($hours ? "{$hours}h " : '') . ($minutes ? "{$minutes}m" : ''));
}
/* ------------------------------------------------------------------ Quotes */
/** A quote's badge colour by status. */
function getQuoteBadgeColor($status) {
return ['Sent' => 'warning', 'Viewed' => 'primary', 'Accepted' => 'success', 'Declined' => 'danger', 'Invoiced' => 'info'][(string) $status] ?? 'secondary';
}
/** Has a quote passed its expiry without an answer? */
function quoteExpired($row) {
return in_array($row['quote_status'], ['Draft', 'Sent', 'Viewed'], true)
&& !empty($row['quote_expire']) && strtotime((string) $row['quote_expire']) < strtotime(date('Y-m-d'));
}
/** A quote's status as a badge, and Expired when it ran out unanswered. */
function quoteStatusBadge($row) {
$html = '<span class="badge rounded-pill text-bg-' . getQuoteBadgeColor($row['quote_status']) . '">' . escapeHtml($row['quote_status']) . '</span>';
if (quoteExpired($row)) {
$html .= ' <span class="badge rounded-pill text-bg-dark">Expired</span>';
}
return $html;
}
/** "Expires in 5 days", "Expired 3 days ago" - for a quote not answered yet. */
function quoteExpiryPill($row) {
if (!in_array($row['quote_status'], ['Draft', 'Sent', 'Viewed'], true) || empty($row['quote_expire'])) {
return '';
}
$days = intval(round((strtotime((string) $row['quote_expire']) - strtotime(date('Y-m-d'))) / 86400));
if ($days < 0) {
return '<span class="badge rounded-pill bg-dark-subtle text-dark-emphasis fw-normal">Expired ' . (-$days) . ' ' . (-$days === 1 ? 'day' : 'days') . ' ago</span>';
}
if ($days === 0) {
return '<span class="badge rounded-pill text-bg-warning fw-normal">Expires today</span>';
}
$class = $days <= 7 ? 'bg-warning-subtle text-warning-emphasis' : 'bg-secondary-subtle text-secondary-emphasis';
return '<span class="badge rounded-pill ' . $class . ' fw-normal">Expires in ' . $days . ' ' . ($days === 1 ? 'day' : 'days') . '</span>';
}
/*
* A quote line is billed once, or each month, quarter or year
* (item_frequency). The one-time lines go on an invoice when the quote is
* invoiced, the others on a recurring invoice for each - quoteConvert().
* quote_amount is what is billed once; quote_monthly_amount what the
* recurring lines come to a month, for the lists and reports.
*/
/** How often a quote line is billed => label. '' is once. */
function quoteFrequencies() {
return ['' => 'One time', 'month' => 'Monthly', 'quarter' => 'Quarterly', 'year' => 'Yearly'];
}
/** A line's frequency, cleaned - '' for once. */
function quoteFrequency($value) {
$value = (string) $value;
return in_array($value, ['month', 'quarter', 'year'], true) ? $value : '';
}
/**
* A quote's lines by how often they are billed - one-time first, then each
* month, quarter and year - each with its lines, subtotal, taxes, discount
* and total. The discount comes off the one-time part; on a quote with
* nothing one-time, off its first recurring part (and onto that recurring
* invoice). Always has at least the one-time group.
*/
function quoteLineGroups(array $items, $discount = 0) {
$labels = ['' => 'One-time', 'month' => 'Each month', 'quarter' => 'Each quarter', 'year' => 'Each year'];
$blank = function ($frequency) use ($labels) {
return ['frequency' => $frequency, 'label' => $labels[$frequency], 'items' => [], 'subtotal' => 0.0, 'tax' => 0.0, 'taxes' => [], 'discount' => 0.0, 'total' => 0.0];
};
$groups = [];
foreach ($items as $item) {
$frequency = quoteFrequency($item['item_frequency'] ?? '');
$groups[$frequency] = $groups[$frequency] ?? $blank($frequency);
$groups[$frequency]['items'][] = $item;
$groups[$frequency]['subtotal'] += floatval($item['item_subtotal']);
$groups[$frequency]['tax'] += floatval($item['item_tax']);
$groups[$frequency]['total'] += floatval($item['item_total']);
}
foreach ($groups as $frequency => $group) {
$groups[$frequency]['taxes'] = billingTaxParts($group['items'])['parts'];
}
$ordered = [];
foreach (array_keys($labels) as $frequency) {
if (isset($groups[$frequency])) {
$ordered[$frequency] = $groups[$frequency];
}
}
if (!$ordered) {
$ordered[''] = $blank('');
}
$on = isset($ordered['']) ? '' : array_key_first($ordered);
$ordered[$on]['discount'] = floatval($discount);
foreach ($ordered as $frequency => $group) {
$ordered[$frequency]['subtotal'] = round($group['subtotal'], 2);
$ordered[$frequency]['tax'] = round($group['tax'], 2);
$ordered[$frequency]['total'] = round($group['total'] - $group['discount'], 2);
}
return $ordered;
}
/** Whether a quote has recurring lines - from quoteLineGroups(). */
function quoteHasRecurring(array $groups) {
return (bool) array_diff_key($groups, ['' => true]);
}
/** What the recurring groups come to a month - a quarterly one a third, a yearly one a twelfth. */
function quoteMonthly(array $groups) {
$monthly = 0.0;
foreach ($groups as $frequency => $group) {
if ($frequency !== '') {
$monthly += recurringInvoiceMonthly($group['total'], $frequency);
}
}
return round($monthly, 2);
}
/**
* What a quote comes to, in words - "$1,500.00", "$1,500.00 up front and
* $2,400.00 a month", "$2,400.00 a month".
*/
function quoteAmountText(array $groups, $currency) {
$parts = [];
foreach ($groups as $frequency => $group) {
if ($frequency === '') {
if ($group['items'] || count($groups) === 1) {
$parts[] = billingMoney($group['total'], $currency) . (count($groups) > 1 ? ' up front' : '');
}
} else {
$parts[] = billingMoney($group['total'], $currency) . ' a ' . recurringInvoicePeriodWord($frequency);
}
}
return count($parts) > 1 ? implode(', ', array_slice($parts, 0, -1)) . ' and ' . end($parts) : (string) reset($parts);
}
/** A quote's groups, from the database - for the pages that only have its row. */
function quoteGroups(array $quote) {
return quoteLineGroups(billingItems('quote', intval($quote['quote_id'])), $quote['quote_discount_amount'] ?? 0);
}
/**
* Put lines on a document after the ones already there - their stored
* amounts as they are, linked to the same products. $suffix goes on the end
* of each description. On an invoice, tracked products come off the shelf;
* check productStockShortfall() first.
*/
function billingCopyLines(array $items, $to_type, $to_id, $suffix = '') {
global $mysqli;
$t = billingType($to_type);
$to_id = intval($to_id);
$order = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(MAX(item_order), 0) FROM {$t['items']} WHERE {$t['item_fk']} = $to_id"))[0]);
$number = $to_type === 'invoice' ? billingNumber('invoice', billingRow('invoice', $to_id, false)) : '';
foreach ($items as $item) {
$order++;
$name = escapeSql($item['item_name']);
$description = escapeSql(trim(trim((string) $item['item_description']) . ($suffix !== '' ? "\n$suffix" : '')));
$product_id = intval($item['item_product_id'] ?? 0);
if ($product_id && !getFieldById('products', $product_id, 'product_id')) {
$product_id = 0;
}
mysqli_query($mysqli, "INSERT INTO {$t['items']} SET item_name = '$name', item_description = '$description', item_quantity = " . floatval($item['item_quantity']) . ",
item_price = " . floatval($item['item_price']) . ", item_subtotal = " . floatval($item['item_subtotal']) . ", item_tax = " . floatval($item['item_tax']) . ",
item_total = " . floatval($item['item_total']) . ", item_order = $order, item_tax_id = " . intval($item['item_tax_id']) . ", item_product_id = $product_id, {$t['item_fk']} = $to_id");
$new_item_id = intval(mysqli_insert_id($mysqli));
// On an invoice, a tracked product comes off the shelf - check first with productStockShortfall()
if ($to_type === 'invoice' && $product_id && floatval($item['item_quantity']) > 0 && intval(getFieldById('products', $product_id, 'product_track_stock')) === 1) {
productStockMove($product_id, -floatval($item['item_quantity']), 'sold', ['invoice_id' => $to_id, 'item_id' => $new_item_id, 'note' => $number]);
}
}
}
/** The day recurring billing starts by default - the first of next month. */
function quoteRecurringStart() {
return date('Y-m-01', strtotime('first day of next month'));
}
/**
* Invoice an accepted quote. Its one-time lines go on a new draft invoice;
* its lines billed each month, quarter or year go on a recurring invoice for
* each - a new one, or added to one of the client's of that frequency. $opts:
* date the invoice's date
* first_period put the recurring lines on the invoice too - the first period billed now
* recurring frequency => [to (0 for new, or a recurring invoice id), start (its first
* invoice date, new ones), auto_send (1 emails them, 0 leaves drafts)]
* by who, for the history
* The quote is claimed on the way (Accepted to Invoiced), so it is invoiced
* once. Returns [invoice_id (0 when nothing one-time), recurring (id =>
* number), numbers] or null with $error.
*/
function quoteConvert($quote_id, array $opts, &$error = '') {
global $mysqli, $config_invoice_prefix, $config_recurring_invoice_prefix;
$error = '';
$quote = billingRow('quote', $quote_id);
if (!$quote) {
$error = 'That quote no longer exists.';
return null;
}
$quote_id = intval($quote['quote_id']);
$client_id = intval($quote['client_id']);
$number = billingNumber('quote', $quote);
if ($quote['quote_status'] !== 'Accepted') {
$error = $quote['quote_status'] === 'Invoiced' ? "$number is already invoiced." : "$number has to be accepted before it is invoiced.";
return null;
}
$by = (string) ($opts['by'] ?? 'ITFlow');
$groups = quoteLineGroups(billingItems('quote', $quote_id), $quote['quote_discount_amount']);
$valid_date = function ($date) {
$date = (string) $date;
return ($parsed = DateTime::createFromFormat('!Y-m-d', $date)) && $parsed->format('Y-m-d') === $date ? $date : '';
};
$date = $valid_date($opts['date'] ?? '') ?: date('Y-m-d');
$first_period = !empty($opts['first_period']);
if (!$groups['']['items'] && !quoteHasRecurring($groups) && floatval($groups['']['discount'] ?? 0) == 0) {
$error = "$number has nothing on it to invoice.";
return null;
}
// Check every recurring part's choice before anything is made
$plans = [];
foreach ($groups as $frequency => $group) {
if ($frequency === '') {
continue;
}
$choice = (array) ($opts['recurring'][$frequency] ?? []);
$to = intval($choice['to'] ?? 0);
if ($to) {
$existing = billingRow('recurring', $to);
if (!$existing || intval($existing['client_id']) !== $client_id || $existing['recurring_invoice_frequency'] !== $frequency || $existing['recurring_invoice_archived_at']) {
$error = 'That recurring invoice is not one of ' . $quote['client_name'] . "'s billed " . strtolower(recurringInvoiceFrequencies()[$frequency]) . '.';
return null;
}
$plans[$frequency] = ['to' => $to, 'row' => $existing];
} else {
$start = $valid_date($choice['start'] ?? '') ?: quoteRecurringStart();
$plans[$frequency] = ['to' => 0, 'start' => $start, 'auto_send' => intval($choice['auto_send'] ?? 1) ? 1 : 0];
}
}
// What goes on the invoice has to be on the shelf, for products that track stock
$invoice_lines = $groups['']['items'] ?? [];
if ($first_period) {
foreach ($groups as $frequency => $group) {
if ($frequency !== '') {
$invoice_lines = array_merge($invoice_lines, $group['items']);
}
}
}
$shortfall = productStockShortfall($invoice_lines);
if ($shortfall !== '') {
$error = "$shortfall Add stock before invoicing $number.";
return null;
}
mysqli_begin_transaction($mysqli);
try {
// Claim it
mysqli_query($mysqli, "UPDATE quotes SET quote_status = 'Invoiced' WHERE quote_id = $quote_id AND quote_status = 'Accepted'");
if (mysqli_affected_rows($mysqli) !== 1) {
mysqli_rollback($mysqli);
$error = "$number was invoiced just now.";
return null;
}
$currency = escapeSql($quote['quote_currency_code']);
$category_id = intval($quote['quote_category_id']);
$scope = escapeSql($quote['quote_scope']);
$note = escapeSql($quote['quote_note']);
$numbers = [];
// The invoice - what is billed once, and the first period when asked
$invoice_id = 0;
$one_time = $groups['']['items'] ?? [];
$invoice_discount = isset($groups['']) ? floatval($groups['']['discount']) : 0.0;
if ($one_time || ($first_period && quoteHasRecurring($groups)) || $invoice_discount != 0) {
$terms = function_exists('contractNetTerms') ? contractNetTerms($client_id, $date) : billingDefaults()['net_terms'];
$invoice_number = billingNextNumber('invoice');
$prefix = escapeSql($config_invoice_prefix);
$url_key = randomString(32);
mysqli_query($mysqli, "INSERT INTO invoices SET invoice_prefix = '$prefix', invoice_number = $invoice_number, invoice_scope = '$scope', invoice_date = '$date',
invoice_due = DATE_ADD('$date', INTERVAL $terms DAY), invoice_discount_amount = $invoice_discount, invoice_amount = 0, invoice_currency_code = '$currency',
invoice_note = '$note', invoice_category_id = $category_id, invoice_status = 'Draft', invoice_url_key = '$url_key', invoice_quote_id = $quote_id, invoice_client_id = $client_id");
$invoice_id = intval(mysqli_insert_id($mysqli));
billingCopyLines($one_time, 'invoice', $invoice_id);
if ($first_period) {
foreach ($groups as $frequency => $group) {
if ($frequency !== '') {
billingCopyLines($group['items'], 'invoice', $invoice_id, 'First ' . recurringInvoicePeriodWord($frequency));
}
}
}
billingRecalc('invoice', $invoice_id);
$invoice_ref = billingNumber('invoice', billingRow('invoice', $invoice_id, false));
$numbers[] = $invoice_ref;
billingLog('invoice', $invoice_id, 'Draft', "Made from quote $number by $by");
logAudit('Invoice', 'Create', escapeSql("$by made invoice $invoice_ref from quote $number"), $client_id, $invoice_id);
// The ticket the quote was raised from is billed with it
mysqli_query($mysqli, "UPDATE tickets SET ticket_invoice_id = $invoice_id WHERE ticket_quote_id = $quote_id AND ticket_invoice_id = 0");
}
// The recurring invoices
$recurring = [];
foreach ($plans as $frequency => $plan) {
$group = $groups[$frequency];
if ($plan['to']) {
$recurring_id = $plan['to'];
billingCopyLines($group['items'], 'recurring', $recurring_id);
if ($group['discount'] != 0) {
mysqli_query($mysqli, "UPDATE recurring_invoices SET recurring_invoice_discount_amount = recurring_invoice_discount_amount + " . floatval($group['discount']) . " WHERE recurring_invoice_id = $recurring_id");
}
billingRecalc('recurring', $recurring_id);
$ref = billingNumber('recurring', $plan['row']);
// On the recurring invoice's history, and carrying the quote's id too - that is
// how the quote finds a recurring invoice it wasn't made into (quoteMadeFrom)
logHistory(escapeSql(intval($plan['row']['recurring_invoice_status']) ? 'Active' : 'Paused'), escapeSql(count($group['items']) . ' line' . (count($group['items']) === 1 ? '' : 's') . " from quote $number added by $by"), 0, $quote_id, $recurring_id);
logAudit('Recurring Invoice', 'Edit', escapeSql("$by added the lines of quote $number to recurring invoice $ref"), $client_id, $recurring_id);
} else {
$recurring_number = billingNextNumber('recurring');
$prefix = escapeSql($config_recurring_invoice_prefix);
$start = $plan['start'];
$discount = floatval($group['discount']);
mysqli_query($mysqli, "INSERT INTO recurring_invoices SET recurring_invoice_prefix = '$prefix', recurring_invoice_number = $recurring_number, recurring_invoice_scope = '$scope',
recurring_invoice_frequency = '$frequency', recurring_invoice_next_date = '$start', recurring_invoice_anchor_day = DAY('$start'), recurring_invoice_status = 1,
recurring_invoice_discount_amount = $discount, recurring_invoice_amount = 0, recurring_invoice_currency_code = '$currency', recurring_invoice_note = '$note',
recurring_invoice_auto_send = {$plan['auto_send']}, recurring_invoice_email_notify = 1, recurring_invoice_category_id = $category_id,
recurring_invoice_quote_id = $quote_id, recurring_invoice_client_id = $client_id");
$recurring_id = intval(mysqli_insert_id($mysqli));
billingCopyLines($group['items'], 'recurring', $recurring_id);
billingRecalc('recurring', $recurring_id);
$ref = billingNumber('recurring', billingRow('recurring', $recurring_id, false));
billingLog('recurring', $recurring_id, 'Active', "Made from quote $number by $by - first invoice on $start");
logAudit('Recurring Invoice', 'Create', escapeSql("$by made recurring invoice $ref from quote $number"), $client_id, $recurring_id);
}
$recurring[$recurring_id] = $ref;
$numbers[] = $ref;
}
billingLog('quote', $quote_id, 'Invoiced', 'Invoiced as ' . implode(', ', $numbers) . " by $by");
logAudit('Quote', 'Edit', escapeSql("$by invoiced quote $number as " . implode(', ', $numbers)), $client_id, $quote_id);
mysqli_commit($mysqli);
} catch (Throwable $e) {
mysqli_rollback($mysqli);
logApp('Quote', 'error', "Invoicing quote $number failed: " . $e->getMessage());
$error = "$number couldn't be invoiced - nothing was changed.";
return null;
}
if ($invoice_id) {
triggerCustomAction('invoice_create', $invoice_id);
}
return ['invoice_id' => $invoice_id, 'recurring' => $recurring, 'numbers' => $numbers];
}
/**
* What a quote became - the invoices and recurring invoices made from it, and
* the recurring invoices its lines were added to ('added' 1).
*/
function quoteMadeFrom($quote_id) {
global $mysqli;
$quote_id = intval($quote_id);
$made = [];
$sql = mysqli_query($mysqli, "SELECT 'invoice' AS type, invoice_id AS id, invoice_prefix AS prefix, invoice_number AS number, invoice_amount AS amount, invoice_status AS status, invoice_currency_code AS currency, '' AS frequency, 0 AS added
FROM invoices WHERE invoice_quote_id = $quote_id
UNION ALL
SELECT 'recurring', recurring_invoice_id, recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_amount, IF(recurring_invoice_status = 1, 'Active', 'Paused'), recurring_invoice_currency_code, recurring_invoice_frequency,
IF(recurring_invoice_quote_id = $quote_id, 0, 1)
FROM recurring_invoices WHERE recurring_invoice_quote_id = $quote_id
OR recurring_invoice_id IN (SELECT history_recurring_invoice_id FROM history WHERE history_quote_id = $quote_id AND history_recurring_invoice_id > 0)");
while ($row = mysqli_fetch_assoc($sql)) {
$made[] = $row;
}
return $made;
}
/**
* A client's answer to a quote, from its guest link or the client portal:
* accepted only while it waits for one (Sent or Viewed) and has not run
* out, declined only while it waits - the first answer through wins. Then
* it goes in the quote's history, the agents are told, the quote_accept or
* quote_decline custom action runs and the quote notification address
* hears. $by, for the portal: name (who answered), note (what they said).
* ['ok' => bool, 'error' => why not, for the client].
*/
function billingQuoteAnswer($quote_id, $accept, array $by = []) {
global $mysqli;
$quote_id = intval($quote_id);
$quote = billingRow('quote', $quote_id, false);
if (!$quote) {
return ['ok' => false, 'error' => 'This quote is no longer awaiting a response'];
}
if ($accept) {
mysqli_query($mysqli, "UPDATE quotes SET quote_status = 'Accepted' WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed') AND quote_expire >= CURDATE()");
} else {
mysqli_query($mysqli, "UPDATE quotes SET quote_status = 'Declined' WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed')");
}
if (mysqli_affected_rows($mysqli) !== 1) {
$expired = $accept ? mysqli_fetch_row(mysqli_query($mysqli, "SELECT quote_expire FROM quotes WHERE quote_id = $quote_id AND quote_status IN ('Sent', 'Viewed') AND quote_expire < CURDATE()")) : null;
return ['ok' => false, 'error' => $expired ? "This quote ran out on " . escapeHtml($expired[0]) . " - ask for an updated one" : "This quote is no longer awaiting a response"];
}
$word = $accept ? 'Accepted' : 'Declined';
$number = billingNumber('quote', $quote);
$name = trim((string) ($by['name'] ?? ''));
$note = trim((string) ($by['note'] ?? ''));
billingLog('quote', $quote_id, $word, $name !== '' ? "$word by $name in the client portal" . ($note !== '' ? "\nNote: $note" : '') : "$word by the client");
appNotify("Quote $word", escapeSql("Quote $number has been " . strtolower($word) . " by {$quote['client_name']}" . ($name !== '' ? " ($name, in the client portal)" : '')),
"/agent/quote.php?quote_id=$quote_id", intval($quote['client_id']));
triggerCustomAction($accept ? 'quote_accept' : 'quote_decline', $quote_id);
if ($name !== '') {
logAudit("Quote", $accept ? "Accept" : "Decline", escapeSql("$name " . strtolower($word) . " quote $number in the client portal"), intval($quote['client_id']), $quote_id);
}
// Our team hears - functions/billing_email.php
$settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_smtp_host, config_quote_notification_email FROM settings WHERE company_id = 1")) ?: [];
if (!empty($settings['config_smtp_host']) && !empty($settings['config_quote_notification_email'])) {
billingQuoteAnsweredNotify($quote_id, $accept, $name, $note);
}
return ['ok' => true, 'error' => ''];
}
/**
* PDFs a client sends back with an accepted quote - a signed copy, a purchase
* order - from its guest link or the client portal. $files: the upload's
* $_FILES entry (file[]). Each goes in the client's Client Uploads folder and
* on the quote, and the agents hear. $by: name, for the portal.
* ['added' => file names, 'failed' => how many could not be taken].
*/
function billingQuoteAddFiles($quote_id, array $files, array $by = []) {
global $mysqli;
$quote = billingRow('quote', $quote_id, false);
$result = ['added' => [], 'failed' => 0];
if (!$quote || !isset($files['name']) || !is_array($files['name'])) {
return $result;
}
$quote_id = intval($quote['quote_id']);
$client_id = intval($quote['client_id']);
$number = billingNumber('quote', $quote);
$number_sql = escapeSql($number);
$name = trim((string) ($by['name'] ?? ''));
$upload_file_dir = dirname(__DIR__) . "/uploads/clients/$client_id/";
mkdirMissing($upload_file_dir);
for ($i = 0; $i < count($files['name']); $i++) {
$single_file = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
$file_reference_name = checkFileUpload($single_file, ['pdf']);
if (!$file_reference_name) {
$result['failed']++;
continue;
}
$file_name = escapeSql($single_file['name']);
$extarr = explode('.', $single_file['name']);
$file_extension = escapeSql(strtolower(end($extarr)));
$file_mime_type = escapeSql($single_file['type']);
$file_size = intval($single_file['size']);
// Get/Create a top-level folder called Client Uploads
$folder = mysqli_fetch_row(mysqli_query($mysqli, "SELECT folder_id FROM folders WHERE folder_name = 'Client Uploads' AND parent_folder = 0 AND folder_client_id = $client_id LIMIT 1"));
if ($folder) {
$folder_id = intval($folder[0]);
} else {
mysqli_query($mysqli, "INSERT INTO folders SET folder_name = 'Client Uploads', parent_folder = 0, folder_location = 1, folder_client_id = $client_id");
$folder_id = intval(mysqli_insert_id($mysqli));
logAudit("Folder", "Create", "Automatically created folder Client Uploads", $client_id, $folder_id);
}
if (!move_uploaded_file($single_file['tmp_name'], $upload_file_dir . $file_reference_name)) {
$result['failed']++;
continue;
}
mysqli_query($mysqli, "INSERT INTO files SET file_reference_name = '" . escapeSql($file_reference_name) . "', file_name = '$file_name', file_description = 'Uploaded via $number_sql', file_ext = '$file_extension', file_mime_type = '$file_mime_type', file_size = $file_size, file_folder_id = $folder_id, file_client_id = $client_id");
$file_id = intval(mysqli_insert_id($mysqli));
mysqli_query($mysqli, "INSERT INTO quote_files SET quote_id = $quote_id, file_id = $file_id");
appNotify("Quote File", "$file_name was uploaded to quote $number_sql", "/agent/quote.php?quote_id=$quote_id", $client_id);
billingLog('quote', $quote_id, 'Upload', $name !== '' ? "$name uploaded file {$single_file['name']} in the client portal" : "Client uploaded file {$single_file['name']}");
logAudit("File", "Upload", ($name !== '' ? escapeSql($name) : 'Guest') . " uploaded file $file_name to quote $number_sql" . ($name !== '' ? ' in the client portal' : ''), $client_id);
$result['added'][] = (string) $single_file['name'];
}
return $result;
}
/** The files sent back with a quote - name and when, newest first. */
function billingQuoteFiles($quote_id) {
global $mysqli;
$quote_id = intval($quote_id);
$rows = [];
$sql = mysqli_query($mysqli, "SELECT files.file_id, file_name, file_size, file_created_at FROM quote_files
JOIN files ON files.file_id = quote_files.file_id
WHERE quote_files.quote_id = $quote_id AND file_archived_at IS NULL ORDER BY file_created_at DESC, files.file_id DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/* ------------------------------------------------------- Recurring invoices */
/** How long a missed run is still billed by the nightly run - a night or a few the cron didn't run. */
const RECURRING_INVOICE_GRACE_DAYS = 7;
/** How often a recurring invoice runs => label. Each is also a MySQL interval unit. */
function recurringInvoiceFrequencies() {
return ['month' => 'Monthly', 'quarter' => 'Quarterly', 'year' => 'Yearly'];
}
/** How many months a run covers. */
function recurringInvoiceMonths($frequency) {
return ['quarter' => 3, 'year' => 12][(string) $frequency] ?? 1;
}
/** "month", "quarter" or "year" - for "each month", "a quarter". */
function recurringInvoicePeriodWord($frequency) {
return ['quarter' => 'quarter', 'year' => 'year'][(string) $frequency] ?? 'month';
}
/** What a recurring invoice brings in a month. */
function recurringInvoiceMonthly($amount, $frequency) {
return floatval($amount) / recurringInvoiceMonths($frequency);
}
/**
* The same in SQL, for sums - a quarterly one counts a third, a yearly one a
* twelfth. Takes the amount and frequency column names.
*/
function recurringInvoiceMonthlySql($amount = 'recurring_invoice_amount', $frequency = 'recurring_invoice_frequency') {
return "(CASE $frequency WHEN 'year' THEN $amount / 12 WHEN 'quarter' THEN $amount / 3 ELSE $amount END)";
}
/** "In 5 days", "Today", "3 days late" - for an active recurring invoice's next run. */
function recurringInvoiceNextPill($row) {
if (intval($row['recurring_invoice_status']) !== 1) {
return '<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis fw-normal"><i class="fas fa-pause me-1"></i>Paused</span>';
}
$days = intval(round((strtotime((string) $row['recurring_invoice_next_date']) - strtotime(date('Y-m-d'))) / 86400));
if ($days < 0) {
$title = -$days <= RECURRING_INVOICE_GRACE_DAYS ? 'Missed - the next nightly run bills it' : "Too far behind to bill by itself - use Invoice Now, or move it on";
return '<span class="badge rounded-pill text-bg-danger fw-normal" title="' . $title . '"><i class="fas fa-exclamation-triangle me-1"></i>' . (-$days) . ' ' . (-$days === 1 ? 'day' : 'days') . ' late</span>';
}
if ($days === 0) {
return '<span class="badge rounded-pill text-bg-info fw-normal">Today</span>';
}
return '<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis fw-normal">In ' . $days . ' ' . ($days === 1 ? 'day' : 'days') . '</span>';
}
/**
* A run date one period on. It keeps to the day of the month the schedule
* bills on ($anchor_day, recurring_invoice_anchor_day) - or the date's own
* day when there isn't one - and falls back to a shorter month's last day:
* the 31st runs on the 28th of February and the 31st of March.
*/
function recurringInvoiceStep($date, $frequency, $anchor_day = 0) {
$d = new DateTime((string) $date);
$day = intval($anchor_day) > 0 ? intval($anchor_day) : intval($d->format('j'));
$d->modify('first day of this month')->modify('+' . recurringInvoiceMonths($frequency) . ' months');
$d->setDate(intval($d->format('Y')), intval($d->format('n')), min($day, intval($d->format('t'))));
return $d->format('Y-m-d');
}
/** A recurring invoice's next run date after $date. */
function recurringInvoiceAfter($row, $date) {
return recurringInvoiceStep($date, $row['recurring_invoice_frequency'], intval($row['recurring_invoice_anchor_day'] ?? 0));
}
/** The next few dates a recurring invoice will run on. */
function recurringInvoiceUpcoming($row, $count = 3) {
$dates = [];
$date = (string) $row['recurring_invoice_next_date'];
for ($i = 0; $i < $count && $date; $i++) {
$dates[] = $date;
$date = recurringInvoiceAfter($row, $date);
}
return $dates;
}
/** The first run date from a recurring invoice's next date on that hasn't passed - the missed runs skipped. */
function recurringInvoiceCatchUp($row) {
$date = (string) $row['recurring_invoice_next_date'];
$today = date('Y-m-d');
for ($guard = 0; $date < $today && $guard < 1200; $guard++) {
$date = recurringInvoiceAfter($row, $date);
}
return $date;
}
/**
* The period a run bills for - from its date to the day before the next one -
* and the words for it that can go in a recurring invoice's scope, notes and
* lines: {month} "November 2026", {quarter} "Q4 2026", {year} "2026" and
* {period} "Nov 1 - Nov 30, 2026".
*/
function recurringInvoicePeriod($row, $date) {
$start = new DateTime((string) $date);
$end = new DateTime(recurringInvoiceAfter($row, $date));
$end->modify('-1 day');
if ($end < $start) {
$end = clone $start;
}
$same_year = $start->format('Y') === $end->format('Y');
return [
'start' => $start->format('Y-m-d'),
'end' => $end->format('Y-m-d'),
'{month}' => $start->format('F Y'),
'{quarter}' => 'Q' . intval(ceil(intval($start->format('n')) / 3)) . ' ' . $start->format('Y'),
'{year}' => $start->format('Y'),
'{period}' => $start->format($same_year ? 'M j' : 'M j, Y') . ' - ' . $end->format('M j, Y'),
];
}
/** Put a period's words into text - the tokens it doesn't use are left as typed. */
function recurringInvoiceTokens($text, array $period) {
return strtr((string) $text, array_intersect_key($period, array_flip(['{month}', '{quarter}', '{year}', '{period}'])));
}
/* ---------------------------------------------------------- Seat-based lines */
/*
* A line on a recurring invoice can take its quantity from the client: their
* contacts (users), their devices, or a license's seats - counted again each
* time an invoice is made, and never less than the line's minimum.
* item_seat_source says which, item_seat_filter (JSON) narrows it: a tag, the
* device types, or the license and whether to count the seats it has or the
* ones in use.
*/
/** The places a line's quantity can come from. */
function recurringSeatSources() {
return [
'contacts' => ['label' => 'Users', 'unit' => 'user', 'icon' => 'fa-user'],
'assets' => ['label' => 'Devices', 'unit' => 'device', 'icon' => 'fa-desktop'],
'license' => ['label' => 'License seats', 'unit' => 'seat', 'icon' => 'fa-cube'],
];
}
/** Device statuses that are not billed - gone, or out of use. */
function recurringSeatAssetSkipStatuses() {
return ['Lost', 'Stolen', 'Retired'];
}
/** A line's seat filter, cleaned - from the stored JSON or a form's array. */
function recurringSeatFilter($source, $filter) {
if (!is_array($filter)) {
$filter = json_decode((string) $filter, true);
$filter = is_array($filter) ? $filter : [];
}
$clean = [];
if (in_array($source, ['contacts', 'assets'], true) && intval($filter['tag'] ?? 0)) {
$clean['tag'] = intval($filter['tag']);
}
if ($source === 'assets' && !empty($filter['types']) && is_array($filter['types'])) {
$types = array_values(array_unique(array_filter(array_map(function ($type) {
return mb_substr(trim((string) $type), 0, 100);
}, $filter['types']), 'strlen')));
if ($types) {
$clean['types'] = $types;
}
}
if ($source === 'license') {
$clean['software_id'] = intval($filter['software_id'] ?? 0);
$clean['count'] = ($filter['count'] ?? '') === 'total' ? 'total' : 'used';
}
return $clean;
}
/**
* How many a client has right now, for a seat source and filter - or null
* when it can't be counted (the license is gone, or isn't the client's).
*/
function recurringSeatCount($client_id, $source, array $filter) {
global $mysqli;
$client_id = intval($client_id);
$tag = intval($filter['tag'] ?? 0);
if ($source === 'contacts') {
$tag_sql = $tag ? "AND contact_id IN (SELECT contact_id FROM contact_tags WHERE tag_id = $tag)" : '';
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL $tag_sql"))[0]);
}
if ($source === 'assets') {
$skip = "'" . implode("', '", array_map('escapeSql', recurringSeatAssetSkipStatuses())) . "'";
$types_sql = !empty($filter['types']) ? "AND asset_type IN ('" . implode("', '", array_map('escapeSql', $filter['types'])) . "')" : '';
$tag_sql = $tag ? "AND asset_id IN (SELECT asset_tag_asset_id FROM asset_tags WHERE asset_tag_tag_id = $tag)" : '';
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM assets WHERE asset_client_id = $client_id AND asset_archived_at IS NULL
AND (asset_status IS NULL OR asset_status NOT IN ($skip)) $types_sql $tag_sql"))[0]);
}
if ($source === 'license') {
$software_id = intval($filter['software_id'] ?? 0);
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT software_seats, " . softwareSeatsUsedSql('software.software_id') . " FROM software
WHERE software_id = $software_id AND software_client_id = $client_id AND software_archived_at IS NULL"));
if (!$row) {
return null;
}
return ($filter['count'] ?? 'used') === 'total' ? intval($row[0]) : intval($row[1]);
}
return null;
}
/** What a seat line counts, in words - "Users tagged VIP", "Desktops and laptops", "Seats on Microsoft 365 in use". */
function recurringSeatDescribe($source, array $filter) {
global $mysqli;
$tag = intval($filter['tag'] ?? 0);
$tag_name = $tag ? (string) getFieldById('tags', $tag, 'tag_name') : '';
if ($source === 'contacts') {
return $tag_name !== '' ? "Contacts tagged $tag_name" : 'All contacts';
}
if ($source === 'assets') {
$types = $filter['types'] ?? [];
$what = $types ? (count($types) > 1 ? implode(', ', array_slice($types, 0, -1)) . ' and ' . end($types) : $types[0]) : 'All devices';
return $what . ($tag_name !== '' ? " tagged $tag_name" : '') . ' in use';
}
if ($source === 'license') {
$name = (string) getFieldById('software', intval($filter['software_id'] ?? 0), 'software_name');
return ($name !== '' ? $name : 'A license that no longer exists') . (($filter['count'] ?? 'used') === 'total' ? ' - seats bought' : ' - seats in use');
}
return '';
}
/**
* What a client has to count, for choosing a seat line's source: how many
* contacts and devices, the tags on them, their device types and their
* licenses - each with how many.
*/
function recurringSeatOptions($client_id) {
global $mysqli;
$client_id = intval($client_id);
$skip = "'" . implode("', '", array_map('escapeSql', recurringSeatAssetSkipStatuses())) . "'";
$assets_in_use = "asset_client_id = $client_id AND asset_archived_at IS NULL AND (asset_status IS NULL OR asset_status NOT IN ($skip))";
$options = [
'contacts' => recurringSeatCount($client_id, 'contacts', []),
'assets' => recurringSeatCount($client_id, 'assets', []),
'contact_tags' => [], 'asset_types' => [], 'asset_tags' => [], 'licenses' => [],
];
$sql = mysqli_query($mysqli, "SELECT tag_id, tag_name, COUNT(*) AS n FROM tags JOIN contact_tags USING (tag_id) JOIN contacts USING (contact_id)
WHERE contact_client_id = $client_id AND contact_archived_at IS NULL AND tag_archived_at IS NULL GROUP BY tag_id ORDER BY tag_name");
while ($row = mysqli_fetch_assoc($sql)) {
$options['contact_tags'][intval($row['tag_id'])] = $row;
}
$sql = mysqli_query($mysqli, "SELECT asset_type, COUNT(*) AS n FROM assets WHERE $assets_in_use AND asset_type <> '' GROUP BY asset_type ORDER BY asset_type");
while ($row = mysqli_fetch_assoc($sql)) {
$options['asset_types'][$row['asset_type']] = intval($row['n']);
}
$sql = mysqli_query($mysqli, "SELECT tag_id, tag_name, COUNT(*) AS n FROM tags JOIN asset_tags ON asset_tag_tag_id = tag_id JOIN assets ON asset_id = asset_tag_asset_id
WHERE $assets_in_use AND tag_archived_at IS NULL GROUP BY tag_id ORDER BY tag_name");
while ($row = mysqli_fetch_assoc($sql)) {
$options['asset_tags'][intval($row['tag_id'])] = $row;
}
$sql = mysqli_query($mysqli, "SELECT software_id, software_name, software_seats, " . softwareSeatsUsedSql('software.software_id') . " AS used FROM software
WHERE software_client_id = $client_id AND software_archived_at IS NULL ORDER BY software_name");
while ($row = mysqli_fetch_assoc($sql)) {
$options['licenses'][intval($row['software_id'])] = $row;
}
return $options;
}
/**
* The seat lines on a recurring invoice, each with what it counts now and
* the quantity that makes (never under its minimum): line row + seat_count,
* seat_quantity, seat_label and seat_drift (true when the line is off).
*/
function recurringSeatLines($recurring_invoice_id, $client_id = null) {
global $mysqli;
$recurring_invoice_id = intval($recurring_invoice_id);
if ($client_id === null) {
$client_id = intval(getFieldById('recurring_invoices', $recurring_invoice_id, 'recurring_invoice_client_id'));
}
$lines = [];
$sql = mysqli_query($mysqli, "SELECT item_id, item_name, item_quantity, item_seat_source, item_seat_filter, item_seat_minimum FROM recurring_invoice_items
WHERE item_recurring_invoice_id = $recurring_invoice_id AND item_seat_source IS NOT NULL AND item_seat_source <> '' ORDER BY item_order, item_id");
while ($row = mysqli_fetch_assoc($sql)) {
$filter = recurringSeatFilter($row['item_seat_source'], $row['item_seat_filter']);
$count = recurringSeatCount($client_id, $row['item_seat_source'], $filter);
$row['seat_filter'] = $filter;
$row['seat_count'] = $count;
$row['seat_quantity'] = $count === null ? null : max(floatval($count), floatval($row['item_seat_minimum']));
$row['seat_label'] = recurringSeatDescribe($row['item_seat_source'], $filter);
$row['seat_drift'] = $count !== null && round($row['seat_quantity'] - floatval($row['item_quantity']), 2) != 0;
$lines[intval($row['item_id'])] = $row;
}
return $lines;
}
/**
* Bring a recurring invoice's lines up to date: each seat line's quantity
* from what the client has now, and each line's tax from the tax's rate now.
* Seat changes go in its history. What changed, in words.
*/
function recurringInvoiceRefresh($recurring_invoice_id, $by = 'Cron') {
global $mysqli;
$recurring_invoice_id = intval($recurring_invoice_id);
$client_id = intval(getFieldById('recurring_invoices', $recurring_invoice_id, 'recurring_invoice_client_id'));
$seats = recurringSeatLines($recurring_invoice_id, $client_id);
$changes = [];
$sql = mysqli_query($mysqli, "SELECT item_id, item_name, item_quantity, item_price, item_subtotal FROM recurring_invoice_items WHERE item_recurring_invoice_id = $recurring_invoice_id");
while ($item = mysqli_fetch_assoc($sql)) {
$item_id = intval($item['item_id']);
$quantity = floatval($item['item_quantity']);
if (isset($seats[$item_id]) && $seats[$item_id]['seat_drift']) {
$new_quantity = floatval($seats[$item_id]['seat_quantity']);
$changes[] = "{$item['item_name']}: " . billingQuantity($quantity) . ' to ' . billingQuantity($new_quantity) . ' (' . $seats[$item_id]['seat_label'] . ')';
$quantity = $new_quantity;
}
$subtotal = round($quantity * floatval($item['item_price']), 2);
if ($quantity != floatval($item['item_quantity']) || $subtotal != floatval($item['item_subtotal'])) {
mysqli_query($mysqli, "UPDATE recurring_invoice_items SET item_quantity = $quantity, item_subtotal = $subtotal WHERE item_id = $item_id");
}
}
// Each line's tax at its rate now, after any discount (billingRecalcTaxes())
billingRecalc('recurring', $recurring_invoice_id);
foreach ($changes as $change) {
billingLog('recurring', $recurring_invoice_id, 'Seats', "$change - by $by");
}
return $changes;
}
/* -------------------------------------------- Making invoices from recurring */
/**
* Make the invoice for a recurring invoice's next run - the nightly run and
* Invoice Now both come here.
*
* Seat lines are counted again and taxes taken at today's rates first. The
* invoice is dated the run's date ($early: today, for Invoice Now), due on
* the net terms of the contract in force (else the default), with the
* period put into its scope, notes and lines.
* The schedule moves one period on from the run it billed - so billing early
* takes the place of that run, and a missed run billed now is not billed
* again. The run is claimed first, so two requests can't bill it twice.
*
* $run is the run date the caller means to bill - what the nightly run
* picked, or what the page showed when Invoice Now was pressed. When the
* schedule has moved on since (another request billed it), nothing is made,
* rather than billing the run after. Without $early a run still to come is
* never billed.
*
* When the recurring invoice sends by itself and recurring invoices are
* emailed (Admin > Settings > Invoice), it goes to the client's primary and
* billing contacts and is marked sent; otherwise it is left as a draft.
* The new invoice's id, or 0.
*/
function recurringInvoiceGenerate($recurring_invoice_id, $by = 'Cron', $early = false, $run = null) {
global $mysqli, $config_base_url;
$recurring_invoice_id = intval($recurring_invoice_id);
$row = billingRow('recurring', $recurring_invoice_id, false);
if (!$row) {
return 0;
}
$due = (string) $row['recurring_invoice_next_date'];
if (($run !== null && (string) $run !== $due) || (!$early && $due > date('Y-m-d'))) {
return 0;
}
$next = recurringInvoiceAfter($row, $due);
$date = $early ? date('Y-m-d') : $due;
// Claim the run
mysqli_query($mysqli, "UPDATE recurring_invoices SET recurring_invoice_next_date = '$next', recurring_invoice_last_sent = '$date'
WHERE recurring_invoice_id = $recurring_invoice_id AND recurring_invoice_next_date = '" . escapeSql($due) . "'");
if (mysqli_affected_rows($mysqli) !== 1) {
return 0;
}
$seat_changes = recurringInvoiceRefresh($recurring_invoice_id, $by);
$row = billingRow('recurring', $recurring_invoice_id, false);
$period = recurringInvoicePeriod($row, $due);
$settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_invoice_prefix, config_recurring_auto_send_invoice,
config_invoice_from_email, config_invoice_from_name, config_smtp_provider FROM settings WHERE company_id = 1")) ?: [];
$client_id = intval($row['client_id']);
// 0 is "On Receipt" - the contract in force's net terms, else the client's
$terms = function_exists('contractNetTerms') ? contractNetTerms($client_id, $date) : billingDefaults()['net_terms'];
$prefix = escapeSql($settings['config_invoice_prefix'] ?? '');
$number = billingNextNumber('invoice');
$url_key = randomString(32);
$scope = escapeSql(recurringInvoiceTokens($row['recurring_invoice_scope'], $period));
$note = escapeSql(recurringInvoiceTokens($row['recurring_invoice_note'], $period));
$discount = floatval($row['recurring_invoice_discount_amount']);
$currency = escapeSql($row['recurring_invoice_currency_code']);
$category_id = intval($row['recurring_invoice_category_id']);
mysqli_query($mysqli, "INSERT INTO invoices SET invoice_prefix = '$prefix', invoice_number = $number, invoice_scope = '$scope', invoice_date = '$date',
invoice_due = DATE_ADD('$date', INTERVAL $terms DAY), invoice_discount_amount = $discount, invoice_amount = 0, invoice_currency_code = '$currency',
invoice_note = '$note', invoice_category_id = $category_id, invoice_status = 'Draft', invoice_url_key = '$url_key',
invoice_recurring_invoice_id = $recurring_invoice_id, invoice_client_id = $client_id");
$invoice_id = mysqli_insert_id($mysqli);
if (!$invoice_id) {
return 0;
}
foreach (billingItems('recurring', $recurring_invoice_id) as $item) {
$name = escapeSql(recurringInvoiceTokens($item['item_name'], $period));
$description = escapeSql(recurringInvoiceTokens($item['item_description'], $period));
// Linked to its product, so it counts as sold - recurring invoices never move stock
mysqli_query($mysqli, "INSERT INTO invoice_items SET item_name = '$name', item_description = '$description', item_quantity = " . floatval($item['item_quantity']) . ",
item_price = " . floatval($item['item_price']) . ", item_subtotal = " . floatval($item['item_subtotal']) . ", item_tax = " . floatval($item['item_tax']) . ",
item_total = " . floatval($item['item_total']) . ", item_order = " . intval($item['item_order']) . ", item_tax_id = " . intval($item['item_tax_id']) . ",
item_product_id = " . intval($item['item_product_id']) . ", item_invoice_id = $invoice_id");
}
billingRecalc('invoice', $invoice_id);
$invoice = billingRow('invoice', $invoice_id, false);
$invoice_number = billingNumber('invoice', $invoice);
$recurring_number = billingNumber('recurring', $row);
billingLog('invoice', $invoice_id, 'Draft', "Made from $recurring_number by $by, for {$period['{period}']}" . ($seat_changes ? ' - seats updated' : ''));
billingLog('recurring', $recurring_invoice_id, 'Invoiced', "$invoice_number made by $by, for {$period['{period}']} - next on $next");
// Send it, when it sends itself - to the primary and billing contacts, on the billing email (functions/billing_email.php)
$sent_to = [];
if (intval($row['recurring_invoice_auto_send']) === 1 && intval($row['recurring_invoice_email_notify']) === 1
&& intval($settings['config_recurring_auto_send_invoice'] ?? 0) === 1 && !empty($settings['config_smtp_provider'])) {
$recipients = billingEmailRecipients($client_id);
if ($recipients) {
[$subject, $email] = billingInvoiceEmail($invoice);
$attach = [];
if (billingReminderRules()['attach_pdf'] && ($file = billingEmailPdf(billingPdf('invoice', $invoice)))) {
$attach[] = $file;
}
$sent_to = billingEmailQueue($recipients, $subject, $email, ['attach' => $attach]);
}
if ($sent_to) {
mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Sent' WHERE invoice_id = $invoice_id");
billingLog('invoice', $invoice_id, 'Sent', 'Emailed to ' . implode(', ', $sent_to) . ($attach ? ' with the PDF' : ''));
} else {
billingLog('invoice', $invoice_id, 'Draft', 'Nobody to email it to - no primary or billing contact with an email address');
}
}
$client_name = escapeSql($row['client_name']);
appNotify($sent_to ? 'Recurring Sent' : 'Recurring Generated', escapeSql("$invoice_number for {$row['client_name']} made from $recurring_number" . ($sent_to ? ' and emailed' : ' - a draft to check and send')),
"/agent/invoice.php?invoice_id=$invoice_id", $client_id);
logAudit('Invoice', 'Create', escapeSql("$by made invoice $invoice_number from recurring invoice $recurring_number for {$period['{period}']}"), $client_id, $invoice_id);
triggerCustomAction('invoice_create', $invoice_id);
return $invoice_id;
}
/** The invoices a recurring invoice has made, newest first, with what has been paid on each. */
function recurringInvoiceGenerated($recurring_invoice_id, $limit = 12) {
global $mysqli;
$recurring_invoice_id = intval($recurring_invoice_id);
$limit = max(1, intval($limit));
$rows = [];
$sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_date, invoice_due, invoice_amount, invoice_status, invoice_currency_code,
(SELECT COALESCE(SUM(payment_amount), 0) FROM payments WHERE payment_invoice_id = invoice_id) AS paid
FROM invoices WHERE invoice_recurring_invoice_id = $recurring_invoice_id
ORDER BY invoice_date DESC, invoice_id DESC LIMIT $limit");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/* ---------------------------------------------------------------- Showing */
/** A document's history as a timeline, newest first. */
function billingHistoryHtml(array $rows) {
if (!$rows) {
return '<div class="itflow-asset-empty py-4">Nothing has happened to it yet.</div>';
}
$html = '<ul class="list-unstyled mb-0 p-3 itflow-billing-history">';
foreach ($rows as $row) {
// Older guest views put the browser's HTML into the description
$description = trim(preg_replace('/[ \t]+/', ' ', strip_tags((string) $row['history_description'])));
$html .= '<li><div>' . nl2br(escapeHtml($description)) . '</div>'
. '<div class="small text-body-secondary">' . escapeHtml($row['history_status'])
. ' &middot; <span title="' . escapeHtml($row['history_created_at']) . '">' . escapeHtml(timeAgo($row['history_created_at'])) . '</span></div></li>';
}
return $html . '</ul>';
}
/** The square tile for a kind of billing document. */
function billingTile($type, $size = '') {
$t = billingType($type);
return '<span class="itflow-asset-icon itflow-billing-tile itflow-billing-tile-' . $type . ($size ? " itflow-asset-icon-$size" : '') . '" aria-hidden="true"><i class="fas fa-fw ' . $t['icon'] . '"></i></span>';
}
/** The "not found" page for a billing document that isn't there or can't be opened. */
function billingNotFound($type, $client_url = '') {
$t = billingType($type);
echo '<div class="itflow-asset-empty mt-5"><i class="fas ' . $t['icon'] . ' fa-3x mb-3 opacity-50"></i>'
. '<h1 class="h4">That ' . strtolower($t['label']) . " isn't here</h1>"
. '<p>It may have been deleted, or it belongs to a client you can\'t open.</p>'
. '<a class="btn btn-outline-secondary" href="' . $t['list'] . ($client_url ? '?' . rtrim($client_url, '&') : '') . '"><i class="fas fa-arrow-left me-2"></i>Back to ' . $t['plural'] . '</a></div>';
}
/* ---------------------------------------------------------------- Payments */
/**
* The accounts a payment can go to, with each one's balance (accountsList(),
* the one balance rule). $keep_id keeps an archived account in the list (the
* one a payment is in).
*/
function paymentAccounts($keep_id = 0) {
return accountsList(false, $keep_id);
}
/* The ways a payment can be made - paymentMethods() is in functions/payment_method.php */
/** Can clients pay invoices online - is a payment provider turned on? */
function billingOnlinePayments() {
global $mysqli;
static $online = null;
if ($online === null) {
$online = (bool) mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM payment_providers WHERE payment_provider_active = 1 LIMIT 1"));
}
return $online;
}
/* -------------------------------------------------------------------- PDFs */
/**
* An invoice, quote or recurring invoice as a PDF - or an invoice's packing
* slip ($variant 'packing'): ['name' => file name, 'data' => the PDF].
* $row is billingRow(). See functions/billing_pdf.php.
*/
function billingPdf($type, array $row, $variant = 'document') {
require_once __DIR__ . '/../libs/TCPDF/tcpdf.php';
require_once __DIR__ . '/billing_pdf.php';
return billingPdfRender($type, $row, $variant);
}
/** Hand a billingPdf() to the browser - opened in a tab, or saved with $download. */
function billingPdfSend(array $pdf, $download = false) {
header('Content-Type: application/pdf');
header('Content-Disposition: ' . ($download ? 'attachment' : 'inline') . '; filename="' . $pdf['name'] . '"');
header('Content-Length: ' . strlen($pdf['data']));
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
echo $pdf['data'];
exit;
}
/* ------------------------------------------------------------- Guest pages */
/**
* A client has opened an invoice or quote - from its link, or in the client
* portal ($who, the contact): a sent one becomes viewed, the view goes in
* its history (where from, in plain text), and the agents hear about it
* while it still matters - an invoice not yet paid, a quote not yet
* answered.
*/
function billingGuestViewed($type, array $row, $who = '') {
global $mysqli;
$t = billingType($type);
$c = $t['col'];
$id = intval($row["{$c}_id"]);
$status = (string) $row["{$c}_status"];
$number = billingNumber($type, $row);
$who = trim((string) $who);
if ($status === 'Sent') {
mysqli_query($mysqli, "UPDATE {$t['table']} SET {$c}_status = 'Viewed' WHERE {$c}_id = $id AND {$c}_status = 'Sent'");
}
if ($who !== '') {
$where = '';
$description = "Viewed in the client portal by $who";
$by = " ($who, in the client portal)";
} else {
$agent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$where = implode(' - ', array_filter([getIP(), trim(strip_tags(getOS($agent))), trim(strip_tags(getWebBrowser($agent)))], function ($part) {
return $part !== '' && $part !== '-';
}));
$description = 'Viewed by the client' . ($where !== '' ? " - $where" : '');
$by = $where !== '' ? " - $where" : '';
}
billingLog($type, $id, $status === 'Sent' ? 'Viewed' : $status, $description);
$matters = $type === 'invoice' ? !in_array($status, ['Paid', 'Cancelled', 'Non-Billable'], true) : in_array($status, ['Draft', 'Sent', 'Viewed'], true);
if ($matters) {
appNotify($t['label'] . ' Viewed', escapeSql("$number has been viewed by {$row['client_name']}$by"), "/agent/{$t['page']}?{$t['param']}=$id", intval($row['client_id']));
}
}
/** A client's invoices still owed, oldest first, with what is left on each - their statement. */
function billingClientOpenInvoices($client_id) {
global $mysqli;
$client_id = intval($client_id);
$rows = [];
$sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_date, invoice_due, invoice_status,
invoice_amount, invoice_currency_code, invoice_url_key,
COALESCE((SELECT SUM(payment_amount) FROM payments WHERE payment_invoice_id = invoice_id), 0) AS paid
FROM invoices
WHERE invoice_client_id = $client_id AND invoice_status IN ('Sent', 'Viewed', 'Partial')
ORDER BY invoice_date ASC, invoice_number ASC");
while ($row = mysqli_fetch_assoc($sql)) {
if (round(floatval($row['invoice_amount']) - floatval($row['paid']), 2) > 0) {
$rows[] = $row;
}
}
return $rows;
}