Taxes). * * A rate is a name and a percent, or a rate made of parts - a state's and a * county's, GST and PST - each charged on the line, or on the line and the * parts before it (compound). A line's tax is its parts, each to the cent; * taxes.tax_percent is the whole rate they come to, for the windows that show * a line's total as it is typed. * * A rate on billed lines is never changed in place: changing its percent or * parts makes a new rate (taxChangeRate()) and archives the old one, so every * invoice keeps the rate it was billed at - in its totals, on its PDF and in * the tax report. Products, clients, the default, recurring invoices and drafts * move on to the new one. * * Which rate a new line gets (taxDefaultFor()): none for a tax exempt client; * none for billed time, trips or expenses unless they are taxed (Admin > * Taxes); none for a product that isn't taxed; and otherwise the client's * rate, a product's own rate, or the default - the first of them still in use. */ /** The tax settings - the default rate, what is taxed, discounts before tax. Read once a request. */ function taxSettings($forget = false) { global $mysqli; static $settings = null; if ($forget) { $settings = null; return []; } if ($settings === null) { $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_tax_default_id, config_tax_time, config_tax_trips, config_tax_expenses, config_tax_discount_before FROM settings WHERE company_id = 1")) ?: []; $settings = [ 'default_id' => intval($row['config_tax_default_id'] ?? 0), 'time' => intval($row['config_tax_time'] ?? 0) === 1, 'trip' => intval($row['config_tax_trips'] ?? 0) === 1, 'expense' => intval($row['config_tax_expenses'] ?? 0) === 1, 'discount_before' => intval($row['config_tax_discount_before'] ?? 1) === 1, ]; } return $settings; } /** A percent as people write it - 6, 7.25, 9.975. */ function taxPercentText($percent) { $text = rtrim(rtrim(number_format(floatval($percent), 4, '.', ''), '0'), '.'); return $text === '' || $text === '-0' ? '0' : $text; } /** * One rate with its parts, or null - cached for the request (taxForget() after * a change). A rate without parts of its own has one: itself. */ function taxRow($tax_id, $forget = false) { global $mysqli; static $cache = []; if ($forget) { $cache = []; return null; } $tax_id = intval($tax_id); if ($tax_id <= 0) { return null; } if (!array_key_exists($tax_id, $cache)) { $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tax_id, tax_name, tax_percent, tax_description, tax_registration_number, tax_replaced_by, tax_created_at, tax_archived_at FROM taxes WHERE tax_id = $tax_id LIMIT 1")); if ($row) { $row['components'] = []; $sql = mysqli_query($mysqli, "SELECT component_name, component_percent, component_compound, component_registration_number FROM tax_components WHERE component_tax_id = $tax_id ORDER BY component_order ASC, component_id ASC"); while ($part = mysqli_fetch_assoc($sql)) { $row['components'][] = ['name' => (string) $part['component_name'], 'percent' => floatval($part['component_percent']), 'compound' => intval($part['component_compound']) === 1, 'registration' => trim((string) $part['component_registration_number'])]; } $row['grouped'] = count($row['components']) > 0; if (!$row['components']) { $row['components'][] = ['name' => (string) $row['tax_name'], 'percent' => floatval($row['tax_percent']), 'compound' => false, 'registration' => '']; } } $cache[$tax_id] = $row ?: null; } return $cache[$tax_id]; } /** Forget the cached rates - after one is saved. */ function taxForget() { taxRow(0, true); } /** The whole rate parts come to - compound parts are charged on the ones before them too. */ function taxEffectivePercent(array $components) { $total = 0.0; foreach ($components as $part) { $percent = floatval($part['percent']); $total += !empty($part['compound']) ? $percent * (1 + $total / 100) : $percent; } return round($total, 4); } /** A rate as it is offered and printed - "PA Sales Tax 6%". */ function taxLabel($row) { if (!$row) { return ''; } return trim($row['tax_name'] . ' ' . taxPercentText($row['tax_percent']) . '%'); } /** * The tax on an amount at a rate - ['tax' => the whole, 'parts' => [[label, * name, percent, amount, registration, registration_for], ...]], each part * to the cent. * $tax: a rate's id, or its taxRow(). */ function taxAmounts($base, $tax) { $row = is_array($tax) ? $tax : taxRow($tax); $base = round(floatval($base), 2); if (!$row) { return ['tax' => 0.0, 'parts' => []]; } $total = 0.0; $parts = []; foreach ($row['components'] as $part) { $on = !empty($part['compound']) ? $base + $total : $base; $amount = round($on * floatval($part['percent']) / 100, 2); $total = round($total + $amount, 2); $parts[] = [ 'label' => trim($part['name'] . ' ' . taxPercentText($part['percent']) . '%'), 'name' => $part['name'], 'percent' => floatval($part['percent']), 'amount' => $amount, // A part's own registration number, or the rate's - and what it is for 'registration' => ($part['registration'] ?? '') !== '' ? $part['registration'] : trim((string) ($row['tax_registration_number'] ?? '')), 'registration_for' => ($part['registration'] ?? '') !== '' ? $part['name'] : (string) $row['tax_name'], ]; } return ['tax' => $total, 'parts' => $parts]; } /** * The tax included in an amount at a rate - what of $gross is tax, to the * cent, the rest being what the tax was on. $tax: a rate's id, or its taxRow(). */ function taxIncluded($gross, $tax) { $row = is_array($tax) ? $tax : taxRow($tax); $gross = round(floatval($gross), 2); if (!$row || floatval($row['tax_percent']) == 0) { return 0.0; } return round($gross - round($gross / (1 + floatval($row['tax_percent']) / 100), 2), 2); } /** * A tax amount shared over its rate's parts, as they come to on $base - * ['parts' => label => amount, adding up to $amount, 'registrations' => what * each number is for => number, 'numbers' => label => its number]. With * no rate ($row null), it is all one part, $no_rate_label. */ function taxSplit($amount, $row, $base, $no_rate_label = 'Tax') { $amount = round(floatval($amount), 2); if (!$row) { return ['parts' => [$no_rate_label => $amount], 'registrations' => [], 'numbers' => []]; } $split = taxAmounts($base, $row)['parts']; $whole = array_sum(array_column($split, 'amount')); $left = $amount; $parts = []; $registrations = []; $numbers = []; foreach ($split as $i => $part) { $share = $i === count($split) - 1 || $whole == 0 ? $left : round($amount * $part['amount'] / $whole, 2); $left = round($left - $share, 2); $parts[$part['label']] = round(($parts[$part['label']] ?? 0) + $share, 2); if ($part['registration'] !== '') { $registrations[$part['registration_for']] = $part['registration']; $numbers[$part['label']] = $part['registration']; } } return ['parts' => $parts, 'registrations' => $registrations, 'numbers' => $numbers]; } /** The rate a rate was changed to, following each change to the one in use now - or 0 when it was archived without one. */ function taxCurrent($tax_id) { $seen = []; $row = taxRow($tax_id); while ($row && $row['tax_archived_at'] && intval($row['tax_replaced_by']) && !isset($seen[intval($row['tax_id'])])) { $seen[intval($row['tax_id'])] = true; $row = taxRow($row['tax_replaced_by']); } return $row && !$row['tax_archived_at'] ? intval($row['tax_id']) : 0; } /** A client's tax standing - its own rate, and whether it is exempt (and on what certificate, until when). */ function taxClient($client_id) { global $mysqli; static $cache = []; $client_id = intval($client_id); if (!array_key_exists($client_id, $cache)) { $row = $client_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_default_tax_id, client_tax_exempt, client_tax_exempt_number, client_tax_exempt_expires, client_tax_id_number FROM clients WHERE client_id = $client_id LIMIT 1")) : null; $expires = (string) ($row['client_tax_exempt_expires'] ?? ''); $cache[$client_id] = [ 'default_id' => intval($row['client_default_tax_id'] ?? 0), 'exempt' => intval($row['client_tax_exempt'] ?? 0) === 1, 'number' => trim((string) ($row['client_tax_exempt_number'] ?? '')), 'expires' => $expires, 'expired' => $expires !== '' && $expires < date('Y-m-d'), 'tax_id_number' => trim((string) ($row['client_tax_id_number'] ?? '')), ]; } return $cache[$client_id]; } /** * A client's tax fields, for its SET list - its own rate, and whether it is * exempt, on what certificate and until when. $in: default_tax_id, * tax_exempt, tax_exempt_number, tax_exempt_expires (raw); any left out keep * $current, the client's row ([] for a new client). A rate has to be one * still offered, or the one the client already has. */ function taxClientSetSql(array $in, array $current = []) { $current_default = intval($current['client_default_tax_id'] ?? 0); $default_id = array_key_exists('default_tax_id', $in) ? intval($in['default_tax_id']) : $current_default; if ($default_id && $default_id !== $current_default && (!($row = taxRow($default_id)) || $row['tax_archived_at'])) { $default_id = $current_default; } if ($default_id && !taxRow($default_id)) { $default_id = 0; } $exempt = array_key_exists('tax_exempt', $in) ? (!empty($in['tax_exempt']) ? 1 : 0) : intval($current['client_tax_exempt'] ?? 0); $number = trim(mb_substr((string) (array_key_exists('tax_exempt_number', $in) ? $in['tax_exempt_number'] : ($current['client_tax_exempt_number'] ?? '')), 0, 200)); $expires = trim((string) (array_key_exists('tax_exempt_expires', $in) ? $in['tax_exempt_expires'] : ($current['client_tax_exempt_expires'] ?? ''))); $expires = $expires !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $expires) && strtotime($expires) ? $expires : ''; return "client_default_tax_id = $default_id, client_tax_exempt = $exempt, client_tax_exempt_number = " . ($number !== '' ? "'" . escapeSql($number) . "'" : 'NULL') . ", client_tax_exempt_expires = " . ($expires !== '' ? "'$expires'" : 'NULL'); } /** "Tax exempt - certificate 12-345, expires Mar 1, 2027" for an exempt client - '' otherwise. Without $with_expiry, for what the client sees. */ function taxExemptText($client, $with_expiry = true) { $client = is_array($client) ? $client : taxClient($client); if (empty($client['exempt'])) { return ''; } $text = 'Tax exempt'; if ($client['number'] !== '') { $text .= ' - certificate ' . $client['number']; } if ($with_expiry && $client['expires'] !== '') { $text .= ($client['number'] !== '' ? ', ' : ' - ') . ($client['expired'] ? 'expired ' : 'expires ') . date('M j, Y', strtotime($client['expires'])); } return $text; } /** * The warning over an invoice, quote or recurring invoice in the agent's view * when its client is tax exempt and its lines can still change ($open) - * lines still taxed (with a link to take the tax off when $can_edit), or an * exemption certificate that has run out. '' otherwise. */ function taxDocWarning($type, $doc_id, $client_id, array $items, $open, $can_edit = false) { $client = taxClient($client_id); if (!$client['exempt']) { return ''; } $notes = []; $action = ''; $taxed = 0; foreach ($items as $item) { $taxed += floatval($item['item_tax']) != 0 ? 1 : 0; } if ($open && $taxed) { $notes[] = 'The client is tax exempt, but ' . ($taxed === 1 ? '1 line here is' : "$taxed lines here are") . ' taxed.'; if ($can_edit) { $action = 'Take the tax off'; } } if ($open && $client['expired']) { $notes[] = 'Its exemption certificate' . ($client['number'] !== '' ? ' (' . escapeHtml($client['number']) . ')' : '') . ' expired on ' . date('M j, Y', strtotime($client['expires'])) . ' - get a new one, or turn exemption off on the client\'s Billing tab.'; } if (!$notes) { return ''; } return '
' . implode(' ', $notes) . '
' . $action . '
'; } /** * The rate a new line for a client gets. $kind: 'goods' (typed in), 'time' * (billed hours), 'trip', 'expense', or 'product' with the product's own rate * ($product_tax_id, 0 when the product isn't taxed). The rate in use now - a * changed rate's replacement - or 0 for no tax. */ function taxDefaultFor($client_id, $kind = 'goods', $product_tax_id = 0) { $settings = taxSettings(); $client = taxClient($client_id); if ($client['exempt']) { return 0; } if (in_array($kind, ['time', 'trip', 'expense'], true) && !$settings[$kind]) { return 0; } if ($kind === 'product' && !intval($product_tax_id)) { return 0; } // A rate archived without a replacement no longer counts - the next one along does $tax_id = taxCurrent($client['default_id']); if (!$tax_id && $kind === 'product') { $tax_id = taxCurrent($product_tax_id); } return $tax_id ?: taxCurrent($settings['default_id']); } /** TAX_USUAL in a tax select: each line's usual rate for its client and kind (taxDefaultFor()) - where one choice covers time, expenses and trips, or many clients. */ const TAX_USUAL = -1; /** A line's rate from a form's choice - TAX_USUAL is the usual rate for this client and kind. */ function taxChoice($input, $client_id, $kind) { $tax_id = intval($input); return $tax_id === TAX_USUAL ? taxDefaultFor($client_id, $kind) : max(0, $tax_id); } /** Where a rate is used - how many invoices, quotes and recurring invoices have a line at it, its products and clients, and whether it is the default. */ function taxUsage($tax_id) { global $mysqli; $tax_id = intval($tax_id); $count = function ($sql) use ($mysqli) { return intval(mysqli_fetch_row(mysqli_query($mysqli, $sql))[0] ?? 0); }; return [ 'invoices' => $count("SELECT COUNT(DISTINCT item_invoice_id) FROM invoice_items WHERE item_tax_id = $tax_id"), 'quotes' => $count("SELECT COUNT(DISTINCT item_quote_id) FROM quote_items WHERE item_tax_id = $tax_id"), 'recurring' => $count("SELECT COUNT(DISTINCT item_recurring_invoice_id) FROM recurring_invoice_items WHERE item_tax_id = $tax_id"), 'products' => $count("SELECT COUNT(*) FROM products WHERE product_tax_id = $tax_id AND product_archived_at IS NULL"), 'clients' => $count("SELECT COUNT(*) FROM clients WHERE client_default_tax_id = $tax_id AND client_archived_at IS NULL"), 'default' => taxSettings()['default_id'] === $tax_id, ]; } /** Whether a rate has been billed - on an invoice or quote past draft, so its rate is part of what the client was sent. */ function taxBilled($tax_id) { global $mysqli; $tax_id = intval($tax_id); return mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM invoice_items JOIN invoices ON invoice_id = item_invoice_id WHERE item_tax_id = $tax_id AND invoice_status != 'Draft' LIMIT 1")) > 0 || mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM quote_items JOIN quotes ON quote_id = item_quote_id WHERE item_tax_id = $tax_id AND quote_status != 'Draft' LIMIT 1")) > 0; } /** * A rate from the form - [$fields, $error]. $in: name, description, * registration, and parts (part_name[key], part_percent[key], * part_compound[key], part_registration[key], in order) or percent for a * single rate. Raw values; * taxCreate() and taxUpdate() escape them. */ function taxFromInput(array $in) { $name = trim(mb_substr((string) ($in['name'] ?? ''), 0, 200)); $description = trim(mb_substr((string) ($in['description'] ?? ''), 0, 255)); $registration = trim(mb_substr((string) ($in['registration'] ?? ''), 0, 100)); $components = []; $names = (array) ($in['part_name'] ?? []); $percents = (array) ($in['part_percent'] ?? []); $compounds = (array) ($in['part_compound'] ?? []); $registrations = (array) ($in['part_registration'] ?? []); foreach ($names as $i => $part_name) { $part_name = trim(mb_substr((string) $part_name, 0, 200)); $percent = trim(str_replace(['%', ' '], '', (string) ($percents[$i] ?? ''))); if ($part_name === '' && $percent === '') { continue; } if ($part_name === '' || !is_numeric($percent)) { return [null, 'Each part needs a name and a percent.']; } $components[] = ['name' => $part_name, 'percent' => round(floatval($percent), 4), 'compound' => !empty($compounds[$i]) && count($components) > 0, 'registration' => trim(mb_substr((string) ($registrations[$i] ?? ''), 0, 100))]; } if ($name === '') { return [null, 'A tax needs a name.']; } if (count($components) === 1) { // One part is just the rate - with its registration number, when the rate has none $percent = $components[0]['percent']; $registration = $registration !== '' ? $registration : $components[0]['registration']; $components = []; } elseif (!$components) { $percent = trim(str_replace(['%', ' '], '', (string) ($in['percent'] ?? ''))); if (!is_numeric($percent)) { return [null, 'A tax needs a percent.']; } $percent = round(floatval($percent), 4); } else { $percent = taxEffectivePercent($components); } foreach (array_merge([['percent' => $percent]], $components) as $part) { if ($part['percent'] < 0 || $part['percent'] > 100) { return [null, 'A percent is between 0 and 100.']; } } return [['name' => $name, 'description' => $description, 'registration' => $registration, 'percent' => $percent, 'components' => $components], '']; } /** Whether two rates charge the same - the same percent, and the same parts. */ function taxSameRate(array $row, array $fields) { $now = $row['grouped'] ? $row['components'] : []; if (abs(floatval($row['tax_percent']) - floatval($fields['percent'])) > 0.00001 || count($now) !== count($fields['components'])) { return false; } foreach ($fields['components'] as $i => $part) { if ($now[$i]['name'] !== $part['name'] || abs($now[$i]['percent'] - $part['percent']) > 0.00001 || (bool) $now[$i]['compound'] !== (bool) $part['compound']) { return false; } } return true; } /** Write a rate's parts - none for a single rate. */ function taxWriteComponents($tax_id, array $components) { global $mysqli; $tax_id = intval($tax_id); mysqli_query($mysqli, "DELETE FROM tax_components WHERE component_tax_id = $tax_id"); foreach (array_values($components) as $order => $part) { $registration = trim((string) ($part['registration'] ?? '')); mysqli_query($mysqli, "INSERT INTO tax_components SET component_tax_id = $tax_id, component_name = '" . escapeSql($part['name']) . "', component_percent = " . round(floatval($part['percent']), 4) . ", component_compound = " . (!empty($part['compound']) ? 1 : 0) . ", component_registration_number = " . ($registration !== '' ? "'" . escapeSql($registration) . "'" : 'NULL') . ", component_order = " . ($order + 1)); } } /** Add a rate. Its id. */ function taxCreate(array $fields) { global $mysqli; mysqli_query($mysqli, "INSERT INTO taxes SET tax_name = '" . escapeSql($fields['name']) . "', tax_percent = " . round(floatval($fields['percent']), 4) . ", tax_description = " . ($fields['description'] !== '' ? "'" . escapeSql($fields['description']) . "'" : 'NULL') . ", tax_registration_number = " . ($fields['registration'] !== '' ? "'" . escapeSql($fields['registration']) . "'" : 'NULL')); $tax_id = intval(mysqli_insert_id($mysqli)); taxWriteComponents($tax_id, $fields['components']); taxForget(); return $tax_id; } /** * Save a rate. Its name, description and registration number change in * place. Its percent or parts change in place too, until it has been billed * (taxBilled()); after that a new rate takes its place (taxChangeRate()). ['id' => the rate * now, 'replaced' => bool, 'moved' => what moved to it]. */ function taxUpdate($tax_id, array $fields, $move_drafts = true) { global $mysqli; $tax_id = intval($tax_id); $row = taxRow($tax_id); if (!$row) { return null; } if (!taxSameRate($row, $fields) && taxBilled($tax_id)) { return taxChangeRate($tax_id, $fields, $move_drafts); } mysqli_query($mysqli, "UPDATE taxes SET tax_name = '" . escapeSql($fields['name']) . "', tax_percent = " . round(floatval($fields['percent']), 4) . ", tax_description = " . ($fields['description'] !== '' ? "'" . escapeSql($fields['description']) . "'" : 'NULL') . ", tax_registration_number = " . ($fields['registration'] !== '' ? "'" . escapeSql($fields['registration']) . "'" : 'NULL') . " WHERE tax_id = $tax_id"); $rate_changed = !taxSameRate($row, $fields); taxWriteComponents($tax_id, $fields['components']); taxForget(); $moved = []; if ($rate_changed) { // Not billed yet - recurring invoices and drafts that carry it are worked out again at its new rate $moved = taxRecalcOpen($tax_id, $move_drafts); } return ['id' => $tax_id, 'replaced' => false, 'moved' => $moved]; } /** * A billed rate's percent or parts changed: a new rate with the same name * takes its place from now on. The old one is archived, saying which rate * replaced it, and stays on every line already billed. Products, clients, * the default rate and recurring invoices move to the new one - and draft * invoices and quotes too, unless $move_drafts is false. */ function taxChangeRate($tax_id, array $fields, $move_drafts = true) { global $mysqli; $tax_id = intval($tax_id); $new_id = taxCreate($fields); mysqli_query($mysqli, "UPDATE taxes SET tax_archived_at = NOW(), tax_replaced_by = $new_id WHERE tax_id = $tax_id"); return ['id' => $new_id, 'replaced' => true, 'moved' => taxRepoint($tax_id, $new_id, $move_drafts)]; } /** * Move what uses a rate on to another: products, clients, the default and * recurring invoices - and draft invoices and quotes too, unless $move_drafts * is false - each worked out again at the new rate. Lines already billed keep * theirs. What moved, for taxMovedText(). */ function taxRepoint($from_id, $to_id, $move_drafts = true) { global $mysqli; $from_id = intval($from_id); $to_id = intval($to_id); if ($from_id <= 0 || $to_id <= 0 || $from_id === $to_id) { return []; } mysqli_query($mysqli, "UPDATE products SET product_tax_id = $to_id WHERE product_tax_id = $from_id"); $moved = ['products' => mysqli_affected_rows($mysqli)]; mysqli_query($mysqli, "UPDATE clients SET client_default_tax_id = $to_id WHERE client_default_tax_id = $from_id"); $moved['clients'] = mysqli_affected_rows($mysqli); mysqli_query($mysqli, "UPDATE settings SET config_tax_default_id = $to_id WHERE config_tax_default_id = $from_id"); // Only the documents that carried the old rate are worked out again $recurring = taxDocIds('recurring', $from_id); $drafts = $move_drafts ? ['invoice' => taxDocIds('invoice', $from_id), 'quote' => taxDocIds('quote', $from_id)] : ['invoice' => [], 'quote' => []]; mysqli_query($mysqli, "UPDATE recurring_invoice_items SET item_tax_id = $to_id WHERE item_tax_id = $from_id"); if ($move_drafts) { mysqli_query($mysqli, "UPDATE invoice_items JOIN invoices ON invoice_id = item_invoice_id SET item_tax_id = $to_id WHERE item_tax_id = $from_id AND invoice_status = 'Draft'"); mysqli_query($mysqli, "UPDATE quote_items JOIN quotes ON quote_id = item_quote_id SET item_tax_id = $to_id WHERE item_tax_id = $from_id AND quote_status = 'Draft'"); } taxSettings(true); taxForget(); foreach (['recurring' => $recurring] + $drafts as $type => $ids) { foreach ($ids as $doc_id) { billingRecalc($type, $doc_id); } } $moved['recurring'] = count($recurring); $moved['invoices'] = count($drafts['invoice']); $moved['quotes'] = count($drafts['quote']); return $moved; } /** The recurring invoices, or draft invoices or quotes, with a line at a rate. */ function taxDocIds($type, $tax_id) { global $mysqli; $tax_id = intval($tax_id); $queries = [ 'recurring' => "SELECT DISTINCT item_recurring_invoice_id FROM recurring_invoice_items WHERE item_tax_id = $tax_id", 'invoice' => "SELECT DISTINCT item_invoice_id FROM invoice_items JOIN invoices ON invoice_id = item_invoice_id WHERE item_tax_id = $tax_id AND invoice_status = 'Draft'", 'quote' => "SELECT DISTINCT item_quote_id FROM quote_items JOIN quotes ON quote_id = item_quote_id WHERE item_tax_id = $tax_id AND quote_status = 'Draft'", ]; $ids = []; $sql = mysqli_query($mysqli, $queries[$type]); while ($row = mysqli_fetch_row($sql)) { $ids[] = intval($row[0]); } return $ids; } /** Work out again the recurring invoices - and, with $drafts, the draft invoices and quotes - that carry a rate. How many of each. */ function taxRecalcOpen($tax_id, $drafts = true) { $moved = ['recurring' => 0, 'invoices' => 0, 'quotes' => 0]; foreach (['recurring' => 'recurring', 'invoice' => 'invoices', 'quote' => 'quotes'] as $type => $key) { if ($type !== 'recurring' && !$drafts) { continue; } foreach (taxDocIds($type, $tax_id) as $doc_id) { billingRecalc($type, $doc_id); $moved[$key]++; } } return $moved; } /** * Archive a rate - no longer offered for new lines. What uses it moves to * $move_to when one is given (taxRepoint()); otherwise products and clients on * it fall back to the next rate along (taxDefaultFor()), and recurring * invoices keep charging it. It stops being the default either way. What * moved, and whether it was the default. */ function taxArchive($tax_id, $move_to = 0, $move_drafts = true) { global $mysqli; $tax_id = intval($tax_id); $move_to = intval($move_to); $was_default = taxSettings()['default_id'] === $tax_id; $moved = []; if ($move_to && $move_to !== $tax_id && ($to = taxRow($move_to)) && !$to['tax_archived_at']) { $moved = taxRepoint($tax_id, $move_to, $move_drafts); } mysqli_query($mysqli, "UPDATE taxes SET tax_archived_at = NOW() WHERE tax_id = $tax_id AND tax_archived_at IS NULL"); mysqli_query($mysqli, "UPDATE settings SET config_tax_default_id = 0 WHERE config_tax_default_id = $tax_id"); taxSettings(true); taxForget(); return ['moved' => $moved, 'was_default' => $was_default && !$moved]; } /** Offer an archived rate again. It no longer points at the rate that replaced it. */ function taxRestore($tax_id) { global $mysqli; $tax_id = intval($tax_id); mysqli_query($mysqli, "UPDATE taxes SET tax_archived_at = NULL, tax_replaced_by = 0 WHERE tax_id = $tax_id"); taxForget(); } /** Whether nothing has ever used a rate - no line, product or client (archived ones too), not the default, and no rate replaced by it. */ function taxUnused($tax_id) { global $mysqli; $tax_id = intval($tax_id); foreach ([ "SELECT 1 FROM invoice_items WHERE item_tax_id = $tax_id LIMIT 1", "SELECT 1 FROM quote_items WHERE item_tax_id = $tax_id LIMIT 1", "SELECT 1 FROM recurring_invoice_items WHERE item_tax_id = $tax_id LIMIT 1", "SELECT 1 FROM products WHERE product_tax_id = $tax_id LIMIT 1", "SELECT 1 FROM clients WHERE client_default_tax_id = $tax_id LIMIT 1", "SELECT 1 FROM taxes WHERE tax_replaced_by = $tax_id LIMIT 1", ] as $query) { if (mysqli_num_rows(mysqli_query($mysqli, $query))) { return false; } } return taxSettings()['default_id'] !== $tax_id; } /** Delete a rate nothing has used, with its parts. Whether it went. */ function taxDelete($tax_id) { global $mysqli; $tax_id = intval($tax_id); if (!taxRow($tax_id) || !taxUnused($tax_id)) { return false; } mysqli_query($mysqli, "DELETE FROM tax_components WHERE component_tax_id = $tax_id"); mysqli_query($mysqli, "DELETE FROM taxes WHERE tax_id = $tax_id"); taxForget(); return true; } /** Make a rate the default for new lines - 0 for none. */ function taxSetDefault($tax_id) { global $mysqli; $tax_id = intval($tax_id); if ($tax_id && (!($row = taxRow($tax_id)) || $row['tax_archived_at'])) { return false; } mysqli_query($mysqli, "UPDATE settings SET config_tax_default_id = $tax_id WHERE company_id = 1"); taxSettings(true); return true; } /** Where each rate is used - taxUsage() for every rate at once, for Admin > Taxes. */ function taxUsageAll() { global $mysqli; $usage = []; $sql = mysqli_query($mysqli, "SELECT tax_id FROM taxes"); while ($row = mysqli_fetch_row($sql)) { $usage[intval($row[0])] = ['invoices' => 0, 'quotes' => 0, 'recurring' => 0, 'products' => 0, 'clients' => 0, 'default' => false]; } $queries = [ 'invoices' => "SELECT item_tax_id, COUNT(DISTINCT item_invoice_id) FROM invoice_items WHERE item_tax_id > 0 GROUP BY item_tax_id", 'quotes' => "SELECT item_tax_id, COUNT(DISTINCT item_quote_id) FROM quote_items WHERE item_tax_id > 0 GROUP BY item_tax_id", 'recurring' => "SELECT item_tax_id, COUNT(DISTINCT item_recurring_invoice_id) FROM recurring_invoice_items WHERE item_tax_id > 0 GROUP BY item_tax_id", 'products' => "SELECT product_tax_id, COUNT(*) FROM products WHERE product_tax_id > 0 AND product_archived_at IS NULL GROUP BY product_tax_id", 'clients' => "SELECT client_default_tax_id, COUNT(*) FROM clients WHERE client_default_tax_id > 0 AND client_archived_at IS NULL GROUP BY client_default_tax_id", ]; foreach ($queries as $key => $query) { $sql = mysqli_query($mysqli, $query); while ($row = mysqli_fetch_row($sql)) { if (isset($usage[intval($row[0])])) { $usage[intval($row[0])][$key] = intval($row[1]); } } } $default_id = taxSettings()['default_id']; if (isset($usage[$default_id])) { $usage[$default_id]['default'] = true; } return $usage; } /** "3 products, 2 clients and 4 recurring invoices" from what moved - '' for nothing. */ function taxMovedText(array $moved) { $words = ['products' => ['product', 'products'], 'clients' => ['client', 'clients'], 'recurring' => ['recurring invoice', 'recurring invoices'], 'invoices' => ['draft invoice', 'draft invoices'], 'quotes' => ['draft quote', 'draft quotes']]; $parts = []; foreach ($words as $key => [$one, $many]) { $n = intval($moved[$key] ?? 0); if ($n) { $parts[] = "$n " . ($n === 1 ? $one : $many); } } if (count($parts) > 1) { $last = array_pop($parts); return implode(', ', $parts) . ' and ' . $last; } return $parts[0] ?? ''; } /** * A line's tax select - No tax and the rates, with $selected chosen and each * ' : ''; $html .= ''; foreach ($taxes as $tax_id => $tax) { $html .= ''; } return $html; } /** taxOptionsHtml() with the rates to offer now - or, with $since, those in use since then too. */ function taxSelectOptions($selected = 0, $since = null) { return taxOptionsHtml(billingTaxes($since), $selected); } /* * ---------- the Tax Collected report ---------- */ /** The bases the tax on sales can be counted on - when invoiced, or when paid. */ const TAX_REPORT_BASES = ['invoiced' => 'Invoiced', 'paid' => 'Paid']; /** Twelve months of nothing, for adding to. */ function taxReportMonths() { return array_fill(1, 12, 0.0); } /** * What one invoice comes to for tax: each line net of its share of the * discount, sorted into taxable, non-taxable (no tax for a client who isn't * exempt) and exempt (no tax for a client who is exempt now), and its tax by * part. $items: the invoice's lines with item_subtotal, item_tax, item_tax_id, * tax_name and tax_percent. */ function taxReportInvoice(array $items, $discount, $exempt) { $out = ['sales' => 0.0, 'taxable' => 0.0, 'nontaxable' => 0.0, 'exempt' => 0.0, 'parts' => [], 'numbers' => []]; $shares = billingDiscountShares($items, $discount); foreach ($items as $item) { $net = floatval($item['item_subtotal']) - ($shares[intval($item['item_id'])] ?? 0); $tax = round(floatval($item['item_tax']), 2); $out['sales'] += $net; if ($tax != 0) { $out['taxable'] += $net; $label = trim(($item['tax_name'] ?? 'Tax') . ($item['tax_percent'] !== null ? ' ' . taxPercentText($item['tax_percent']) . '%' : '')); $split = taxSplit($tax, taxRow($item['item_tax_id']), floatval($item['item_subtotal']), $label); foreach ($split['parts'] as $part => $amount) { $out['parts'][$part] = ($out['parts'][$part] ?? 0) + $amount; } $out['numbers'] += $split['numbers']; } elseif ($exempt) { $out['exempt'] += $net; } else { $out['nontaxable'] += $net; } } return $out; } /** * Tax on sales from $from to $to (Y-m-d, within one year), by month. On the * invoiced basis each invoice sent counts in the month of its date; on the * paid basis each payment carries its share of its invoice - a part-paid * invoice counts only the part that came in. Drafts, cancelled and * non-billable invoices count only for payments made on them. * * ['parts' => label => ['months', 'total'], 'numbers' => label => the * registration it is charged under, 'sales' / 'taxable' / 'nontaxable' / 'exempt' => * months, 'exempt_clients' => client id => ['name', 'number', 'expires', * 'expired', 'amount'], 'invoices' => how many]. */ function taxReportSales($from, $to, $basis = 'paid') { global $mysqli; $from = escapeSql($from); $to = escapeSql($to); if ($basis === 'invoiced') { $sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_discount_amount, invoice_client_id, MONTH(invoice_date) AS m, 1 AS w FROM invoices WHERE invoice_status IN ('Sent', 'Viewed', 'Partial', 'Paid') AND invoice_date >= '$from' AND invoice_date <= '$to'"); } else { $sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_discount_amount, invoice_client_id, MONTH(payment_date) AS m, payment_amount / invoice_amount AS w FROM payments JOIN invoices ON invoice_id = payment_invoice_id WHERE payment_archived_at IS NULL AND invoice_amount > 0 AND payment_date >= '$from' AND payment_date <= '$to'"); } $rows = []; while ($row = mysqli_fetch_assoc($sql)) { $rows[] = $row; } // Every invoice's lines in a few queries, not one each $items = []; $ids = array_values(array_unique(array_map(function ($row) { return intval($row['invoice_id']); }, $rows))); foreach (array_chunk($ids, 500) as $chunk) { $sql = mysqli_query($mysqli, "SELECT item_id, item_invoice_id, item_subtotal, item_tax, item_tax_id, tax_name, tax_percent FROM invoice_items LEFT JOIN taxes ON tax_id = item_tax_id WHERE item_invoice_id IN (" . implode(',', $chunk) . ")"); while ($item = mysqli_fetch_assoc($sql)) { $items[intval($item['item_invoice_id'])][] = $item; } } // Clients exempt now, with their certificates $exempt_clients = []; $sql = mysqli_query($mysqli, "SELECT client_id, client_name, client_tax_exempt_number, client_tax_exempt_expires FROM clients WHERE client_tax_exempt = 1"); while ($row = mysqli_fetch_assoc($sql)) { $expires = (string) $row['client_tax_exempt_expires']; $exempt_clients[intval($row['client_id'])] = ['name' => $row['client_name'], 'number' => (string) $row['client_tax_exempt_number'], 'expires' => $expires, 'expired' => $expires !== '' && $expires < date('Y-m-d'), 'amount' => 0.0]; } $out = ['parts' => [], 'numbers' => [], 'sales' => taxReportMonths(), 'taxable' => taxReportMonths(), 'nontaxable' => taxReportMonths(), 'exempt' => taxReportMonths(), 'exempt_clients' => [], 'invoices' => count($ids)]; $done = []; foreach ($rows as $row) { $invoice_id = intval($row['invoice_id']); $client_id = intval($row['invoice_client_id']); $m = intval($row['m']); $w = floatval($row['w']); if (!isset($done[$invoice_id])) { $done[$invoice_id] = taxReportInvoice($items[$invoice_id] ?? [], $row['invoice_discount_amount'], isset($exempt_clients[$client_id])); } $invoice = $done[$invoice_id]; foreach (['sales', 'taxable', 'nontaxable', 'exempt'] as $key) { $out[$key][$m] += $invoice[$key] * $w; } foreach ($invoice['parts'] as $part => $amount) { $out['parts'][$part] = $out['parts'][$part] ?? ['months' => taxReportMonths(), 'total' => 0.0]; $out['parts'][$part]['months'][$m] += $amount * $w; $out['parts'][$part]['total'] += $amount * $w; } $out['numbers'] += $invoice['numbers']; if ($invoice['exempt'] != 0) { $out['exempt_clients'][$client_id] = $out['exempt_clients'][$client_id] ?? $exempt_clients[$client_id]; $out['exempt_clients'][$client_id]['amount'] += $invoice['exempt'] * $w; } } ksort($out['parts'], SORT_NATURAL | SORT_FLAG_CASE); uasort($out['exempt_clients'], function ($a, $b) { return $b['amount'] <=> $a['amount']; }); return $out; } /** * Tax paid on purchases from $from to $to, by month - the tax included in * expenses, by part of its rate ('Other tax' for an amount with no rate of * ours). ['parts' => label => ['months', 'total'], 'purchases' => months - * what those expenses came to before tax, 'expenses' => how many]. */ function taxReportPurchases($from, $to) { global $mysqli; $from = escapeSql($from); $to = escapeSql($to); $out = ['parts' => [], 'purchases' => taxReportMonths(), 'expenses' => 0]; $sql = mysqli_query($mysqli, "SELECT MONTH(expense_date) AS m, expense_amount, expense_tax_amount, expense_tax_id FROM expenses WHERE expense_tax_amount != 0 AND expense_archived_at IS NULL AND expense_date >= '$from' AND expense_date <= '$to'"); while ($row = mysqli_fetch_assoc($sql)) { $m = intval($row['m']); $tax = floatval($row['expense_tax_amount']); $net = floatval($row['expense_amount']) - $tax; $out['purchases'][$m] += $net; $out['expenses']++; foreach (taxSplit($tax, taxRow($row['expense_tax_id']), $net, 'Other tax')['parts'] as $part => $amount) { $out['parts'][$part] = $out['parts'][$part] ?? ['months' => taxReportMonths(), 'total' => 0.0]; $out['parts'][$part]['months'][$m] += $amount; $out['parts'][$part]['total'] += $amount; } } ksort($out['parts'], SORT_NATURAL | SORT_FLAG_CASE); return $out; }