Taxes stage 1: Admin > Taxes rebuilt (renamed from Tax Rates) listing each rate with its whole percent, parts, description, registration number and usage (invoices, quotes, recurring invoices, products, clients), Current and Archived tabs showing what replaced a rate, Restore, Make default, and Delete only for a rate never used; rates made of parts (state and county, GST and QST) with parts charged on top of the ones above, the whole rate shown as typed, four decimal places and a registration number per rate or part printed with the company details on invoices, quotes and PDFs; changing the percent or parts of a rate on a sent invoice or quote makes a new rate of the same name and archives the old one, so billed lines keep the old rate while products, client rates, the default, recurring invoices and optionally drafts move to the new one, and archiving offers to move whatever still uses it; How lines are taxed settings for the default rate, taxing billed time, trips and expenses, and discounts taken off before tax (on by default, shared across lines by amount, paid, cancelled and answered documents never recalculated); clients get their own tax rate and tax exempt with certificate number and expiry on the Billing tab, new lines defaulting to the client's rate then the product's then the default and to no tax for exempt clients, exempt badge on the client header and overview, a warning with Take the tax off on open documents that still carry taxed lines for an exempt client, and Tax exempt with the certificate under the client on untaxed documents; totals, PDFs, guest and portal views show tax by part; Unbilled Time can bill at each client's usual rate and the ticket, Add Tickets, Add Expenses and Add Trips windows start on it; API client fields for rate and exemption and tax_id default on invoice item create; Tax Collected shows exact percents; demo taxes get parts and registration numbers, demo clients their rates and two nonprofits exemptions; database 3.1.1 makes tax_percent decimal(9,4) and adds tax description, registration and replaced-by, tax_components, client rate and exemption columns and the tax settings; new functions/tax.php and js/tax_form.js; changelog updated

This commit is contained in:
johnnyq
2026-10-17 13:00:54 -04:00
parent e29369926f
commit afaaa5fc32
50 changed files with 1919 additions and 318 deletions

View File

@@ -91,3 +91,14 @@ if (isset($_POST['client_notes'])) {
} else {
$notes = '';
}
// Its tax rate (0: the default) and exemption - client_default_tax_id,
// client_tax_exempt, client_tax_exempt_number, client_tax_exempt_expires
// (YYYY-MM-DD); those not sent keep what the client has
$tax_input = [];
foreach (['client_default_tax_id' => 'default_tax_id', 'client_tax_exempt' => 'tax_exempt', 'client_tax_exempt_number' => 'tax_exempt_number', 'client_tax_exempt_expires' => 'tax_exempt_expires'] as $api_field => $tax_field) {
if (isset($_POST[$api_field])) {
$tax_input[$tax_field] = $_POST[$api_field];
}
}
$tax_set_sql = taxClientSetSql($tax_input, $client_row ?? []);

View File

@@ -15,7 +15,7 @@ $insert_id = false;
if (!empty($name) && $client_id == 0) {
// Insert client
$insert_sql = mysqli_query($mysqli, "INSERT INTO clients SET client_name = '$name', client_type = '$type', client_website = '$website', client_referral = '$referral', client_rate = $rate, client_currency_code = '$currency_code', client_net_terms = $net_terms, client_tax_id_number = '$tax_id_number', client_abbreviation = '$abbreviation', client_lead = $lead, client_notes = '$notes', client_accessed_at = NOW()");
$insert_sql = mysqli_query($mysqli, "INSERT INTO clients SET client_name = '$name', client_type = '$type', client_website = '$website', client_referral = '$referral', client_rate = $rate, client_currency_code = '$currency_code', client_net_terms = $net_terms, client_tax_id_number = '$tax_id_number', client_abbreviation = '$abbreviation', client_lead = $lead, client_notes = '$notes', client_accessed_at = NOW(), $tax_set_sql");
// Check insert & get insert ID
if ($insert_sql) {

View File

@@ -31,7 +31,8 @@ if (!empty($client_id)) {
client_abbreviation = '$abbreviation',
client_tax_id_number = '$tax_id_number',
client_lead = $lead,
client_notes = '$notes'
client_notes = '$notes',
$tax_set_sql
WHERE client_id = $client_id
LIMIT 1
");

View File

@@ -15,7 +15,7 @@
* description optional - Item description
* qty required - Quantity
* price required - Unit price
* tax_id optional - Tax ID (default 0)
* tax_id optional - Tax ID (default 0, no tax), or 'default' for the client's usual rate
* item_order optional - Display order (default 0)
* product_id optional - Product ID for inventory tracking
*
@@ -37,7 +37,9 @@ $name = escapeSql(substr($_POST['name'], 0, 200) ?? '');
$description = escapeSql($_POST['description'] ?? '');
$qty = floatval($_POST['qty'] ?? 0);
$price = floatval($_POST['price'] ?? 0);
$tax_id = intval($_POST['tax_id'] ?? 0);
// 'default' takes the rate a new line for the client gets (functions/tax.php: taxDefaultFor())
$tax_id_posted = (string) ($_POST['tax_id'] ?? '0');
$tax_id = intval($tax_id_posted);
$item_order = intval($_POST['item_order'] ?? 0);
$product_id = intval($_POST['product_id'] ?? 0);
@@ -116,22 +118,14 @@ if (
}
// Tax
if ($tax_id > 0) {
$tax_sql = mysqli_query($mysqli, "SELECT tax_percent FROM taxes WHERE tax_id = $tax_id");
$tax_row = mysqli_fetch_assoc($tax_sql);
$tax_percent = floatval($tax_row['tax_percent']);
$tax_amount = $subtotal * $tax_percent / 100;
} else {
$tax_amount = 0;
// Tax - at the rate's parts, each to the cent; a rate that doesn't exist is none
if ($tax_id_posted === 'default') {
$tax_id = $product_id ? taxDefaultFor($client_id, 'product', intval(getFieldById('products', $product_id, 'product_tax_id'))) : taxDefaultFor($client_id);
}
$total = $subtotal + $tax_amount;
if ($tax_id > 0 && !taxRow($tax_id)) {
$tax_id = 0;
}
[$subtotal, $tax_amount, $total] = billingLineTax($qty, $price, $tax_id);
$insert_sql = mysqli_query(
$mysqli,
@@ -157,26 +151,8 @@ if (
productStockMove($product_id, -$qty, 'sold', ['invoice_id' => $invoice_id, 'item_id' => $insert_id, 'note' => stripslashes($invoice_prefix) . "$invoice_number - API"]);
}
// Recalculate invoice total
$items_sql = mysqli_query(
$mysqli,
"SELECT SUM(item_total) AS invoice_total
FROM invoice_items
WHERE item_invoice_id = $invoice_id"
);
$items_row = mysqli_fetch_assoc($items_sql);
$invoice_total = floatval($items_row['invoice_total']);
$new_invoice_amount = $invoice_total - $invoice_discount;
mysqli_query(
$mysqli,
"UPDATE invoices
SET invoice_amount = $new_invoice_amount
WHERE invoice_id = $invoice_id
LIMIT 1"
);
// The invoice's total - and each line's tax after any discount (functions/billing.php)
billingRecalc('invoice', $invoice_id);
logAudit(
"Invoice",