[ '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_net_terms, 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 = '' . escapeHtml($status) . ''; if ($overdue) { $html .= ' Overdue'; } 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 '' . $late . ' ' . ($late === 1 ? 'day' : 'days') . ' overdue'; } if ($days === 0) { return 'Due today'; } $class = $days <= 7 ? 'bg-warning-subtle text-warning-emphasis' : 'bg-secondary-subtle text-secondary-emphasis'; return 'Due in ' . $days . ' ' . ($days === 1 ? 'day' : 'days') . ''; } /** 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); } /** The hourly rate a client's time is billed at - its own, or the default. */ function billingTimeRate($client_rate) { global $config_default_hourly_rate; return floatval($client_rate) > 0 ? floatval($client_rate) : floatval($config_default_hourly_rate); } /** * The SQL that makes a ticket unbilled: billable, resolved or closed, not * archived, on no invoice. $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 . ")"); } /** * 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, the rate and what that comes to, and the days * since it was done. */ 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, client_rate, 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, 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['hours'] = billingTimeHours($row['seconds'], $rules); $row['rate'] = billingTimeRate($row['client_rate']); $row['default_rate'] = floatval($row['client_rate']) <= 0; $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, value, * 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'], 'tickets' => [], 'seconds' => 0, 'hours' => 0.0, 'value' => 0.0, 'oldest' => 0, 'no_time' => 0, 'drafts' => []]; } $clients[$id]['tickets'][intval($ticket['ticket_id'])] = $ticket; $clients[$id]['seconds'] += $ticket['seconds']; $clients[$id]['hours'] += $ticket['hours']; $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'); $terms = intval(getFieldById('clients', $client_id, 'client_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: the client's rate), description (one ticket only) * combine one line for all of them instead of one each * 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); } $price = isset($opts['price']) && $opts['price'] !== '' && $opts['price'] !== null ? billingNumberInput($opts['price']) : billingTimeRate(reset($tickets)['client_rate']); if ($price === null || 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'])) { $combined[] = 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, 'tax_id' => $tax_id])) { $lines++; } } logTicketHistory($ticket_id, escapeSql("Billed on invoice $number by $by - " . ($h > 0 ? billingQuantity($h) . ' h' : 'no charge'))); } if ($combined) { $combined_hours = round(array_sum(array_intersect_key($bill, $claimed)), 2); if (billingItemSave('invoice', $invoice_id, ['name' => $item_name, 'description' => implode("\n", $combined), 'qty' => $combined_hours, 'price' => $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 = '' . escapeHtml($row['quote_status']) . ''; if (quoteExpired($row)) { $html .= ' Expired'; } 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 'Expired ' . (-$days) . ' ' . (-$days === 1 ? 'day' : 'days') . ' ago'; } if ($days === 0) { return 'Expires today'; } $class = $days <= 7 ? 'bg-warning-subtle text-warning-emphasis' : 'bg-secondary-subtle text-secondary-emphasis'; return 'Expires in ' . $days . ' ' . ($days === 1 ? 'day' : 'days') . ''; } /* * 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 = intval($quote['client_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 'Paused'; } $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 '' . (-$days) . ' ' . (-$days === 1 ? 'day' : 'days') . ' late'; } if ($days === 0) { return 'Today'; } return 'In ' . $days . ' ' . ($days === 1 ? 'day' : 'days') . ''; } /** * 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 client's terms, 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")) ?: []; $terms = intval($row['client_net_terms']); // 0 is "On Receipt" $client_id = intval($row['client_id']); $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 '
It may have been deleted, or it belongs to a client you can\'t open.
' . 'Back to ' . $t['plural'] . '